diff options
Diffstat (limited to 'lib')
8 files changed, 240 insertions, 125 deletions
diff --git a/lib/docu-list-rule/document-class/service.ts b/lib/docu-list-rule/document-class/service.ts index d92f3a95..9d3ff23a 100644 --- a/lib/docu-list-rule/document-class/service.ts +++ b/lib/docu-list-rule/document-class/service.ts @@ -149,51 +149,16 @@ export async function createDocumentClassCodeGroup(input: { description?: string }) { try { - // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환 - const formatValue = (input: string): string => { - // 공백 제거 및 대소문자 정규화 - const cleaned = input.trim().toLowerCase() - - // "class"가 포함되어 있으면 제거 - const withoutClass = cleaned.replace(/\s*class\s*/g, '') - - // 알파벳과 숫자만 추출 - const letters = withoutClass.replace(/[^a-z0-9]/g, '') - - if (letters.length === 0) { - return input.trim() // 변환할 수 없으면 원본 반환 - } - - // 첫 글자를 대문자로 변환하고 "Class" 추가 - return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class" - } - - const formattedValue = formatValue(input.value) - - // 해당 프로젝트의 자동으로 code 생성 (예: "DOC_CLASS_001", "DOC_CLASS_002" 등) - const existingClasses = await db - .select({ code: documentClasses.code }) - .from(documentClasses) - .where(eq(documentClasses.projectId, input.projectId)) // projectId로 변경 - .orderBy(desc(documentClasses.code)) - - let newCode = "DOC_CLASS_001" - if (existingClasses.length > 0) { - const lastClass = existingClasses[0] - if (lastClass.code) { - const lastNumber = parseInt(lastClass.code.replace("DOC_CLASS_", "")) || 0 - newCode = `DOC_CLASS_${String(lastNumber + 1).padStart(3, '0')}` - } - } + // Value는 1자리 대문자 알파벳 그대로 저장 (API DOC_CLASS 전송용) + const formattedValue = input.value.trim().toUpperCase() const [newDocumentClass] = await db .insert(documentClasses) .values({ - projectId: input.projectId, // projectId로 변경 - code: newCode, - value: formattedValue, + projectId: input.projectId, + value: formattedValue, // "A", "B", "C" 등 1자리 description: input.description || "", - codeGroupId: null, // Code Group 연결 제거 + codeGroupId: null, isActive: true, }) .returning({ id: documentClasses.id }) @@ -222,31 +187,13 @@ export async function updateDocumentClassCodeGroup(input: { description?: string }) { try { - // Value 자동 변환: "A", "AB", "A Class", "A CLASS" 등을 "A Class", "AB Class" 형태로 변환 - const formatValue = (value: string): string => { - // 공백 제거 및 대소문자 정규화 - const cleaned = value.trim().toLowerCase() - - // "class"가 포함되어 있으면 제거 - const withoutClass = cleaned.replace(/\s*class\s*/g, '') - - // 알파벳과 숫자만 추출 - const letters = withoutClass.replace(/[^a-z0-9]/g, '') - - if (letters.length === 0) { - return value.trim() // 변환할 수 없으면 원본 반환 - } - - // 첫 글자를 대문자로 변환하고 "Class" 추가 - return letters.charAt(0).toUpperCase() + letters.slice(1) + " Class" - } - - const formattedValue = formatValue(input.value) + // Value는 1자리 대문자 알파벳 그대로 저장 (API DOC_CLASS 전송용) + const formattedValue = input.value.trim().toUpperCase() const [updatedDocumentClass] = await db .update(documentClasses) .set({ - value: formattedValue, + value: formattedValue, // "A", "B", "C" 등 1자리 description: input.description || "", updatedAt: new Date(), }) @@ -630,4 +577,67 @@ export async function getProjectKindScheduleSetting(projectCode: string): Promis console.error('Error fetching schedule settings:', error) return [] } +} + +/** + * 프로젝트의 Document Class와 해당 Stage 옵션 매핑 조회 + * @param projectCode 프로젝트 코드 (예: "SN2190") + * @returns Document Class별 허용 Stage 목록 맵 + */ +export async function getProjectDocumentClassStages(projectCode: string): Promise<Record<string, string[]>> { + try { + // 1. 프로젝트 ID 조회 + const project = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!project.length) { + console.warn(`[getProjectDocumentClassStages] 프로젝트를 찾을 수 없습니다: ${projectCode}`); + return {}; + } + + const projectId = project[0].id; + + // 2. 프로젝트의 모든 Document Class와 옵션 조회 + const documentClassesWithOptions = await db + .select({ + docClassValue: documentClasses.value, + optionCode: documentClassOptions.optionCode, + }) + .from(documentClasses) + .leftJoin( + documentClassOptions, + eq(documentClasses.id, documentClassOptions.documentClassId) + ) + .where( + and( + eq(documentClasses.projectId, projectId), + eq(documentClasses.isActive, true), + eq(documentClassOptions.isActive, true) + ) + ) + .orderBy(documentClasses.value, documentClassOptions.sdq); + + // 3. Document Class별로 Stage 목록 그룹핑 + const stageMap: Record<string, string[]> = {}; + + for (const row of documentClassesWithOptions) { + if (!row.docClassValue || !row.optionCode) continue; + + if (!stageMap[row.docClassValue]) { + stageMap[row.docClassValue] = []; + } + + stageMap[row.docClassValue].push(row.optionCode); + } + + console.log(`[getProjectDocumentClassStages] ${projectCode}: ${Object.keys(stageMap).length}개 Document Class, 총 ${Object.values(stageMap).flat().length}개 Stage 옵션`); + + return stageMap; + } catch (error) { + console.error('[getProjectDocumentClassStages] 오류:', error); + return {}; + } }
\ No newline at end of file diff --git a/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx index e2cfc39e..6e8ac686 100644 --- a/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx +++ b/lib/docu-list-rule/document-class/table/document-class-add-dialog.tsx @@ -32,7 +32,10 @@ import { createDocumentClassCodeGroup } from "@/lib/docu-list-rule/document-clas import { useParams } from "next/navigation" const createDocumentClassSchema = z.object({ - value: z.string().min(1, "Value는 필수입니다."), + value: z.string() + .min(1, "Value는 필수입니다.") + .max(1, "Value는 1자리만 입력 가능합니다. (예: A, B, 0, 1)") + .regex(/^[A-Z0-9]$/, "대문자 알파벳 또는 숫자 1자리만 입력 가능합니다. (예: A, B, 0, 1)"), description: z.string().optional(), }) @@ -117,8 +120,17 @@ export function DocumentClassAddDialog({ <FormItem> <FormLabel>Value *</FormLabel> <FormControl> - <Input {...field} placeholder="예: A Class" /> + <Input + {...field} + placeholder="예: A" + maxLength={1} + className="uppercase" + onChange={(e) => field.onChange(e.target.value.toUpperCase())} + /> </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + 💡 대문자 알파벳 또는 숫자 1자리 (A, B, 0, 1 등) - API DOC_CLASS로 전송됩니다 + </div> <FormMessage /> </FormItem> )} @@ -131,8 +143,11 @@ export function DocumentClassAddDialog({ <FormItem> <FormLabel>Description</FormLabel> <FormControl> - <Input {...field} placeholder="예: A Class Description (선택사항)" /> + <Input {...field} placeholder="예: General Documents (선택사항)" /> </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + 선택사항: Document Class에 대한 추가 설명 + </div> <FormMessage /> </FormItem> )} diff --git a/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx index 8c391def..9d8d91e0 100644 --- a/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx +++ b/lib/docu-list-rule/document-class/table/document-class-table-columns.tsx @@ -107,43 +107,28 @@ export function getColumns({ setRowAction, onDetail }: GetColumnsProps): ColumnD // ---------------------------------------------------------------- const dataColumns: ColumnDef<typeof documentClasses.$inferSelect>[] = [ { - accessorKey: "code", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="코드" /> - ), - meta: { - excelHeader: "코드", - type: "text", - }, - cell: ({ row }) => row.getValue("code") ?? "", - minSize: 80 - }, - { accessorKey: "value", enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="값" /> + <DataTableColumnHeaderSimple column={column} title="클래스" /> ), meta: { - excelHeader: "값", + excelHeader: "클래스", type: "text", }, - cell: ({ row }) => row.getValue("value") ?? "", - minSize: 80 - }, - { - accessorKey: "description", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="설명" /> - ), - meta: { - excelHeader: "설명", - type: "text", + cell: ({ row }) => { + const value = row.getValue("value") as string + const description = row.getValue("description") as string + return ( + <div className="flex items-center gap-2"> + <span className="font-mono font-bold text-lg">{value}</span> + {description && ( + <span className="text-muted-foreground text-sm">- {description}</span> + )} + </div> + ) }, - cell: ({ row }) => row.getValue("description") ?? "", - minSize: 80 + minSize: 250 }, { diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index add69666..013b4a13 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -19,6 +19,8 @@ import { validateFileName } from "./swp-upload-validation-dialog"; import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog"; +import { getProjectDocumentClassStages } from "@/lib/docu-list-rule/document-class/service"; +import type { DocumentListItem } from "@/lib/swp/document-service"; interface SwpTableFilters { docNo?: string; @@ -38,7 +40,7 @@ interface SwpTableToolbarProps { vendorCode?: string; droppedFiles?: File[]; onFilesProcessed?: () => void; - documents?: Array<{ OWN_DOC_NO: string | null }>; // 업로드 권한 검증용 문서 목록 (OWN_DOC_NO 기준) + documents?: DocumentListItem[]; // 업로드 권한 검증 + DOC_TYPE 확인용 문서 목록 userId?: string; // 파일 취소 시 필요 } @@ -80,6 +82,10 @@ export function SwpTableToolbar({ }>>([]); const [showValidationDialog, setShowValidationDialog] = useState(false); + // Document Class-Stage 매핑 (프로젝트별) + const [documentClassStages, setDocumentClassStages] = useState<Record<string, string[]>>({}); + const [isLoadingDocClassStages, setIsLoadingDocClassStages] = useState(false); + /** * 업로드 가능한 문서번호 목록 추출 (OWN_DOC_NO 기준) */ @@ -90,11 +96,62 @@ export function SwpTableToolbar({ }, [documents]); /** + * 문서번호 → DOC_TYPE 매핑 (Stage 검증용) + */ + const docNoToDocTypeMap = useMemo(() => { + const map: Record<string, string> = {}; + for (const doc of documents) { + if (doc.OWN_DOC_NO && doc.DOC_TYPE) { + map[doc.OWN_DOC_NO] = doc.DOC_TYPE; + } + } + return map; + }, [documents]); + + /** * 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드) */ const isVendorMode = !!vendorCode; /** + * 프로젝트 변경 시 Document Class-Stage 매핑 로드 + */ + useEffect(() => { + if (!projNo) { + setDocumentClassStages({}); + return; + } + + let isCancelled = false; + + const loadDocumentClassStages = async () => { + try { + setIsLoadingDocClassStages(true); + const stages = await getProjectDocumentClassStages(projNo); + if (!isCancelled) { + setDocumentClassStages(stages); + console.log(`[SwpTableToolbar] Document Class-Stage 매핑 로드 완료:`, stages); + } + } catch (error) { + if (!isCancelled) { + console.error('[SwpTableToolbar] Document Class-Stage 매핑 로드 실패:', error); + setDocumentClassStages({}); + } + } finally { + if (!isCancelled) { + setIsLoadingDocClassStages(false); + } + } + }; + + loadDocumentClassStages(); + + return () => { + isCancelled = true; + }; + }, [projNo]); + + /** * 드롭된 파일 처리 - useEffect로 감지하여 자동 검증 */ useEffect(() => { @@ -120,9 +177,15 @@ export function SwpTableToolbar({ return; } - // 파일명 검증 (문서번호 권한 포함) + // 파일명 검증 (문서번호 권한 + Stage 검증 포함) const results = droppedFiles.map((file) => { - const validation = validateFileName(file.name, availableDocNos, isVendorMode); + const validation = validateFileName( + file.name, + availableDocNos, + isVendorMode, + docNoToDocTypeMap, + documentClassStages + ); return { file, valid: validation.valid, @@ -135,7 +198,7 @@ export function SwpTableToolbar({ setShowValidationDialog(true); onFilesProcessed?.(); } - }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode]); + }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode, docNoToDocTypeMap, documentClassStages]); /** * 파일 업로드 핸들러 @@ -171,9 +234,15 @@ export function SwpTableToolbar({ return; } - // 각 파일의 파일명 검증 (문서번호 권한 포함) + // 각 파일의 파일명 검증 (문서번호 권한 + Stage 검증 포함) const results = Array.from(selectedFiles).map((file) => { - const validation = validateFileName(file.name, availableDocNos, isVendorMode); + const validation = validateFileName( + file.name, + availableDocNos, + isVendorMode, + docNoToDocTypeMap, + documentClassStages + ); return { file, valid: validation.valid, diff --git a/lib/swp/table/swp-upload-validation-dialog.tsx b/lib/swp/table/swp-upload-validation-dialog.tsx index 8e786c8b..ef48f0c6 100644 --- a/lib/swp/table/swp-upload-validation-dialog.tsx +++ b/lib/swp/table/swp-upload-validation-dialog.tsx @@ -43,11 +43,15 @@ interface SwpUploadValidationDialogProps { * @param fileName 검증할 파일명 * @param availableDocNos 업로드 가능한 문서번호 목록 (선택) * @param isVendorMode 벤더 모드인지 여부 (true인 경우 문서번호 검증 필수) + * @param docNoToDocTypeMap 문서번호 → DOC_TYPE 매핑 (Stage 검증용) + * @param documentClassStages Document Class → 허용 Stage 목록 매핑 */ export function validateFileName( fileName: string, availableDocNos?: string[], - isVendorMode?: boolean + isVendorMode?: boolean, + docNoToDocTypeMap?: Record<string, string>, + documentClassStages?: Record<string, string[]> ): { valid: boolean; parsed?: { @@ -134,12 +138,36 @@ export function validateFileName( } } + // Stage 검증 (DOC_TYPE별 허용 Stage 확인) + const trimmedDocNo = ownDocNo.trim(); + const trimmedStage = stage.trim(); + + if (docNoToDocTypeMap && documentClassStages) { + const docType = docNoToDocTypeMap[trimmedDocNo]; + + if (docType) { + const allowedStages = documentClassStages[docType]; + + if (allowedStages && allowedStages.length > 0) { + // 허용된 Stage 목록이 있는 경우에만 검증 + if (!allowedStages.includes(trimmedStage)) { + return { + valid: false, + error: `문서 '${trimmedDocNo}'의 Document Class '${docType}'에서 Stage '${trimmedStage}'는 허용되지 않습니다. 허용된 Stage: ${allowedStages.join(", ")}`, + }; + } + } + // allowedStages가 비어있으면 Stage 검증을 스킵 (설정되지 않은 경우) + } + // docType이 없으면 Stage 검증을 스킵 (문서 정보가 없는 경우) + } + return { valid: true, parsed: { - ownDocNo: ownDocNo.trim(), + ownDocNo: trimmedDocNo, revNo: revNo.trim(), - stage: stage.trim(), + stage: trimmedStage, fileName: customFileName.trim(), extension, }, @@ -314,7 +342,7 @@ export function SwpUploadValidationDialog({ {/* 형식 안내 */} <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3"> <div className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1"> - 올바른 파일명 형식 + 📋 올바른 파일명 형식 </div> <code className="text-xs text-blue-700 dark:text-blue-300"> [OWN_DOC_NO]_[REV_NO]_[STAGE].[확장자] @@ -329,13 +357,18 @@ export function SwpUploadValidationDialog({ ※ 파일명에는 언더스코어(_)가 포함될 수 있습니다. </div> {isVendorMode && ( - <div className="text-xs text-blue-600 dark:text-blue-400 mt-2 pt-2 border-t border-blue-200 dark:border-blue-800"> - {availableDocNos.length > 0 ? ( - <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개</> - ) : ( - <>⚠️ 할당된 문서가 없습니다</> - )} - </div> + <> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-2 pt-2 border-t border-blue-200 dark:border-blue-800"> + {availableDocNos.length > 0 ? ( + <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개</> + ) : ( + <>⚠️ 할당된 문서가 없습니다</> + )} + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ⚠️ 각 문서의 Document Class에 정의된 Stage만 사용할 수 있습니다. + </div> + </> )} </div> </div> diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index a738eba9..0c972658 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -374,9 +374,9 @@ export function AddDocumentDialog({ const shiDocNumber = shiType ? generateShiPreview() : '' const cpyDocNumber = cpyType ? generateCpyPreview() : '' - // 선택된 Document Class의 code 값을 가져오기 (SWP API의 DOC_CLASS로 사용) + // 선택된 Document Class의 value 값을 가져오기 (SWP API의 DOC_CLASS로 사용) const selectedDocClass = documentClasses.find(cls => String(cls.id) === data.documentClassId) - const docClassCode = selectedDocClass?.code || '' + const docClassValue = selectedDocClass?.value || '' // documentNumberTypeId는 SHI를 우선 사용, 없으면 CPY 사용 const documentNumberTypeId = shiType?.id || cpyType?.id @@ -391,7 +391,7 @@ export function AddDocumentDialog({ contractId, documentNumberTypeId, documentClassId: Number(data.documentClassId), - docClass: docClassCode, // 첫 번째 선택기의 code 값 사용 (A, B, C 등) + docClass: docClassValue, // Document Class의 value 값 사용 (A Class, B Class 등) title: data.title, docNumber: shiDocNumber, vendorDocNumber: cpyDocNumber, @@ -610,8 +610,10 @@ export function AddDocumentDialog({ <SelectContent> {documentClasses.map((cls) => ( <SelectItem key={cls.id} value={String(cls.id)}> - <span className="font-mono font-semibold">{cls.code}</span> - {cls.description && <span className="text-muted-foreground"> - {cls.description}</span>} + <span className="font-mono font-bold">{cls.value}</span> + {cls.description && cls.description.trim() !== '' && ( + <span className="text-muted-foreground"> - {cls.description}</span> + )} </SelectItem> ))} </SelectContent> diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 30f57a23..1a7b1ab6 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -804,7 +804,8 @@ export async function getDocumentClassOptionsByContract(contractId: number) { .select({ id: documentClasses.id, code: documentClasses.code, - description: documentClasses.value, + value: documentClasses.value, + description: documentClasses.description, }) .from(documentClasses) .where( @@ -1374,18 +1375,18 @@ export async function uploadImportData(data: UploadData) { } - // 2. Document Class 매핑 가져오기 (트랜잭션 밖에서) - code 기반으로 통일 + // 2. Document Class 매핑 가져오기 (트랜잭션 밖에서) - value 기반으로 통일 const documentClassesData = await db .select({ id: documentClasses.id, - code: documentClasses.code, + value: documentClasses.value, description: documentClasses.description, }) .from(documentClasses) .where(and(eq(documentClasses.projectId, contract.projectId), eq(documentClasses.isActive, true))) const classMap = new Map( - documentClassesData.map(dc => [dc.code, dc.id]) + documentClassesData.map(dc => [dc.value, dc.id]) ) console.log(classMap) diff --git a/lib/vendor-document-list/plant/excel-import-stage.tsx b/lib/vendor-document-list/plant/excel-import-stage.tsx index 9bd9ed4c..24db6ea1 100644 --- a/lib/vendor-document-list/plant/excel-import-stage.tsx +++ b/lib/vendor-document-list/plant/excel-import-stage.tsx @@ -421,7 +421,7 @@ async function createImportTemplate(projectType: "ship" | "plant", contractId: n const res = await getDocumentClassOptionsByContract(contractId) if (!res.success) throw new Error(res.error || "데이터 로딩 실패") - const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }> + const documentClasses = res.data.classes as Array<{ id: number; value: string; description: string }> const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> // 클래스별 옵션 맵 @@ -460,7 +460,7 @@ async function createImportTemplate(projectType: "ship" | "plant", contractId: n const sampleRow = [ projectType === "ship" ? "SH-2024-001" : "PL-2024-001", "샘플 문서명", - firstClass ? firstClass.code : "", + firstClass ? firstClass.value : "", // value 사용 (A Class, B Class 등) ...(projectType === "plant" ? ["V-001"] : []), ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")), ] @@ -648,8 +648,8 @@ if (projectType === "plant") { styleHeaderRow(matrixHeaderRow, "FF34495E") for (const docClass of documentClasses) { const validStages = new Set(optionsByClassId.get(docClass.id) ?? []) - // Code를 사용하고 설명을 괄호 안에 추가 - const row = [`${docClass.code}`, ...allStageNames.map((stage) => (validStages.has(stage) ? "✓" : ""))] + // Value를 사용 (A Class, B Class 등) + const row = [`${docClass.value}`, ...allStageNames.map((stage) => (validStages.has(stage) ? "✓" : ""))] const dataRow = matrixSheet.addRow(row) allStageNames.forEach((stage, idx) => { const cell = dataRow.getCell(idx + 2) @@ -712,8 +712,8 @@ if (projectType === "plant") { const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) referenceSheet.getCell("A1").value = "DocumentClasses" documentClasses.forEach((dc, idx) => { - // 코드를 메인으로, 설명을 참고용으로 표시 - referenceSheet.getCell(`A${idx + 2}`).value = dc.code + // value를 메인으로 (A Class, B Class 등), 설명을 참고용으로 표시 + referenceSheet.getCell(`A${idx + 2}`).value = dc.value referenceSheet.getCell(`B${idx + 2}`).value = dc.description }) |
