summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx87
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts158
2 files changed, 241 insertions, 4 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index d4e0ff33..b6cf6d7a 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -44,7 +44,8 @@ import {
updateDocument,
deleteDocuments,
updateStage,
- getDocumentClassOptionsByContract
+ getDocumentClassOptionsByContract,
+ checkDuplicateDocuments
} from "./document-stages-service"
import { type Row } from "@tanstack/react-table"
@@ -127,6 +128,14 @@ export function AddDocumentDialog({
const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([])
const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({})
+ // Duplicate check states
+ const [duplicateWarning, setDuplicateWarning] = React.useState<{
+ isDuplicate: boolean
+ type?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ message?: string
+ }>({ isDuplicate: false })
+ const [isCheckingDuplicate, setIsCheckingDuplicate] = React.useState(false)
+
// Initialize react-hook-form
const form = useForm<DocumentFormValues>({
resolver: zodResolver(documentFormSchema),
@@ -167,6 +176,7 @@ export function AddDocumentDialog({
setShiComboBoxOptions({})
setCpyComboBoxOptions({})
setDocumentClassOptions([])
+ setDuplicateWarning({ isDuplicate: false })
}
}, [open])
@@ -359,6 +369,59 @@ export function AddDocumentDialog({
return preview && preview !== '' && !preview.includes('[value]')
}
+ // Real-time duplicate check with debounce
+ const checkDuplicateDebounced = React.useMemo(() => {
+ let timeoutId: NodeJS.Timeout | null = null
+
+ return (shiDocNo: string, cpyDocNo: string) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+
+ timeoutId = setTimeout(async () => {
+ // Skip if both are empty or incomplete
+ if ((!shiDocNo || shiDocNo.includes('[value]')) &&
+ (!cpyDocNo || cpyDocNo.includes('[value]'))) {
+ setDuplicateWarning({ isDuplicate: false })
+ return
+ }
+
+ setIsCheckingDuplicate(true)
+ try {
+ const result = await checkDuplicateDocuments(
+ contractId,
+ shiDocNo && !shiDocNo.includes('[value]') ? shiDocNo : undefined,
+ cpyDocNo && !cpyDocNo.includes('[value]') ? cpyDocNo : undefined
+ )
+
+ if (result.isDuplicate) {
+ setDuplicateWarning({
+ isDuplicate: true,
+ type: result.duplicateType,
+ message: result.message
+ })
+ } else {
+ setDuplicateWarning({ isDuplicate: false })
+ }
+ } catch (error) {
+ console.error('Duplicate check error:', error)
+ } finally {
+ setIsCheckingDuplicate(false)
+ }
+ }, 500) // 500ms debounce
+ }
+ }, [contractId])
+
+ // Trigger duplicate check when document numbers change
+ React.useEffect(() => {
+ const shiPreview = generateShiPreview()
+ const cpyPreview = generateCpyPreview()
+
+ if (shiPreview || cpyPreview) {
+ checkDuplicateDebounced(shiPreview, cpyPreview)
+ }
+ }, [shiFieldValues, cpyFieldValues])
+
const onSubmit = async (data: DocumentFormValues) => {
// Validate that at least one document number is configured and complete
if (shiType && !isShiComplete()) {
@@ -520,6 +583,24 @@ export function AddDocumentDialog({
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
+ {/* Duplicate Warning Alert */}
+ {duplicateWarning.isDuplicate && (
+ <Alert variant="destructive" className="border-red-300 bg-red-50 dark:bg-red-950/50">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ {duplicateWarning.message}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* Checking Duplicate Indicator */}
+ {isCheckingDuplicate && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ Checking for duplicates...
+ </div>
+ )}
+
{/* SHI Document Number Card */}
{shiType && (
<Card className="border-blue-200 dark:border-blue-800">
@@ -719,7 +800,9 @@ export function AddDocumentDialog({
form.formState.isSubmitting ||
!hasAvailableTypes ||
(shiType && !isShiComplete()) ||
- (cpyType && !isCpyComplete())
+ (cpyType && !isCpyComplete()) ||
+ duplicateWarning.isDuplicate ||
+ isCheckingDuplicate
}
>
{form.formState.isSubmitting ? (
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index ed4099b3..cf19eb41 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -878,6 +878,127 @@ interface CreateDocumentData {
vendorDocNumber?: string
}
+// ═══════════════════════════════════════════════════════════════════════════════
+// 문서번호 중복 체크 함수 (SHI_DOC_NO / OWN_DOC_NO 각각 중복 방지)
+// ═══════════════════════════════════════════════════════════════════════════════
+interface CheckDuplicateResult {
+ isDuplicate: boolean
+ duplicateType?: 'SHI_DOC_NO' | 'OWN_DOC_NO' | 'BOTH'
+ existingDocNumbers?: {
+ shiDocNo?: string
+ ownDocNo?: string
+ }
+ message?: string
+}
+
+/**
+ * 프로젝트 내에서 SHI_DOC_NO (docNumber)와 OWN_DOC_NO (vendorDocNumber) 중복 체크
+ * @param contractId 계약 ID (프로젝트 ID를 가져오기 위함)
+ * @param shiDocNo SHI 문서번호 (docNumber)
+ * @param ownDocNo CPY 문서번호 (vendorDocNumber)
+ * @param excludeDocumentId 수정 시 제외할 문서 ID (선택)
+ */
+export async function checkDuplicateDocuments(
+ contractId: number,
+ shiDocNo?: string,
+ ownDocNo?: string,
+ excludeDocumentId?: number
+): Promise<CheckDuplicateResult> {
+ try {
+ // 1. 계약에서 프로젝트 ID 가져오기
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ columns: { projectId: true },
+ })
+
+ if (!contract) {
+ return { isDuplicate: false, message: "유효하지 않은 계약입니다." }
+ }
+
+ const { projectId } = contract
+ let shiDuplicate = false
+ let ownDuplicate = false
+ const existingDocNumbers: { shiDocNo?: string; ownDocNo?: string } = {}
+
+ // 2. SHI_DOC_NO 중복 체크 (docNumber)
+ if (shiDocNo && shiDocNo.trim() !== '') {
+ const shiConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.docNumber, shiDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ shiConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingShiDoc = await db
+ .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber })
+ .from(stageDocuments)
+ .where(and(...shiConditions))
+ .limit(1)
+
+ if (existingShiDoc.length > 0) {
+ shiDuplicate = true
+ existingDocNumbers.shiDocNo = existingShiDoc[0].docNumber
+ }
+ }
+
+ // 3. OWN_DOC_NO 중복 체크 (vendorDocNumber)
+ if (ownDocNo && ownDocNo.trim() !== '') {
+ const ownConditions = [
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.vendorDocNumber, ownDocNo.trim()),
+ eq(stageDocuments.status, "ACTIVE"),
+ ]
+
+ if (excludeDocumentId) {
+ ownConditions.push(ne(stageDocuments.id, excludeDocumentId))
+ }
+
+ const existingOwnDoc = await db
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(and(...ownConditions))
+ .limit(1)
+
+ if (existingOwnDoc.length > 0) {
+ ownDuplicate = true
+ existingDocNumbers.ownDocNo = existingOwnDoc[0].vendorDocNumber || undefined
+ }
+ }
+
+ // 4. 결과 반환
+ if (shiDuplicate && ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'BOTH',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' and CPY Document Number '${ownDocNo}' already exist in this project.`
+ }
+ } else if (shiDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'SHI_DOC_NO',
+ existingDocNumbers,
+ message: `SHI Document Number '${shiDocNo}' already exists in this project.`
+ }
+ } else if (ownDuplicate) {
+ return {
+ isDuplicate: true,
+ duplicateType: 'OWN_DOC_NO',
+ existingDocNumbers,
+ message: `CPY Document Number '${ownDocNo}' already exists in this project.`
+ }
+ }
+
+ return { isDuplicate: false }
+ } catch (error) {
+ console.error("중복 체크 실패:", error)
+ return { isDuplicate: false, message: "중복 체크 중 오류가 발생했습니다." }
+ }
+}
+
// 문서 생성
export async function createDocument(data: CreateDocumentData) {
try {
@@ -907,6 +1028,20 @@ export async function createDocument(data: CreateDocumentData) {
return { success: false, error: configsResult.error }
}
+ /* ──────────────────────────────── 2. 중복 체크 (SHI_DOC_NO & OWN_DOC_NO) ─────────────────────────────── */
+ const duplicateCheck = await checkDuplicateDocuments(
+ data.contractId,
+ data.docNumber,
+ data.vendorDocNumber
+ )
+
+ if (duplicateCheck.isDuplicate) {
+ return {
+ success: false,
+ error: duplicateCheck.message || "Document number already exists in this project.",
+ duplicateType: duplicateCheck.duplicateType,
+ }
+ }
/* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */
const insertData = {
@@ -1403,7 +1538,7 @@ export async function uploadImportData(data: UploadData) {
try {
// 개별 트랜잭션으로 각 문서 처리
const result = await db.transaction(async (tx) => {
- // 먼저 문서가 이미 존재하는지 확인
+ // 먼저 SHI_DOC_NO (docNumber)가 이미 존재하는지 확인
const [existingDoc] = await tx
.select({ id: stageDocuments.id })
.from(stageDocuments)
@@ -1417,7 +1552,26 @@ export async function uploadImportData(data: UploadData) {
.limit(1)
if (existingDoc) {
- throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`)
+ throw new Error(`SHI Document Number "${doc.docNumber}" already exists in this project`)
+ }
+
+ // OWN_DOC_NO (vendorDocNumber) 중복 체크
+ if (doc.vendorDocNumber && doc.vendorDocNumber.trim() !== '') {
+ const [existingVendorDoc] = await tx
+ .select({ id: stageDocuments.id, vendorDocNumber: stageDocuments.vendorDocNumber })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, contract.projectId),
+ eq(stageDocuments.vendorDocNumber, doc.vendorDocNumber.trim()),
+ eq(stageDocuments.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ if (existingVendorDoc) {
+ throw new Error(`CPY Document Number "${doc.vendorDocNumber}" already exists in this project`)
+ }
}
// 3-1. 문서 생성