diff options
| -rw-r--r-- | lib/vendor-document-list/plant/document-stage-dialogs.tsx | 87 | ||||
| -rw-r--r-- | lib/vendor-document-list/plant/document-stages-service.ts | 158 |
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. 문서 생성 |
