diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
| commit | 10f90dc68dec42e9a64e081cc0dce6a484447290 (patch) | |
| tree | 5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib | |
| parent | 792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff) | |
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib')
29 files changed, 6936 insertions, 411 deletions
diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx index 6b6ab105..43c19e67 100644 --- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -51,7 +51,7 @@ const TEMPLATE_NAME_OPTIONS = [ "기술자료 요구서",
"비밀유지 계약서",
"표준하도급기본 계약서",
- "General GTC",
+ "GTC",
"안전보건관리 약정서",
"동반성장",
"윤리규범 준수 서약서",
diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx index 5783ca27..446112db 100644 --- a/lib/basic-contract/template/basic-contract-template-columns.tsx +++ b/lib/basic-contract/template/basic-contract-template-columns.tsx @@ -120,7 +120,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const handleViewDetails = () => {
// templateName이 "General GTC"인 경우 특별한 라우팅
- if (template.templateName === "General GTC") {
+ if (template.templateName === "GTC") {
router.push(`/evcp/basic-contract-template/gtc`);
} else {
// 일반적인 경우는 기존과 동일
@@ -141,8 +141,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={handleViewDetails}>
- <Eye className="mr-2 h-4 w-4" />
- View Details
+ {/* <Eye className="mr-2 h-4 w-4" /> */}
+ 상세보기
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -150,8 +150,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef <DropdownMenuItem
onSelect={() => setRowAction({ row, type: "createRevision" })}
>
- <GitBranch className="mr-2 h-4 w-4" />
- 리비전 생성
+ {/* <GitBranch className="mr-2 h-4 w-4" /> */}
+ 리비전 생성하기
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -182,8 +182,8 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef <DropdownMenuItem
onSelect={() => setRowAction({ row, type: "delete" })}
>
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ 삭제하기
+ {/* <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> */}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -221,7 +221,12 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const template = row.original;
const handleClick = () => {
- router.push(`/evcp/basic-contract-template/${template.id}`);
+ if (template.templateName === "GTC") {
+ router.push(`/evcp/basic-contract-template/gtc`);
+ } else {
+ // 일반적인 경우는 기존과 동일
+ router.push(`/evcp/basic-contract-template/${template.id}`);
+ }
};
return (
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx index 66037601..07bac31b 100644 --- a/lib/basic-contract/template/update-basicContract-sheet.tsx +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -58,7 +58,7 @@ const TEMPLATE_NAME_OPTIONS = [ "기술자료 요구서", "비밀유지 계약서", "표준하도급기본 계약서", - "General GTC", + "GTC", "안전보건관리 약정서", "동반성장", "윤리규범 준수 서약서", diff --git a/lib/docu-list-rule/combo-box-settings/service.ts b/lib/docu-list-rule/combo-box-settings/service.ts index 70046828..2c5ee42b 100644 --- a/lib/docu-list-rule/combo-box-settings/service.ts +++ b/lib/docu-list-rule/combo-box-settings/service.ts @@ -229,39 +229,34 @@ export async function createComboBoxOption(input: { } } - const codeGroupDescription = codeGroup[0].description - - // 해당 Code Group의 마지막 옵션 번호 찾기 - const lastOption = await db - .select({ code: comboBoxSettings.code }) + // 코드 중복 체크 + const existingOption = await db + .select({ id: comboBoxSettings.id }) .from(comboBoxSettings) - .where(eq(comboBoxSettings.codeGroupId, input.codeGroupId)) - .orderBy(sql`CAST(SUBSTRING(${comboBoxSettings.code} FROM ${codeGroupDescription.length + 2}) AS INTEGER) DESC`) + .where( + sql`${comboBoxSettings.codeGroupId} = ${input.codeGroupId} AND ${comboBoxSettings.code} = ${input.code}` + ) .limit(1) - let nextNumber = 1 - if (lastOption.length > 0 && lastOption[0].code) { - const prefix = `${codeGroupDescription}_` - if (lastOption[0].code.startsWith(prefix)) { - const lastNumber = parseInt(lastOption[0].code.replace(prefix, '')) - if (!isNaN(lastNumber)) { - nextNumber = lastNumber + 1 - } + if (existingOption.length > 0) { + return { + success: false, + error: "이미 존재하는 코드입니다." } } - const newCode = `${codeGroupDescription}_${nextNumber}` - const [newOption] = await db .insert(comboBoxSettings) .values({ codeGroupId: input.codeGroupId, - code: newCode, - description: input.description, + code: input.code, + description: input.description || "-", remark: input.remark, }) .returning({ id: comboBoxSettings.id }) + + revalidatePath("/evcp/docu-list-rule/combo-box-settings") return { diff --git a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx index 1fb8950c..a5a8af2f 100644 --- a/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx +++ b/lib/docu-list-rule/combo-box-settings/table/combo-box-options-add-dialog.tsx @@ -30,7 +30,8 @@ import { Input } from "@/components/ui/input" import { createComboBoxOption } from "../service" const createOptionSchema = z.object({ - description: z.string().min(1, "값은 필수입니다."), + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().default("-"), remark: z.string().optional(), }) @@ -48,7 +49,8 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt const form = useForm<CreateOptionSchema>({ resolver: zodResolver(createOptionSchema), defaultValues: { - description: "", + code: "", + description: "-", remark: "", }, }) @@ -58,8 +60,8 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt try { const result = await createComboBoxOption({ codeGroupId, - code: "", // 서비스에서 자동 생성 - description: data.description, + code: data.code, + description: data.description || "-", remark: data.remark, }) @@ -102,6 +104,19 @@ export function ComboBoxOptionsAddDialog({ codeGroupId, onSuccess }: ComboBoxOpt <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <FormField control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>코드</FormLabel> + <FormControl> + <Input {...field} placeholder="옵션 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} name="description" render={({ field }) => ( <FormItem> diff --git a/lib/export.ts b/lib/export.ts index d910ef6a..71fae264 100644 --- a/lib/export.ts +++ b/lib/export.ts @@ -2,7 +2,17 @@ import { type Table } from "@tanstack/react-table" import ExcelJS from "exceljs" /** - * `exportTableToExcel`: + * 컬럼 정의 인터페이스 + */ +export interface ExcelColumnDef { + id: string + header: string + accessor: string | ((row: any) => any) + group?: string +} + +/** + * `exportTableToExcel`: 기존 테이블 기반 내보내기 (페이지네이션된 데이터) * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) * - onlySelected: 선택된 행만 내보낼지 여부 * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) @@ -89,12 +99,100 @@ export async function exportTableToExcel<TData>( sheetData = [headerRow, ...dataRows] } + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename, useGroupHeader) +} + +/** + * `exportFullDataToExcel`: 전체 데이터를 Excel로 내보내기 + * - data: 전체 데이터 배열 + * - columns: 컬럼 정의 배열 + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportFullDataToExcel<TData>( + data: TData[], + columns: ExcelColumnDef[], + { + filename = "export", + useGroupHeader = true, + }: { + filename?: string + useGroupHeader?: boolean + } = {} +): Promise<void> { + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + row1.push(col.group ?? "") + // header + row2.push(col.header) + }) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => col.header) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename, useGroupHeader) +} + +/** + * 공통 Excel 파일 생성 및 다운로드 함수 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string, + useGroupHeader: boolean +): Promise<void> { // ────────────── ExcelJS 워크북/시트 생성 ────────────── const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet("Sheet1") - // (추가) 칼럼별 최대 길이 추적 - const maxColumnLengths = columns.map(() => 0) + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) sheetData.forEach((row) => { row.forEach((cellValue, colIdx) => { const cellText = cellValue?.toString() ?? "" @@ -141,7 +239,6 @@ export async function exportTableToExcel<TData>( // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── if (useGroupHeader) { // row1 (인덱스 1) = 그룹명 행 - // row2 (인덱스 2) = 실제 컬럼 헤더 행 const groupRowIndex = 1 const groupRow = worksheet.getRow(groupRowIndex) @@ -149,7 +246,7 @@ export async function exportTableToExcel<TData>( let start = 1 // 시작 열 인덱스 (1-based) let prevValue = groupRow.getCell(start).value - for (let c = 2; c <= columns.length; c++) { + for (let c = 2; c <= columnCount; c++) { const cellVal = groupRow.getCell(c).value if (cellVal !== prevValue) { // 이전 그룹명이 빈 문자열이 아니면 병합 @@ -173,12 +270,12 @@ export async function exportTableToExcel<TData>( groupRowIndex, start, groupRowIndex, - columns.length + columnCount ) } } - // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + // ────────────── 칼럼 너비 자동 조정 ────────────── maxColumnLengths.forEach((len, idx) => { // 최소 너비 10, +2 여백 worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) diff --git a/lib/exportFullData.ts b/lib/exportFullData.ts new file mode 100644 index 00000000..fde5aac2 --- /dev/null +++ b/lib/exportFullData.ts @@ -0,0 +1,189 @@ +import ExcelJS from "exceljs" + +/** + * 컬럼 정의 인터페이스 + */ +export interface ExcelColumnDef { + id: string + header: string + accessor: string | ((row: any) => any) + group?: string +} + +/** + * `exportFullDataToExcel`: 전체 데이터를 Excel로 내보내기 + * - data: 전체 데이터 배열 + * - columns: 컬럼 정의 배열 + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportFullDataToExcel<TData>( + data: TData[], + columns: ExcelColumnDef[], + { + filename = "export", + useGroupHeader = true, + }: { + filename?: string + useGroupHeader?: boolean + } = {} +): Promise<void> { + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + row1.push(col.group ?? "") + // header + row2.push(col.header) + }) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => col.header) + + // 데이터 행 생성 + const dataRows = data.map((item) => + columns.map((col) => { + let val: any + if (typeof col.accessor === "function") { + val = col.accessor(item) + } else { + val = (item as any)[col.accessor] + } + + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + c - 1 + ) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + columns.length + ) + } + } + + // ────────────── 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +}
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx index 30e369b4..f979f0ea 100644 --- a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx +++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx @@ -18,6 +18,8 @@ interface ClausePreviewViewerProps { document: any instance: WebViewerInstance | null setInstance: Dispatch<SetStateAction<WebViewerInstance | null>> + onSuccess?: () => void + onError?: () => void } export function ClausePreviewViewer({ @@ -25,140 +27,263 @@ export function ClausePreviewViewer({ document, instance, setInstance, + onSuccess, + onError, }: ClausePreviewViewerProps) { const [fileLoading, setFileLoading] = useState<boolean>(true) + const [loadingStage, setLoadingStage] = useState<string>("뷰어 준비 중...") const viewer = useRef<HTMLDivElement>(null) const initialized = useRef(false) const isCancelled = useRef(false) - // WebViewer 초기화 + // WebViewer 초기화 (단계별) useEffect(() => { if (!initialized.current && viewer.current) { initialized.current = true isCancelled.current = false - requestAnimationFrame(() => { - if (viewer.current) { - import("@pdftron/webviewer").then(({ default: WebViewer }) => { - if (isCancelled.current) { - console.log("📛 WebViewer 초기화 취소됨") - return - } - - const viewerElement = viewer.current - if (!viewerElement) return - - WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - enableOfficeEditing: true, - l: "ko", - // 미리보기 모드로 설정 - enableReadOnlyMode: false, - }, - viewerElement - ).then(async (instance: WebViewerInstance) => { - setInstance(instance) - - try { - const { disableElements, enableElements, setToolbarGroup } = instance.UI - - // 미리보기에 필요한 도구만 활성화 - enableElements([ - "toolbarGroup-View", - "zoomInButton", - "zoomOutButton", - "fitButton", - "rotateCounterClockwiseButton", - "rotateClockwiseButton", - ]) - - // 편집 도구는 비활성화 - disableElements([ - "toolbarGroup-Edit", - "toolbarGroup-Insert", - "toolbarGroup-Annotate", - "toolbarGroup-Shapes", - "toolbarGroup-Forms", - ]) - - setToolbarGroup("toolbarGroup-View") - - // 조항 데이터로 문서 생성 - await generateDocumentFromClauses(instance, clauses, document) - - } catch (uiError) { - console.warn("⚠️ UI 설정 중 오류:", uiError) - } finally { - setFileLoading(false) - } - }).catch((error) => { - console.error("❌ WebViewer 초기화 실패:", error) - setFileLoading(false) - toast.error("뷰어 초기화에 실패했습니다.") - }) - }) - } - }) + initializeViewerStepByStep() } return () => { if (instance) { - instance.UI.dispose() + try { + instance.UI.dispose() + } catch (error) { + console.warn("뷰어 정리 중 오류:", error) + } } - isCancelled.current = true + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); } }, []) - // 조항 데이터로 워드 문서 생성 + const initializeViewerStepByStep = async () => { + try { + setLoadingStage("라이브러리 로딩 중...") + + // 1단계: 라이브러리 동적 import (지연 추가) + await new Promise(resolve => setTimeout(resolve, 300)) + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewer.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setLoadingStage("뷰어 초기화 중...") + + // 2단계: WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + l: "ko", + enableReadOnlyMode: false, + }, + viewer.current + ) + + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setInstance(webviewerInstance) + setLoadingStage("UI 설정 중...") + + // 3단계: UI 설정 (약간의 지연 후) + await new Promise(resolve => setTimeout(resolve, 500)) + await configureViewerUI(webviewerInstance) + + setLoadingStage("문서 생성 중...") + + // 4단계: 문서 생성 (충분한 지연 후) + await new Promise(resolve => setTimeout(resolve, 800)) + await generateDocumentFromClauses(webviewerInstance, clauses, document) + + } catch (error) { + console.error("❌ WebViewer 단계별 초기화 실패:", error) + setFileLoading(false) + onError?.() // 초기화 실패 콜백 호출 + toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + + const configureViewerUI = async (webviewerInstance: WebViewerInstance) => { + try { + const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI + + // 미리보기에 필요한 도구만 활성화 + enableElements([ + "toolbarGroup-View", + "zoomInButton", + "zoomOutButton", + "fitButton", + "rotateCounterClockwiseButton", + "rotateClockwiseButton", + ]) + + // 편집 도구는 비활성화 + disableElements([ + "toolbarGroup-Edit", + "toolbarGroup-Insert", + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Forms", + ]) + + setToolbarGroup("toolbarGroup-View") + + console.log("✅ UI 설정 완료") + } catch (uiError) { + console.warn("⚠️ UI 설정 중 오류:", uiError) + // UI 설정 실패해도 계속 진행 + } + } + + // 문서 생성 함수 (재시도 로직 포함) const generateDocumentFromClauses = async ( - instance: WebViewerInstance, - clauses: GtcClauseTreeView[], - document: any + webviewerInstance: WebViewerInstance, + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 ) => { + const MAX_RETRIES = 3 + try { console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) - + // 활성화된 조항만 필터링하고 정렬 const activeClauses = clauses .filter(clause => clause.isActive !== false) .sort((a, b) => { - // sortOrder 또는 itemNumber로 정렬 if (a.sortOrder && b.sortOrder) { return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) } return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) }) - // ✅ DOCX 문서 생성 - const docxBlob = await generateDocxDocument(activeClauses, document) - - // ✅ DOCX 파일로 변환 - const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { - type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + if (activeClauses.length === 0) { + throw new Error("활성화된 조항이 없습니다.") + } + + setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`) + + // DOCX 문서 생성 (재시도 로직 포함) + const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document) + + // 파일 생성 + const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }) + + setLoadingStage("문서 로딩 중...") + + // WebViewer가 완전히 준비된 상태인지 확인 + await waitForViewerReady(webviewerInstance) + + // DOCX 문서 로드 (재시도 포함) + await loadDocumentWithRetry(webviewerInstance, docxFile) + + console.log("✅ DOCX 기반 문서 생성 완료") + toast.success("Word 문서 미리보기가 생성되었습니다.") + setFileLoading(false) + onSuccess?.() // 성공 콜백 호출 + + } catch (err) { + console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err) - // ✅ PDFTron에서 DOCX 문서로 로드 - await instance.UI.loadDocument(docxFile, { - filename: `${document?.title || 'GTC계약서'}_미리보기.docx`, - enableOfficeEditing: true, // DOCX 편집 모드 활성화 + if (retryCount < MAX_RETRIES) { + console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`) + setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`) + + await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000)) + + if (!isCancelled.current) { + return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1) + } + } else { + setFileLoading(false) + onError?.() // 실패 콜백 호출 + toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + } + } + } + + // WebViewer 준비 상태 확인 + const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => { + const startTime = Date.now() + + while (Date.now() - startTime < timeout) { + try { + // UI가 준비되었는지 확인 + if (webviewerInstance.UI && webviewerInstance.Core) { + console.log("✅ WebViewer 준비 완료") + return + } + } catch (error) { + // 아직 준비되지 않음 + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error("WebViewer 준비 시간 초과") + } + + // 문서 로드 재시도 함수 + const loadDocumentWithRetry = async ( + webviewerInstance: WebViewerInstance, + file: File, + retryCount = 0 + ) => { + const MAX_LOAD_RETRIES = 2 + + try { + await webviewerInstance.UI.loadDocument(file, { + filename: file.name, + enableOfficeEditing: true, }) + console.log("✅ 문서 로드 성공") + } catch (error) { + console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error) - console.log("✅ DOCX 기반 문서 생성 완료") - toast.success("Word 문서 미리보기가 생성되었습니다.") + if (retryCount < MAX_LOAD_RETRIES) { + await new Promise(resolve => setTimeout(resolve, 1000)) + return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1) + } else { + throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + } + + // DOCX 생성 재시도 함수 + const generateDocxDocumentWithRetry = async ( + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ): Promise<Blob> => { + try { + return await generateDocxDocument(clauses, document) + } catch (error) { + console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error) - } catch (err) { - console.error("❌ DOCX 문서 생성 중 오류:", err) - toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + if (retryCount < 2) { + await new Promise(resolve => setTimeout(resolve, 500)) + return generateDocxDocumentWithRetry(clauses, document, retryCount + 1) + } else { + throw error + } } } return ( <div className="relative w-full h-full overflow-hidden"> - <div - ref={viewer} + <div + ref={viewer} className="w-full h-full" style={{ position: 'relative', @@ -169,10 +294,13 @@ export function ClausePreviewViewer({ {fileLoading && ( <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10"> <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 생성 중...</p> + <p className="text-sm text-muted-foreground">{loadingStage}</p> <p className="text-xs text-muted-foreground mt-1"> {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중 </p> + <div className="mt-3 text-xs text-gray-400"> + 초기화에 시간이 걸릴 수 있습니다... + </div> </div> )} </div> @@ -180,82 +308,81 @@ export function ClausePreviewViewer({ ) } +// ===== 유틸리티 함수들 ===== - -// ===== data URL 판별 및 디코딩 유틸 ===== +// data URL 판별 및 디코딩 유틸 function isDataUrl(url: string) { - return /^data:/.test(url); - } - - function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { - // 형식: data:<mime>;base64,<payload> - const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); - if (!match) { - // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 - throw new Error("지원하지 않는 data URL 형식입니다."); - } - const mime = match[1]; - const base64 = match[2]; - const binary = atob(base64); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); - return { bytes, mime }; + return /^data:/.test(url); +} + +function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { + // 형식: data:<mime>;base64,<payload> + const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); + if (!match) { + // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 + throw new Error("지원하지 않는 data URL 형식입니다."); } - - // ===== helper: 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) ===== - async function fetchImageData(url: string, maxWidthPx = 500) { - let blob: Blob; - let bytes: Uint8Array; - - if (isDataUrl(url)) { - // data URL → Uint8Array, Blob - const { bytes: arr, mime } = dataUrlToUint8Array(url); - bytes = arr; - blob = new Blob([bytes], { type: mime }); - } else { - // http(s) 또는 상대 경로 - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); - blob = await res.blob(); - const arrayBuffer = await blob.arrayBuffer(); - bytes = new Uint8Array(arrayBuffer); - } - - // 원본 크기 파악 (공통) - const dims = await new Promise<{ width: number; height: number }>((resolve) => { - const img = new Image(); - const objectUrl = URL.createObjectURL(blob); - img.onload = () => { - const width = img.naturalWidth || 800; - const height = img.naturalHeight || 600; - URL.revokeObjectURL(objectUrl); - resolve({ width, height }); - }; - img.onerror = () => { - URL.revokeObjectURL(objectUrl); - resolve({ width: 800, height: 600 }); // 실패 시 기본값 - }; - img.src = objectUrl; - }); - - // 비율 유지 축소 - const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); - const width = Math.round((dims.width || maxWidthPx) * scale); - const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); - - return { data: bytes, width, height }; + const mime = match[1]; + const base64 = match[2]; + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, mime }; +} + +// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) +async function fetchImageData(url: string, maxWidthPx = 500) { + let blob: Blob; + let bytes: Uint8Array; + + if (isDataUrl(url)) { + // data URL → Uint8Array, Blob + const { bytes: arr, mime } = dataUrlToUint8Array(url); + bytes = arr; + blob = new Blob([bytes], { type: mime }); + } else { + // http(s) 또는 상대 경로 + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); + blob = await res.blob(); + const arrayBuffer = await blob.arrayBuffer(); + bytes = new Uint8Array(arrayBuffer); } + // 원본 크기 파악 (공통) + const dims = await new Promise<{ width: number; height: number }>((resolve) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => { + const width = img.naturalWidth || 800; + const height = img.naturalHeight || 600; + URL.revokeObjectURL(objectUrl); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve({ width: 800, height: 600 }); // 실패 시 기본값 + }; + img.src = objectUrl; + }); + + // 비율 유지 축소 + const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); + const width = Math.round((dims.width || maxWidthPx) * scale); + const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); + + return { data: bytes, width, height }; +} + // DOCX 문서 생성 (docx 라이브러리 사용) async function generateDocxDocument( - clauses: GtcClauseTreeView[], - document: any - ): Promise<Blob> { - const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); - - -function textToParagraphs(text: string, indentLeft: number) { + clauses: GtcClauseTreeView[], + document: any +): Promise<Blob> { + const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); + + function textToParagraphs(text: string, indentLeft: number) { const lines = text.split("\n"); return [ new Paragraph({ @@ -269,11 +396,10 @@ function textToParagraphs(text: string, indentLeft: number) { }), ]; } - - const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] + const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] -async function pushContentWithInlineImages( + async function pushContentWithInlineImages( content: string, indentLeft: number, children: any[], @@ -284,135 +410,135 @@ async function pushContentWithInlineImages( const start = match.index ?? 0; const end = start + match[0].length; const imageId = match[1]; - + // 앞부분 텍스트 if (start > lastIndex) { const txt = content.slice(lastIndex, start); children.push(...textToParagraphs(txt, indentLeft)); } - + // 이미지 삽입 const imgMeta = imageMap.get(imageId); if (imgMeta?.url) { - const { data, width, height } = await fetchImageData(imgMeta.url, 520); - children.push( - new Paragraph({ - children: [ - new ImageRun({ - data, - transformation: { width, height }, - }), - ], - indent: { left: indentLeft }, - }) - ); - // 사용된 이미지 표시(뒤에서 중복 추가 방지) - imageMap.delete(imageId); - } - // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) - - lastIndex = end; - } - - // 남은 꼬리 텍스트 - if (lastIndex < content.length) { - const tail = content.slice(lastIndex); - children.push(...textToParagraphs(tail, indentLeft)); - } - } - - - const documentTitle = document?.title || "GTC 계약서"; - const currentDate = new Date().toLocaleDateString("ko-KR"); - - // depth 추정/정렬 - const structuredClauses = organizeClausesByHierarchy(clauses); - - const children: any[] = [ - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], - }), - new Paragraph({ - alignment: AlignmentType.CENTER, - children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], - }), - new Paragraph({ text: "" }), - new Paragraph({ text: "" }), - ]; - - for (const clause of structuredClauses) { - const depth = Math.min(clause.estimatedDepth || 0, 3); - const indentLeft = depth * 400; // 번호/제목 - const indentContent = indentLeft + 200; // 본문/이미지 - - // 번호 + 제목 - children.push( - new Paragraph({ - children: [ - new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), - ...(clause.subtitle - ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] - : []), - ], - indent: { left: indentLeft }, - }) - ); - - const imageMap = new Map( - Array.isArray((clause as any).images) - ? (clause as any).images.map((im: any) => [String(im.id), im]) - : [] - ); - - // 내용 - const hasContent = clause.content && clause.content.trim(); - if (hasContent) { - await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); - } - - // else { - // children.push( - // new Paragraph({ - // // children: [new TextRun({ text: "(상세 내용 없음)", italics: true, color: "6b7280", size: 20 })], - // indent: { left: indentContent }, - // }) - // ); - // } - - // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) - - for (const [, imgMeta] of imageMap) { try { const { data, width, height } = await fetchImageData(imgMeta.url, 520); children.push( new Paragraph({ - children: [new ImageRun({ data, transformation: { width, height } })], - indent: { left: indentContent }, + children: [ + new ImageRun({ + data, + transformation: { width, height }, + }), + ], + indent: { left: indentLeft }, }) ); - } catch (e) { + // 사용된 이미지 표시(뒤에서 중복 추가 방지) + imageMap.delete(imageId); + } catch (imgError) { + console.warn("이미지 로드 실패:", imgMeta, imgError); + // 이미지 로드 실패시 텍스트로 대체 children.push( new Paragraph({ - children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], - indent: { left: indentContent }, + children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], + indent: { left: indentLeft }, }) ); - console.warn("이미지 로드 실패(잔여):", imgMeta, e); } } - - // 조항 간 간격 - children.push(new Paragraph({ text: "" })); + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + lastIndex = end; + } + + // 남은 꼬리 텍스트 + if (lastIndex < content.length) { + const tail = content.slice(lastIndex); + children.push(...textToParagraphs(tail, indentLeft)); + } + } + + const documentTitle = document?.title || "GTC 계약서"; + const currentDate = new Date().toLocaleDateString("ko-KR"); + + // depth 추정/정렬 + const structuredClauses = organizeClausesByHierarchy(clauses); + + const children: any[] = [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], + }), + new Paragraph({ text: "" }), + new Paragraph({ text: "" }), + ]; + + for (const clause of structuredClauses) { + const depth = Math.min(clause.estimatedDepth || 0, 3); + const indentLeft = depth * 400; // 번호/제목 + const indentContent = indentLeft + 200; // 본문/이미지 + + // 번호 + 제목 + children.push( + new Paragraph({ + children: [ + new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), + ...(clause.subtitle + ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] + : []), + ], + indent: { left: indentLeft }, + }) + ); + + const imageMap = new Map( + Array.isArray((clause as any).images) + ? (clause as any).images.map((im: any) => [String(im.id), im]) + : [] + ); + + // 내용 + const hasContent = clause.content && clause.content.trim(); + if (hasContent) { + await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); } - - const doc = new Document({ - sections: [{ properties: {}, children }], - }); - - return await Packer.toBlob(doc); + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + for (const [, imgMeta] of imageMap) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [new ImageRun({ data, transformation: { width, height } })], + indent: { left: indentContent }, + }) + ); + } catch (e) { + children.push( + new Paragraph({ + children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], + indent: { left: indentContent }, + }) + ); + console.warn("이미지 로드 실패(잔여):", imgMeta, e); + } + } + + // 조항 간 간격 + children.push(new Paragraph({ text: "" })); } + const doc = new Document({ + sections: [{ properties: {}, children }], + }); + + return await Packer.toBlob(doc); +} + // 조항들을 계층구조로 정리 function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { // depth가 없는 경우 itemNumber로 depth 추정 @@ -421,9 +547,9 @@ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) })).sort((a, b) => { // itemNumber 기준 자연 정렬 - return a.itemNumber.localeCompare(b.itemNumber, undefined, { - numeric: true, - sensitivity: 'base' + return a.itemNumber.localeCompare(b.itemNumber, undefined, { + numeric: true, + sensitivity: 'base' }) }) } @@ -433,3 +559,12 @@ function estimateDepthFromItemNumber(itemNumber: string): number { const parts = itemNumber.split('.') return Math.max(0, parts.length - 1) } + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +};
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/excel-import.tsx b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx new file mode 100644 index 00000000..d8f435f7 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx @@ -0,0 +1,340 @@ +import { ExcelColumnDef } from "@/lib/export" +import ExcelJS from "exceljs" + +/** + * Excel 템플릿 다운로드 함수 + */ +export async function downloadExcelTemplate( + columns: ExcelColumnDef[], + { + filename = "template", + includeExampleData = true, + useGroupHeader = true, + }: { + filename?: string + includeExampleData?: boolean + useGroupHeader?: boolean + } = {} +): Promise<void> { + let sheetData: any[][] + + if (useGroupHeader) { + // 2줄 헤더 생성 + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + row1.push(col.group ?? "") + row2.push(col.header) + }) + + sheetData = [row1, row2] + + // 예시 데이터 추가 + if (includeExampleData) { + // 빈 행 3개 추가 (사용자가 데이터 입력할 공간) + for (let i = 0; i < 3; i++) { + const exampleRow = columns.map((col) => { + // 컬럼 타입에 따른 예시 데이터 + if (col.id === "itemNumber") return i === 0 ? `1.${i + 1}` : i === 1 ? "2.1" : "" + if (col.id === "subtitle") return i === 0 ? "예시 조항 소제목" : i === 1 ? "하위 조항 예시" : "" + if (col.id === "content") return i === 0 ? "조항의 상세 내용을 입력합니다." : i === 1 ? "하위 조항의 내용" : "" + if (col.id === "category") return i === 0 ? "일반조항" : i === 1 ? "특별조항" : "" + if (col.id === "sortOrder") return i === 0 ? "10" : i === 1 ? "20" : "" + if (col.id === "parentId") return i === 1 ? "1" : "" + if (col.id === "isActive") return i === 0 ? "활성" : i === 1 ? "활성" : "" + if (col.id === "editReason") return i === 0 ? "신규 작성" : "" + return "" + }) + sheetData.push(exampleRow) + } + } + } else { + // 1줄 헤더 + const headerRow = columns.map((col) => col.header) + sheetData = [headerRow] + + if (includeExampleData) { + // 예시 데이터 행 추가 + const exampleRow = columns.map(() => "") + sheetData.push(exampleRow) + } + } + + // ExcelJS로 워크북 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("GTC조항템플릿") + + // 데이터 추가 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, // 연한 파란색 + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + }) + } + } else { + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, + } + }) + } + } + + // 예시 데이터 행 스타일 + if (includeExampleData && idx === (useGroupHeader ? 2 : 1)) { + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFEAA7" }, // 연한 노란색 + } + cell.font = { italic: true, color: { argb: "FF666666" } } + }) + } + }) + + // 그룹 헤더 병합 + if (useGroupHeader) { + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + let start = 1 + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + start = c + prevValue = cellVal + } + } + + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // 컬럼 너비 자동 조정 + columns.forEach((col, idx) => { + let width = Math.max(col.header.length + 5, 15) + + // 특정 컬럼은 더 넓게 + if (col.id === "content" || col.id === "subtitle") { + width = 30 + } else if (col.id === "itemNumber") { + width = 15 + } else if (col.id === "editReason") { + width = 20 + } + + worksheet.getColumn(idx + 1).width = width + }) + + // 사용 안내 시트 추가 + const instructionSheet = workbook.addWorksheet("사용안내") + const instructions = [ + ["GTC 조항 Excel 가져오기 사용 안내"], + [""], + ["1. 기본 규칙"], + [" - 첫 번째 시트(GTC조항템플릿)에 데이터를 입력하세요"], + [" - 헤더 행은 수정하지 마세요"], + [" - 예시 데이터(노란색 행)는 삭제하고 실제 데이터를 입력하세요"], + [""], + ["2. 필수 입력 항목"], + [" - 채번: 필수 입력 (예: 1.1, 2.3.1)"], + [" - 소제목: 필수 입력"], + [""], + ["3. 선택 입력 항목"], + [" - 상세항목: 조항의 구체적인 내용"], + [" - 분류: 조항의 카테고리 (예: 일반조항, 특별조항)"], + [" - 순서: 숫자 (기본값: 10, 20, 30...)"], + [" - 상위 조항 ID: 계층 구조를 만들 때 사용"], + [" - 활성 상태: '활성' 또는 '비활성' (기본값: 활성)"], + [" - 편집 사유: 작성/수정 이유"], + [""], + ["4. 자동 처리 항목"], + [" - ID, 생성일, 수정일: 시스템에서 자동 생성"], + [" - 계층 깊이: 상위 조항 ID를 기반으로 자동 계산"], + [" - 전체 경로: 시스템에서 자동 생성"], + [""], + ["5. 채번 규칙"], + [" - 같은 부모 하에서 채번은 유일해야 합니다"], + [" - 예: 상위 조항이 같으면 1.1, 1.2는 가능하지만 1.1이 중복되면 오류"], + [""], + ["6. 계층 구조 만들기"], + [" - 상위 조항 ID: 기존 조항의 ID를 입력"], + [" - 예: ID가 5인 조항 하위에 조항을 만들려면 상위 조항 ID에 5 입력"], + [" - 최상위 조항은 상위 조항 ID를 비워두세요"], + [""], + ["7. 주의사항"], + [" - 순서는 숫자로 입력하세요 (소수점 가능: 10, 15.5, 20)"], + [" - 상위 조항 ID는 반드시 존재하는 조항의 ID여야 합니다"], + [" - 파일 저장 시 .xlsx 형식으로 저장하세요"], + ] + + instructions.forEach((instruction, idx) => { + const row = instructionSheet.addRow(instruction) + if (idx === 0) { + row.font = { bold: true, size: 14 } + row.alignment = { horizontal: "center" } + } else if (instruction[0]?.match(/^\d+\./)) { + row.font = { bold: true } + } + }) + + instructionSheet.getColumn(1).width = 80 + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + +/** + * Excel 파일에서 데이터 파싱 + */ +export async function parseExcelFile<TData>( + file: File, + columns: ExcelColumnDef[], + { + hasGroupHeader = true, + sheetName = "GTC조항템플릿", + }: { + hasGroupHeader?: boolean + sheetName?: string + } = {} +): Promise<{ + data: Partial<TData>[] + errors: string[] +}> { + const errors: string[] = [] + const data: Partial<TData>[] = [] + + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(sheetName) || workbook.worksheets[0] + + if (!worksheet) { + errors.push("워크시트를 찾을 수 없습니다.") + return { data, errors } + } + + // 헤더 행 인덱스 결정 + const headerRowIndex = hasGroupHeader ? 2 : 1 + const dataStartRowIndex = headerRowIndex + 1 + + // 헤더 검증 + const headerRow = worksheet.getRow(headerRowIndex) + const expectedHeaders = columns.map(col => col.header) + + for (let i = 0; i < expectedHeaders.length; i++) { + const cellValue = headerRow.getCell(i + 1).value?.toString() || "" + if (cellValue !== expectedHeaders[i]) { + errors.push(`헤더가 일치하지 않습니다. 예상: "${expectedHeaders[i]}", 실제: "${cellValue}"`) + } + } + + if (errors.length > 0) { + return { data, errors } + } + + // 데이터 파싱 + let rowIndex = dataStartRowIndex + while (rowIndex <= worksheet.actualRowCount) { + const row = worksheet.getRow(rowIndex) + + // 빈 행 체크 (모든 셀이 비어있으면 스킵) + const isEmpty = columns.every((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + return !cellValue || cellValue.toString().trim() === "" + }) + + if (isEmpty) { + rowIndex++ + continue + } + + const rowData: Partial<TData> = {} + let hasError = false + + columns.forEach((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + let processedValue: any = cellValue + + // 데이터 타입별 처리 + if (cellValue !== null && cellValue !== undefined) { + const strValue = cellValue.toString().trim() + + // 특별한 처리가 필요한 컬럼들 + if (col.id === "isActive") { + processedValue = strValue === "활성" + } else if (col.id === "sortOrder") { + const numValue = parseFloat(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else if (col.id === "parentId") { + const numValue = parseInt(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else { + processedValue = strValue + } + } + + // 필수 필드 검증 + if ((col.id === "itemNumber" || col.id === "subtitle") && (!processedValue || processedValue === "")) { + errors.push(`${rowIndex}행: ${col.header}은(는) 필수 입력 항목입니다.`) + hasError = true + } + + if (processedValue !== null && processedValue !== undefined && processedValue !== "") { + (rowData as any)[col.id] = processedValue + } + }) + + if (!hasError) { + data.push(rowData) + } + + rowIndex++ + } + + } catch (error) { + errors.push(`파일 파싱 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`) + } + + return { data, errors } +}
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx index 2a7452ef..ea516f49 100644 --- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx @@ -32,42 +32,170 @@ import { type GtcClauseTreeView } from "@/db/schema/gtc" import { CreateGtcClauseDialog } from "./create-gtc-clause-dialog" import { PreviewDocumentDialog } from "./preview-document-dialog" import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" +import { exportTableToExcel } from "@/lib/export" +import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" +import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "../../service" +import { ImportExcelDialog } from "./import-excel-dialog" +import { toast } from "@/hooks/use-toast" interface GtcClausesTableToolbarActionsProps { table: Table<GtcClauseTreeView> documentId: number document: any + currentUserId?: number // 현재 사용자 ID 추가 } +// GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) +const gtcClauseExcelColumns: ExcelColumnDef[] = [ + { + id: "itemNumber", + header: "채번", + accessor: "itemNumber", + group: "필수 정보" + }, + { + id: "subtitle", + header: "소제목", + accessor: "subtitle", + group: "필수 정보" + }, + { + id: "content", + header: "상세항목", + accessor: "content", + group: "기본 정보" + }, + { + id: "category", + header: "분류", + accessor: "category", + group: "기본 정보" + }, + { + id: "sortOrder", + header: "순서", + accessor: "sortOrder", + group: "순서" + }, + { + id: "parentId", + header: "상위 조항 ID", + accessor: "parentId", + group: "계층 구조" + }, + { + id: "isActive", + header: "활성 상태", + accessor: (row) => row.isActive ? "활성" : "비활성", + group: "상태" + }, + { + id: "editReason", + header: "편집 사유", + accessor: "editReason", + group: "추가 정보" + } +] + export function GtcClausesTableToolbarActions({ table, documentId, document, + currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) const [showBulkUpdateDialog, setShowBulkUpdateDialog] = React.useState(false) const [showGenerateVariablesDialog, setShowGenerateVariablesDialog] = React.useState(false) - const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) // ✅ 미리보기 다이얼로그 상태 + const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) const selectedRows = table.getSelectedRowModel().rows const selectedCount = selectedRows.length - // ✅ 테이블의 모든 데이터 가져오기 + // 테이블의 모든 데이터 가져오기 (현재 페이지만) const allClauses = table.getRowModel().rows.map(row => row.original) - const handleExportToExcel = () => { - // Excel 내보내기 로직 - console.log("Export to Excel") + // 현재 페이지 데이터만 Excel로 내보내기 + const handleExportCurrentPageToExcel = () => { + exportTableToExcel(table, { + filename: `gtc-clauses-page-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + + // 전체 데이터를 Excel로 내보내기 + const handleExportAllToExcel = async () => { + try { + setIsExporting(true) + + // 서버에서 전체 데이터 가져오기 + const allData = await getAllGtcClausesForExport(documentId) + + // 전체 데이터를 Excel로 내보내기 + await exportFullDataToExcel( + allData, + gtcClauseExcelColumns, + { + filename: `gtc-clauses-all-${new Date().toISOString().split('T')[0]}`, + useGroupHeader: true + } + ) + + toast({ + title: "내보내기 완료", + description: `총 ${allData.length}개의 조항이 Excel 파일로 내보내졌습니다.`, + }) + } catch (error) { + console.error("Excel export failed:", error) + toast({ + title: "내보내기 실패", + description: "Excel 파일 내보내기 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsExporting(false) + } } - const handleImportFromExcel = () => { - // Excel 가져오기 로직 - console.log("Import from Excel") + // Excel 데이터 가져오기 처리 + const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { + try { + const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + + if (result.success) { + toast({ + title: "가져오기 성공", + description: `${result.importedCount}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 테이블 새로고침 + handleRefreshTable() + } else { + const errorMessage = result.errors.length > 0 + ? `오류: ${result.errors.slice(0, 3).join(', ')}${result.errors.length > 3 ? '...' : ''}` + : "알 수 없는 오류가 발생했습니다." + + toast({ + title: "가져오기 실패", + description: errorMessage, + variant: "destructive" + }) + + // 오류가 있어도 일부는 성공했을 수 있음 + if (result.importedCount > 0) { + handleRefreshTable() + } + + throw new Error("Import failed with errors") + } + } catch (error) { + console.error("Excel import failed:", error) + throw error // ImportExcelDialog에서 처리하도록 다시 throw + } } const handlePreviewDocument = () => { - // ✅ 미리보기 다이얼로그 열기 setShowPreviewDialog(true) } @@ -108,9 +236,8 @@ export function GtcClausesTableToolbarActions({ {selectedCount > 0 && ( <> <DeleteGtcClausesDialog - gtcClauses={allClauses} - onSuccess={() => table.toggleAllRowsSelected(false)} - + gtcClauses={allClauses} + onSuccess={() => table.toggleAllRowsSelected(false)} /> </> )} @@ -118,33 +245,39 @@ export function GtcClausesTableToolbarActions({ {/* 관리 도구 드롭다운 */} <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm"> + <Button variant="outline" size="sm" disabled={isExporting}> <Settings2 className="mr-2 h-4 w-4" /> 관리 도구 </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - {/* <DropdownMenuItem onClick={handleReorderClauses}> - <ArrowUpDown className="mr-2 h-4 w-4" /> - 조항 순서 변경 + <DropdownMenuContent align="end" className="w-64"> + <DropdownMenuItem onClick={handleExportCurrentPageToExcel}> + <Download className="mr-2 h-4 w-4" /> + 현재 페이지 Excel로 내보내기 </DropdownMenuItem> - <DropdownMenuItem onClick={handleGenerateVariables}> - <Wand2 className="mr-2 h-4 w-4" /> - PDFTron 변수명 일괄 생성 - </DropdownMenuItem> */} - - <DropdownMenuSeparator /> - - <DropdownMenuItem onClick={handleExportToExcel}> + <DropdownMenuItem + onClick={handleExportAllToExcel} + disabled={isExporting} + > <Download className="mr-2 h-4 w-4" /> - Excel로 내보내기 + {isExporting ? "내보내는 중..." : "전체 데이터 Excel로 내보내기"} </DropdownMenuItem> - <DropdownMenuItem onClick={handleImportFromExcel}> - <Upload className="mr-2 h-4 w-4" /> - Excel에서 가져오기 - </DropdownMenuItem> + <DropdownMenuSeparator /> + + <ImportExcelDialog + documentId={documentId} + columns={gtcClauseExcelColumns} + onSuccess={handleRefreshTable} + onImport={handleImportExcelData} + trigger={ + <DropdownMenuItem onSelect={(e) => e.preventDefault()}> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </DropdownMenuItem> + } + /> <DropdownMenuSeparator /> @@ -152,11 +285,6 @@ export function GtcClausesTableToolbarActions({ <Eye className="mr-2 h-4 w-4" /> 문서 미리보기 </DropdownMenuItem> - - {/* <DropdownMenuItem onClick={handleGenerateDocument}> - <FileText className="mr-2 h-4 w-4" /> - 최종 문서 생성 - </DropdownMenuItem> */} </DropdownMenuContent> </DropdownMenu> @@ -180,7 +308,7 @@ export function GtcClausesTableToolbarActions({ )} </div> - {/* ✅ 미리보기 다이얼로그 */} + {/* 미리보기 다이얼로그 */} <PreviewDocumentDialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog} diff --git a/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx new file mode 100644 index 00000000..f37566fc --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx @@ -0,0 +1,381 @@ +"use client" + +import * as React from "react" +import { Upload, Download, FileText, AlertCircle, CheckCircle2, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" + +import { type ExcelColumnDef } from "@/lib/export" +import { downloadExcelTemplate, parseExcelFile } from "./excel-import" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { toast } from "@/hooks/use-toast" + +interface ImportExcelDialogProps { + documentId: number + columns: ExcelColumnDef[] + onSuccess?: () => void + onImport?: (data: Partial<GtcClauseTreeView>[]) => Promise<void> + trigger?: React.ReactNode +} + +type ImportStep = "upload" | "preview" | "importing" | "complete" + +export function ImportExcelDialog({ + documentId, + columns, + onSuccess, + onImport, + trigger, +}: ImportExcelDialogProps) { + const [open, setOpen] = React.useState(false) + const [step, setStep] = React.useState<ImportStep>("upload") + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [parsedData, setParsedData] = React.useState<Partial<GtcClauseTreeView>[]>([]) + const [errors, setErrors] = React.useState<string[]>([]) + const [isProcessing, setIsProcessing] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 다이얼로그 열기/닫기 시 상태 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + // 다이얼로그 닫을 때 상태 초기화 + setStep("upload") + setSelectedFile(null) + setParsedData([]) + setErrors([]) + setIsProcessing(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + try { + await downloadExcelTemplate(columns, { + filename: `gtc-clauses-template-${new Date().toISOString().split('T')[0]}`, + includeExampleData: true, + useGroupHeader: true, + }) + + toast({ + title: "템플릿 다운로드 완료", + description: "Excel 템플릿이 다운로드되었습니다. 템플릿에 데이터를 입력한 후 업로드해주세요.", + }) + } catch (error) { + toast({ + title: "템플릿 다운로드 실패", + description: "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + + // 파일 선택 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (file.type !== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" && + file.type !== "application/vnd.ms-excel") { + toast({ + title: "잘못된 파일 형식", + description: "Excel 파일(.xlsx, .xls)만 업로드할 수 있습니다.", + variant: "destructive", + }) + return + } + setSelectedFile(file) + } + } + + // 파일 파싱 + const handleParseFile = async () => { + if (!selectedFile) return + + setIsProcessing(true) + try { + const result = await parseExcelFile<GtcClauseTreeView>( + selectedFile, + columns, + { + hasGroupHeader: true, + sheetName: "GTC조항템플릿", + } + ) + + setParsedData(result.data) + setErrors(result.errors) + + if (result.errors.length > 0) { + toast({ + title: "파싱 완료 (오류 있음)", + description: `${result.data.length}개의 행을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`, + variant: "destructive", + }) + } else { + toast({ + title: "파싱 완료", + description: `${result.data.length}개의 행이 성공적으로 파싱되었습니다.`, + }) + } + + setStep("preview") + } catch (error) { + toast({ + title: "파싱 실패", + description: "파일 파싱 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsProcessing(false) + } + } + + // 데이터 가져오기 실행 + const handleImportData = async () => { + if (parsedData.length === 0 || !onImport) return + + setStep("importing") + try { + await onImport(parsedData) + setStep("complete") + + toast({ + title: "가져오기 완료", + description: `${parsedData.length}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 성공 콜백 호출 후 잠시 후 다이얼로그 닫기 + setTimeout(() => { + onSuccess?.() + setOpen(false) + }, 2000) + } catch (error) { + toast({ + title: "가져오기 실패", + description: "데이터 가져오기 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("preview") + } + } + + const renderUploadStep = () => ( + <div className="space-y-6"> + <div className="text-center"> + <FileText className="mx-auto h-12 w-12 text-muted-foreground" /> + <h3 className="mt-4 text-lg font-semibold">Excel 파일로 조항 가져오기</h3> + <p className="mt-2 text-sm text-muted-foreground"> + 먼저 템플릿을 다운로드하여 데이터를 입력한 후 업로드해주세요. + </p> + </div> + + <div className="space-y-4"> + <div> + <Button + onClick={handleDownloadTemplate} + variant="outline" + className="w-full" + > + <Download className="mr-2 h-4 w-4" /> + Excel 템플릿 다운로드 + </Button> + <p className="mt-2 text-xs text-muted-foreground"> + 템플릿에는 입력 가이드와 예시 데이터가 포함되어 있습니다. + </p> + </div> + + <Separator /> + + <div> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + onChange={handleFileSelect} + className="hidden" + /> + <Button + onClick={() => fileInputRef.current?.click()} + variant={selectedFile ? "secondary" : "outline"} + className="w-full" + > + <Upload className="mr-2 h-4 w-4" /> + {selectedFile ? selectedFile.name : "Excel 파일 선택"} + </Button> + </div> + + {selectedFile && ( + <Button + onClick={handleParseFile} + disabled={isProcessing} + className="w-full" + > + {isProcessing ? "파싱 중..." : "파일 분석하기"} + </Button> + )} + </div> + </div> + ) + + const renderPreviewStep = () => ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">데이터 미리보기</h3> + <div className="flex items-center gap-2"> + <Badge variant="secondary"> + {parsedData.length}개 행 + </Badge> + {errors.length > 0 && ( + <Badge variant="destructive"> + {errors.length}개 오류 + </Badge> + )} + </div> + </div> + + {errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <div className="space-y-1"> + <div className="font-medium">다음 오류들을 확인해주세요:</div> + <ul className="list-disc list-inside space-y-1 text-sm"> + {errors.slice(0, 5).map((error, index) => ( + <li key={index}>{error}</li> + ))} + {errors.length > 5 && ( + <li>... 및 {errors.length - 5}개 추가 오류</li> + )} + </ul> + </div> + </AlertDescription> + </Alert> + )} + + <ScrollArea className="h-[300px] border rounded-md"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12">#</TableHead> + <TableHead>채번</TableHead> + <TableHead>소제목</TableHead> + <TableHead>상세항목</TableHead> + <TableHead>분류</TableHead> + <TableHead>상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {parsedData.map((item, index) => ( + <TableRow key={index}> + <TableCell>{index + 1}</TableCell> + <TableCell className="font-mono"> + {item.itemNumber || "-"} + </TableCell> + <TableCell className="max-w-[200px] truncate"> + {item.subtitle || "-"} + </TableCell> + <TableCell className="max-w-[300px] truncate"> + {item.content || "-"} + </TableCell> + <TableCell>{item.category || "-"}</TableCell> + <TableCell> + <Badge variant={item.isActive ? "default" : "secondary"}> + {item.isActive ? "활성" : "비활성"} + </Badge> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => setStep("upload")} + className="flex-1" + > + 다시 선택 + </Button> + <Button + onClick={handleImportData} + disabled={parsedData.length === 0 || errors.length > 0} + className="flex-1" + > + {errors.length > 0 ? "오류 수정 후 가져오기" : `${parsedData.length}개 조항 가져오기`} + </Button> + </div> + </div> + ) + + const renderImportingStep = () => ( + <div className="text-center py-8"> + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> + <h3 className="mt-4 text-lg font-semibold">가져오는 중...</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항을 데이터베이스에 저장하고 있습니다. + </p> + </div> + ) + + const renderCompleteStep = () => ( + <div className="text-center py-8"> + <CheckCircle2 className="mx-auto h-12 w-12 text-green-500" /> + <h3 className="mt-4 text-lg font-semibold">가져오기 완료!</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항이 성공적으로 가져와졌습니다. + </p> + </div> + ) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button variant="outline" size="sm"> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> + <DialogHeader> + <DialogTitle>Excel에서 조항 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 여러 조항을 한 번에 가져올 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="mt-4"> + {step === "upload" && renderUploadStep()} + {step === "preview" && renderPreviewStep()} + {step === "importing" && renderImportingStep()} + {step === "complete" && renderCompleteStep()} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx index 29ab1b5a..3639c0f3 100644 --- a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx +++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx @@ -12,7 +12,8 @@ import { Loader2, FileText, RefreshCw, - Settings + Settings, + AlertCircle } from "lucide-react" import { toast } from "sonner" @@ -35,6 +36,7 @@ export function PreviewDocumentDialog({ const [isGenerating, setIsGenerating] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState<any>(null) + const [hasError, setHasError] = React.useState(false) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -52,38 +54,84 @@ export function PreviewDocumentDialog({ const handleGeneratePreview = async () => { setIsGenerating(true) + setHasError(false) + setDocumentGenerated(false) + try { - // 잠시 후 문서 생성 완료로 설정 (실제로는 뷰어에서 처리) - setTimeout(() => { + // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 + // 여기서는 상태만 관리 + console.log("🚀 문서 미리보기 생성 시작") + + // ClausePreviewViewer가 완전히 로드될 때까지 기다림 + await new Promise(resolve => setTimeout(resolve, 2000)) + + if (!hasError) { setDocumentGenerated(true) - setIsGenerating(false) toast.success("문서 미리보기가 생성되었습니다.") - }, 1500) + } } catch (error) { - setIsGenerating(false) + console.error("문서 생성 중 오류:", error) + setHasError(true) toast.error("문서 생성 중 오류가 발생했습니다.") + } finally { + setIsGenerating(false) } } const handleExportDocument = () => { if (viewerInstance) { - // PDFTron의 다운로드 기능 실행 - viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` - }) - toast.success("문서가 다운로드됩니다.") + try { + // PDFTron의 다운로드 기능 실행 + viewerInstance.UI.downloadPdf({ + filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + }) + toast.success("PDF 다운로드가 시작됩니다.") + } catch (error) { + console.error("다운로드 오류:", error) + toast.error("다운로드 중 오류가 발생했습니다.") + } + } else { + toast.error("뷰어가 준비되지 않았습니다.") } } const handleRegenerateDocument = () => { + console.log("🔄 문서 재생성 시작") setDocumentGenerated(false) + setHasError(false) handleGeneratePreview() } + const handleViewerSuccess = React.useCallback(() => { + setDocumentGenerated(true) + setIsGenerating(false) + setHasError(false) + }, []) + + const handleViewerError = React.useCallback(() => { + setHasError(true) + setIsGenerating(false) + setDocumentGenerated(false) + }, []) + + // 다이얼로그가 열릴 때 자동으로 미리보기 생성 + React.useEffect(() => { + if (props.open && !documentGenerated && !isGenerating && !hasError) { + const timer = setTimeout(() => { + handleGeneratePreview() + }, 300) // 다이얼로그 애니메이션 후 시작 + + return () => clearTimeout(timer) + } + }, [props.open, documentGenerated, isGenerating, hasError]) + + // 다이얼로그가 닫힐 때 상태 초기화 React.useEffect(() => { - // 다이얼로그가 열릴 때 자동으로 미리보기 생성 - if (props.open && !documentGenerated && !isGenerating) { - handleGeneratePreview() + if (!props.open) { + setDocumentGenerated(false) + setIsGenerating(false) + setHasError(false) + setViewerInstance(null) } }, [props.open]) @@ -107,9 +155,15 @@ export function PreviewDocumentDialog({ <FileText className="h-4 w-4" /> <span className="font-medium">{document?.title || 'GTC 계약서'}</span> <Badge variant="outline">{stats.total}개 조항</Badge> + {hasError && ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + 오류 발생 + </Badge> + )} </div> <div className="flex items-center gap-2"> - {documentGenerated && ( + {documentGenerated && !hasError && ( <> <Button variant="outline" @@ -117,19 +171,31 @@ export function PreviewDocumentDialog({ onClick={handleRegenerateDocument} disabled={isGenerating} > - <RefreshCw className="mr-2 h-3 w-3" /> + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> 재생성 </Button> <Button variant="outline" size="sm" onClick={handleExportDocument} + disabled={!viewerInstance} > <Download className="mr-2 h-3 w-3" /> PDF 다운로드 </Button> </> )} + {hasError && ( + <Button + variant="default" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 다시 시도 + </Button> + )} </div> </div> @@ -164,6 +230,21 @@ export function PreviewDocumentDialog({ <p className="text-sm text-muted-foreground"> {stats.total}개의 조항을 배치하고 있습니다. </p> + <p className="text-xs text-gray-400 mt-2"> + 초기화에 시간이 걸릴 수 있습니다... + </p> + </div> + ) : hasError ? ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> + <AlertCircle className="h-12 w-12 text-destructive mb-4" /> + <p className="text-lg font-medium mb-2 text-destructive">문서 생성 실패</p> + <p className="text-sm text-muted-foreground mb-4 text-center max-w-md"> + 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. + </p> + <Button onClick={handleRegenerateDocument} disabled={isGenerating}> + <RefreshCw className="mr-2 h-4 w-4" /> + 다시 시도 + </Button> </div> ) : documentGenerated ? ( <ClausePreviewViewer @@ -171,6 +252,8 @@ export function PreviewDocumentDialog({ document={document} instance={viewerInstance} setInstance={setViewerInstance} + onSuccess={handleViewerSuccess} + onError={handleViewerError} /> ) : ( <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index 308c52bf..4d11ad0a 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -1,13 +1,13 @@ 'use server' import { revalidateTag, unstable_cache } from "next/cache" -import { and, desc, asc, eq, or, ilike, count, max , inArray} from "drizzle-orm" +import { and, desc, asc, eq, or, ilike, count, max , inArray, isNotNull, notInArray} from "drizzle-orm" import db from "@/db/db" -import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" import { projects } from "@/db/schema/projects" import { users } from "@/db/schema/users" import { filterColumns } from "@/lib/filter-columns" -import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema } from "./validations" +import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" /** * 프로젝트 존재 여부 확인 @@ -330,4 +330,518 @@ export async function getGtcDocumentById(id: number) { tags: [`gtc-document-${id}`, "gtc-documents"], } )() +} + +// 복제 함수 +export async function cloneGtcDocument( + data: CloneGtcDocumentSchema & { createdById: number } +): Promise<{ data?: GtcDocument; error?: string }> { + try { + return await db.transaction(async (tx) => { + // 1. 원본 문서 조회 + const [sourceDocument] = await tx + .select() + .from(gtcDocuments) + .where(eq(gtcDocuments.id, data.sourceDocumentId)) + + if (!sourceDocument) { + throw new Error("원본 문서를 찾을 수 없습니다.") + } + + // 2. 새로운 리비전 번호 계산 + const nextRevision = await getNextRevision(data.type, data.projectId || undefined) + + // 3. 새 문서 생성 + const [newDocument] = await tx + .insert(gtcDocuments) + .values({ + type: data.type, + projectId: data.projectId, + title: data.title || sourceDocument.title, + revision: nextRevision, + fileName: sourceDocument.fileName, // 파일 정보도 복사 + filePath: sourceDocument.filePath, + fileSize: sourceDocument.fileSize, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || `${sourceDocument.title || 'GTC 문서'} v${sourceDocument.revision}에서 복제`, + isActive: true, + }) + .returning() + + // 4. 원본 문서의 모든 clauses 조회 + const sourceClauses = await tx + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, data.sourceDocumentId)) + .orderBy(gtcClauses.sortOrder) + + // 5. clauses 복제 (ID 매핑을 위한 Map 생성) + if (sourceClauses.length > 0) { + const clauseIdMapping = new Map<number, number>() + + // 첫 번째 pass: 모든 clauses를 복사하고 ID 매핑 생성 + for (const sourceClause of sourceClauses) { + const [newClause] = await tx + .insert(gtcClauses) + .values({ + documentId: newDocument.id, + parentId: null, // 첫 번째 pass에서는 null로 설정 + itemNumber: sourceClause.itemNumber, + category: sourceClause.category, + subtitle: sourceClause.subtitle, + content: sourceClause.content, + sortOrder: sourceClause.sortOrder, + depth: sourceClause.depth, + fullPath: sourceClause.fullPath, + images: sourceClause.images, + isActive: sourceClause.isActive, + createdById: data.createdById, + updatedById: data.createdById, + editReason: data.editReason || "문서 복제", + }) + .returning() + + clauseIdMapping.set(sourceClause.id, newClause.id) + } + + // 두 번째 pass: parentId 관계 설정 + for (const sourceClause of sourceClauses) { + if (sourceClause.parentId) { + const newParentId = clauseIdMapping.get(sourceClause.parentId) + const newClauseId = clauseIdMapping.get(sourceClause.id) + + if (newParentId && newClauseId) { + await tx + .update(gtcClauses) + .set({ parentId: newParentId }) + .where(eq(gtcClauses.id, newClauseId)) + } + } + } + } + + revalidateTag("gtc-documents") + revalidateTag(`gtc-clauses-${newDocument.id}`) + + return { data: newDocument } + }) + } catch (error) { + console.error("Error cloning GTC document:", error) + return { + error: error instanceof Error + ? error.message + : "문서 복제 중 오류가 발생했습니다." + } + } +} + + +// 새 함수: GTC 문서가 없는 프로젝트만 조회 +export async function getAvailableProjectsForGtc(): Promise<ProjectForFilter[]> { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + const usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + // 사용된 프로젝트가 없으면 모든 프로젝트 반환 + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +// 복제시 사용할 함수: 특정 프로젝트는 제외하고 조회 +export async function getAvailableProjectsForGtcExcluding(excludeProjectId?: number): Promise<ProjectForFilter[]> { + // 이미 GTC 문서가 있는 프로젝트 ID들 조회 + const projectsWithGtc = await db + .selectDistinct({ + projectId: gtcDocuments.projectId + }) + .from(gtcDocuments) + .where(isNotNull(gtcDocuments.projectId)) + + let usedProjectIds = projectsWithGtc + .map(row => row.projectId) + .filter((id): id is number => id !== null) + + // 제외할 프로젝트 ID가 있다면 사용된 ID 목록에서 제거 (복제시 원본 프로젝트는 선택 가능) + if (excludeProjectId) { + usedProjectIds = usedProjectIds.filter(id => id !== excludeProjectId) + } + + // GTC 문서가 없는 프로젝트들만 반환 + if (usedProjectIds.length === 0) { + return await getProjectsForSelect() + } + + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(notInArray(projects.id, usedProjectIds)) + .orderBy(projects.name) +} + +export async function hasStandardGtcDocument(): Promise<boolean> { + const result = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where(eq(gtcDocuments.type, "standard")) + .limit(1) + + return result.length > 0 +} + +export async function getAllGtcClausesForExport(documentId: number): Promise<GtcClauseTreeView[]> { + try { + // 실제 데이터베이스 쿼리 로직을 여기에 구현 + // 예시: 문서 ID에 해당하는 모든 조항을 트리 뷰 형태로 가져오기 + const clauses = await db + .select() + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + // 여기에 필요한 JOIN, ORDER BY 등을 추가 + .orderBy(gtcClauses.sortOrder) + + // GtcClauseTreeView 형태로 변환하여 반환 + return clauses.map((clause) => ({ + ...clause, + // 필요한 추가 필드들을 여기에 매핑 + })) as GtcClauseTreeView[] + } catch (error) { + console.error("Failed to fetch GTC clauses for export:", error) + throw new Error("Failed to fetch GTC clauses for export") + } +} + + +interface ImportGtcClauseData { + itemNumber: string + subtitle: string + content?: string + category?: string + sortOrder?: number + parentId?: number | null + depth?: number + fullPath?: string + images?: any[] + isActive?: boolean + editReason?: string +} + +interface ImportResult { + success: boolean + importedCount: number + errors: string[] + duplicates: string[] +} + +/** + * Excel에서 가져온 GTC 조항들을 데이터베이스에 저장 + */ +export async function importGtcClausesFromExcel( + documentId: number, + data: Partial<GtcClauseTreeView>[], + userId: number = 1 // TODO: 실제 사용자 ID로 교체 +): Promise<ImportResult> { + const result: ImportResult = { + success: false, + importedCount: 0, + errors: [], + duplicates: [] + } + + try { + // 데이터 검증 및 변환 + const validData: ImportGtcClauseData[] = [] + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + result.errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + continue + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + result.errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + continue + } + + // 중복 채번 체크 (같은 문서 내에서, 같은 부모 하에서) + const existingClause = await db + .select({ id: gtcClauses.id, itemNumber: gtcClauses.itemNumber }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.parentId, item.parentId || null), + eq(gtcClauses.itemNumber, item.itemNumber.trim()) + ) + ) + .limit(1) + + if (existingClause.length > 0) { + result.duplicates.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + continue + } + + // 상위 조항 ID 검증 (제공된 경우) + if (item.parentId && typeof item.parentId === 'number') { + const parentExists = await db + .select({ id: gtcClauses.id }) + .from(gtcClauses) + .where( + and( + eq(gtcClauses.documentId, documentId), + eq(gtcClauses.id, item.parentId) + ) + ) + .limit(1) + + if (parentExists.length === 0) { + result.errors.push(`${rowNumber}행: 상위 조항 ID ${item.parentId}를 찾을 수 없습니다.`) + continue + } + } + + // sortOrder를 decimal로 변환 + let sortOrder = 0 + if (item.sortOrder !== undefined) { + if (typeof item.sortOrder === 'number') { + sortOrder = item.sortOrder + } else if (typeof item.sortOrder === 'string') { + const parsed = parseFloat(item.sortOrder) + if (!isNaN(parsed)) { + sortOrder = parsed + } + } + } else { + // 기본값: (현재 인덱스 + 1) * 10 + sortOrder = (validData.length + 1) * 10 + } + + // depth 계산 (parentId가 있으면 부모의 depth + 1, 아니면 0) + let depth = 0 + if (item.parentId) { + const parentClause = await db + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, item.parentId)) + .limit(1) + + if (parentClause.length > 0) { + depth = (parentClause[0].depth || 0) + 1 + } + } + + // 유효한 데이터 추가 + validData.push({ + itemNumber: item.itemNumber.trim(), + subtitle: item.subtitle.trim(), + content: item.content?.toString().trim() || null, + category: item.category?.toString().trim() || null, + sortOrder: sortOrder, + parentId: item.parentId || null, + isActive: typeof item.isActive === 'boolean' ? item.isActive : true, + editReason: item.editReason?.toString().trim() || null, + }) + } + + // 오류가 있거나 중복이 있으면 가져오기 중단 + if (result.errors.length > 0 || result.duplicates.length > 0) { + return result + } + + // 트랜잭션으로 데이터 저장 + await db.transaction(async (tx) => { + for (const clauseData of validData) { + try { + // depth 재계산 (저장 시점에서) + let finalDepth = 0 + if (clauseData.parentId) { + const parentClause = await tx + .select({ depth: gtcClauses.depth }) + .from(gtcClauses) + .where(eq(gtcClauses.id, clauseData.parentId)) + .limit(1) + + if (parentClause.length > 0) { + finalDepth = (parentClause[0].depth || 0) + 1 + } + } + + await tx.insert(gtcClauses).values({ + documentId, + parentId: clauseData.parentId, + itemNumber: clauseData.itemNumber, + category: clauseData.category, + subtitle: clauseData.subtitle, + content: clauseData.content, + sortOrder:clauseData.sortOrder? clauseData.sortOrder.toString() :"0", // decimal로 저장 + depth: finalDepth, + fullPath: null, // 추후 별도 로직에서 생성 + images: null, // Excel 가져오기에서는 이미지 제외 + isActive: clauseData.isActive, + createdById: userId, + updatedById: userId, + editReason: clauseData.editReason, + createdAt: new Date(), + updatedAt: new Date(), + }) + + result.importedCount++ + } catch (insertError) { + result.errors.push(`"${clauseData.subtitle}" 저장 중 오류: ${insertError instanceof Error ? insertError.message : '알 수 없는 오류'}`) + } + } + }) + + result.success = result.importedCount > 0 && result.errors.length === 0 + + return result + + } catch (error) { + result.errors.push(`가져오기 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + return result + } +} + +/** + * Excel 가져오기 전 데이터 유효성 검사 + */ +export async function validateGtcClausesImport( + documentId: number, + data: Partial<GtcClauseTreeView>[] +): Promise<{ + valid: boolean + errors: string[] + warnings: string[] + summary: { + totalRows: number + validRows: number + duplicateCount: number + errorCount: number + } +}> { + const errors: string[] = [] + const warnings: string[] = [] + let validRows = 0 + let duplicateCount = 0 + + try { + // 기존 채번들 가져오기 (중복 체크용) + const existingItems = await db + .select({ + itemNumber: gtcClauses.itemNumber, + parentId: gtcClauses.parentId + }) + .from(gtcClauses) + .where(eq(gtcClauses.documentId, documentId)) + + // 채번-부모ID 조합으로 중복 체크 세트 생성 + const existingItemSet = new Set( + existingItems.map(item => `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}`) + ) + + // 같은 파일 내에서의 중복 체크를 위한 세트 + const currentFileItems = new Set<string>() + + for (let i = 0; i < data.length; i++) { + const item = data[i] + const rowNumber = i + 1 + let hasError = false + + // 필수 필드 검증 + if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') { + errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`) + hasError = true + } + + if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') { + errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`) + hasError = true + } + + if (item.itemNumber && item.itemNumber.trim() !== '') { + const itemKey = `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}` + + // DB에 이미 존재하는지 체크 + if (existingItemSet.has(itemKey)) { + warnings.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`) + duplicateCount++ + } + + // 현재 파일 내에서 중복인지 체크 + if (currentFileItems.has(itemKey)) { + errors.push(`${rowNumber}행: 채번 "${item.itemNumber}"가 파일 내에서 중복됩니다.`) + hasError = true + } else { + currentFileItems.add(itemKey) + } + } + + // 숫자 필드 검증 + if (item.sortOrder !== undefined) { + const sortOrderNum = typeof item.sortOrder === 'string' ? parseFloat(item.sortOrder) : item.sortOrder + if (typeof sortOrderNum !== 'number' || isNaN(sortOrderNum) || sortOrderNum < 0) { + warnings.push(`${rowNumber}행: 순서는 0 이상의 숫자여야 합니다.`) + } + } + + if (!hasError) { + validRows++ + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + summary: { + totalRows: data.length, + validRows, + duplicateCount, + errorCount: errors.length + } + } + + } catch (error) { + errors.push(`유효성 검사 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + + return { + valid: false, + errors, + warnings, + summary: { + totalRows: data.length, + validRows: 0, + duplicateCount: 0, + errorCount: errors.length + } + } + } }
\ No newline at end of file diff --git a/lib/gtc-contract/status/clone-gtc-document-dialog.tsx b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx new file mode 100644 index 00000000..1e56f2f7 --- /dev/null +++ b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx @@ -0,0 +1,383 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader, Copy } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { cloneGtcDocumentSchema, type CloneGtcDocumentSchema } from "@/lib/gtc-contract/validations" +import { cloneGtcDocument, getAvailableProjectsForGtcExcluding, hasStandardGtcDocument } from "@/lib/gtc-contract/service" +import { type ProjectForFilter } from "@/lib/gtc-contract/service" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" +import { Input } from "@/components/ui/input" +import { useRouter } from "next/navigation" + +interface CloneGtcDocumentDialogProps { + sourceDocument: GtcDocumentWithRelations + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function CloneGtcDocumentDialog({ + sourceDocument, + open: controlledOpen, + onOpenChange: controlledOnOpenChange +}: CloneGtcDocumentDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false) + const [projects, setProjects] = React.useState<ProjectForFilter[]>([]) + const [isClonePending, startCloneTransition] = React.useTransition() + const { data: session } = useSession() + const router = useRouter() + const [defaultType, setDefaultType] = React.useState<"standard" | "project">("standard") + + const isControlled = controlledOpen !== undefined + const open = isControlled ? controlledOpen! : internalOpen + const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + + + + const form = useForm<CloneGtcDocumentSchema>({ + resolver: zodResolver(cloneGtcDocumentSchema), + defaultValues: { + sourceDocumentId: sourceDocument.id, + type: sourceDocument.type, + projectId: sourceDocument.projectId, + title: sourceDocument.title || "", + editReason: "", + }, + }) + + const resetForm = React.useCallback((type: "standard" | "project") => { + form.reset({ + sourceDocumentId: sourceDocument.id, + type, + projectId: sourceDocument.projectId, + title: sourceDocument.title || "", + editReason: "", + }) + }, [form, sourceDocument]) + + React.useEffect(() => { + if (open) { + // 표준 GTC 존재 여부와 사용 가능한 프로젝트 동시 조회 + Promise.all([ + hasStandardGtcDocument(), + getAvailableProjectsForGtcExcluding(sourceDocument.projectId || undefined) + ]).then(([hasStandard, availableProjects]) => { + const initialType = hasStandard ? "project" : "standard" + setDefaultType(initialType) + setProjects(availableProjects) + + // 폼 기본값 설정: 원본 문서 타입을 우선으로 하되, 표준이 이미 있고 원본도 표준이면 프로젝트로 변경 + const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type + resetForm(targetType) + }) + } + }, [open, sourceDocument.projectId, sourceDocument.type, resetForm]) + + + const watchedType = form.watch("type") + + React.useEffect(() => { + // 소스 문서가 변경되면 폼 기본값 업데이트 (다이얼로그가 열려있을 때만) + if (open) { + hasStandardGtcDocument().then((hasStandard) => { + const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type + resetForm(targetType) + }) + } + }, [sourceDocument, resetForm, open]) + + async function onSubmit(data: CloneGtcDocumentSchema) { + startCloneTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + const result = await cloneGtcDocument({ + ...data, + createdById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + resetForm(sourceDocument.type) + setOpen(false) + router.refresh() + + toast.success("GTC 문서가 복제되었습니다.") + } catch (error) { + toast.error("문서 복제 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // 다이얼로그 닫을 때는 원본 문서 정보로 리셋 + resetForm(sourceDocument.type) + } + setOpen(nextOpen) + } + + const DialogWrapper = isControlled ? React.Fragment : Dialog + + return ( + <DialogWrapper> + {!isControlled && ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Copy className="mr-2 h-4 w-4" /> + 복제하기 + </Button> + </DialogTrigger> + )} + + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>GTC 문서 복제</DialogTitle> + <DialogDescription> + 기존 문서를 복제하여 새로운 문서를 생성합니다. <br /> + <span className="font-medium text-foreground"> + 원본: {sourceDocument.title || `${sourceDocument.type === 'standard' ? '표준' : '프로젝트'} GTC v${sourceDocument.revision}`} + </span> + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 구분 (Type) */} + <FormField + control={form.control} + name="type" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + field.onChange(value) + // 표준으로 변경시 프로젝트 ID 초기화 + if (value === "standard") { + form.setValue("projectId", null) + } + }} + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="구분을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="standard">표준</SelectItem> + <SelectItem value="project">프로젝트</SelectItem> + </SelectContent> + </Select> + </FormControl> + {defaultType === "project" && sourceDocument.type === "standard" && ( + <FormDescription> + 표준 GTC 문서가 이미 존재합니다. 복제시에는 프로젝트 타입을 권장합니다. + </FormDescription> + )} + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 선택 (프로젝트 타입인 경우만) */} + {watchedType === "project" && ( + <FormField + control={form.control} + name="projectId" + render={({ field }) => { + const selectedProject = projects.find( + (p) => p.id === field.value + ) + const [popoverOpen, setPopoverOpen] = React.useState(false) + + return ( + <FormItem> + <FormLabel>프로젝트</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedProject + ? `${selectedProject.name} (${selectedProject.code})` + : "프로젝트를 선택하세요..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="프로젝트 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty> + {projects.length === 0 + ? "사용 가능한 프로젝트가 없습니다." + : "프로젝트를 찾을 수 없습니다." + } + </CommandEmpty> + <CommandGroup> + {projects.map((project) => { + const label = `${project.name} (${project.code})` + return ( + <CommandItem + key={project.id} + value={label} + onSelect={() => { + field.onChange(project.id) + setPopoverOpen(false) + }} + > + {label} + <Check + className={cn( + "ml-auto h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>GTC 제목 (선택사항)</FormLabel> + <FormControl> + <Input + placeholder="GTC 제목을 입력하세요..." + {...field} + /> + </FormControl> + <FormDescription> + 워드의 제목으로 사용됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>복제 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="복제 사유를 입력하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isClonePending} + > + Cancel + </Button> + <Button type="submit" disabled={isClonePending}> + {isClonePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 복제하기 + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + </DialogWrapper> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/create-gtc-document-dialog.tsx b/lib/gtc-contract/status/create-gtc-document-dialog.tsx index 003e4d51..174bb8dd 100644 --- a/lib/gtc-contract/status/create-gtc-document-dialog.tsx +++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx @@ -41,28 +41,45 @@ import { cn } from "@/lib/utils" import { toast } from "sonner" import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc-contract/validations" -import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service" -import { type Project } from "@/db/schema/projects" +import { createGtcDocument, getAvailableProjectsForGtc, hasStandardGtcDocument } from "@/lib/gtc-contract/service" +import { type ProjectForFilter } from "@/lib/gtc-contract/service" import { useSession } from "next-auth/react" import { Input } from "@/components/ui/input" -import { useRouter } from "next/navigation"; +import { useRouter } from "next/navigation" export function CreateGtcDocumentDialog() { const [open, setOpen] = React.useState(false) - const [projects, setProjects] = React.useState<Project[]>([]) + const [projects, setProjects] = React.useState<ProjectForFilter[]>([]) const [isCreatePending, startCreateTransition] = React.useTransition() + const [defaultType, setDefaultType] = React.useState<"standard" | "project">("standard") const { data: session } = useSession() - const router = useRouter(); + const router = useRouter() const currentUserId = React.useMemo(() => { return session?.user?.id ? Number(session.user.id) : null; - }, [session]); - + }, [session]) React.useEffect(() => { if (open) { - getProjectsForSelect().then((res) => { - setProjects(res) + // 표준 GTC 존재 여부와 사용 가능한 프로젝트 동시 조회 + Promise.all([ + hasStandardGtcDocument(), + getAvailableProjectsForGtc() + ]).then(([hasStandard, availableProjects]) => { + const initialType = hasStandard ? "project" : "standard" + setDefaultType(initialType) + setProjects(availableProjects) + + // 폼 기본값 설정 (setTimeout으로 다음 틱에 실행) + setTimeout(() => { + form.reset({ + type: initialType, + projectId: null, + title: "", + revision: 0, + editReason: "", + }) + }, 0) }) } }, [open]) @@ -78,11 +95,21 @@ export function CreateGtcDocumentDialog() { }, }) + const resetForm = React.useCallback((type: "standard" | "project") => { + form.reset({ + type, + projectId: null, + title: "", + revision: 0, + editReason: "", + }) + }, [form]) + + const watchedType = form.watch("type") async function onSubmit(data: CreateGtcDocumentSchema) { startCreateTransition(async () => { - if (!currentUserId) { toast.error("로그인이 필요합니다") return @@ -99,9 +126,9 @@ export function CreateGtcDocumentDialog() { return } - form.reset() + resetForm(defaultType) setOpen(false) - router.refresh(); + router.refresh() toast.success("GTC 문서가 생성되었습니다.") } catch (error) { @@ -112,7 +139,7 @@ export function CreateGtcDocumentDialog() { function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { - form.reset() + resetForm(defaultType) } setOpen(nextOpen) } @@ -122,15 +149,15 @@ export function CreateGtcDocumentDialog() { <DialogTrigger asChild> <Button variant="default" size="sm"> <Plus className="mr-2 h-4 w-4" /> - Add GTC Document + GTC 추가 </Button> </DialogTrigger> <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>Create New GTC Document</DialogTitle> + <DialogTitle>새 GTC 만들기</DialogTitle> <DialogDescription> - 새 GTC 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + 새 GTC 문서 정보를 입력하고 <b>생성</b> 버튼을 누르세요. </DialogDescription> </DialogHeader> @@ -159,11 +186,26 @@ export function CreateGtcDocumentDialog() { <SelectValue placeholder="구분을 선택하세요" /> </SelectTrigger> <SelectContent> - <SelectItem value="standard">표준</SelectItem> - <SelectItem value="project">프로젝트</SelectItem> + {/* 기본값에 따라 순서 조정 */} + {defaultType === "project" ? ( + <> + <SelectItem value="project">프로젝트</SelectItem> + <SelectItem value="standard">표준</SelectItem> + </> + ) : ( + <> + <SelectItem value="standard">표준</SelectItem> + <SelectItem value="project">프로젝트</SelectItem> + </> + )} </SelectContent> </Select> </FormControl> + {defaultType === "project" && ( + <FormDescription> + 표준 GTC 문서가 이미 존재합니다. 새 문서는 프로젝트 타입을 권장합니다. + </FormDescription> + )} <FormMessage /> </FormItem> )} @@ -198,7 +240,9 @@ export function CreateGtcDocumentDialog() { > {selectedProject ? `${selectedProject.name} (${selectedProject.code})` - : "프로젝트를 선택하세요..."} + : projects.length === 0 + ? "사용 가능한 프로젝트가 없습니다" + : "프로젝트를 선택하세요..."} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> @@ -210,7 +254,12 @@ export function CreateGtcDocumentDialog() { className="h-9" /> <CommandList> - <CommandEmpty>프로젝트를 찾을 수 없습니다.</CommandEmpty> + <CommandEmpty> + {projects.length === 0 + ? "모든 프로젝트에 이미 GTC 문서가 있습니다." + : "프로젝트를 찾을 수 없습니다." + } + </CommandEmpty> <CommandGroup> {projects.map((project) => { const label = `${project.name} (${project.code})` @@ -241,6 +290,9 @@ export function CreateGtcDocumentDialog() { </PopoverContent> </Popover> </FormControl> + <FormDescription> + {projects.length === 0 && "이미 GTC 문서가 있는 프로젝트는 표시되지 않습니다."} + </FormDescription> <FormMessage /> </FormItem> ) @@ -256,7 +308,7 @@ export function CreateGtcDocumentDialog() { <FormLabel>GTC 제목 (선택사항)</FormLabel> <FormControl> <Input - placeholder="GTC 제목를 입력하세요..." + placeholder="GTC 제목을 입력하세요..." {...field} /> </FormControl> @@ -295,7 +347,7 @@ export function CreateGtcDocumentDialog() { onClick={() => setOpen(false)} disabled={isCreatePending} > - Cancel + 취소 </Button> <Button type="submit" disabled={isCreatePending}> {isCreatePending && ( @@ -304,7 +356,7 @@ export function CreateGtcDocumentDialog() { aria-hidden="true" /> )} - Create + 생성 </Button> </DialogFooter> </form> diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx index 0fb637b6..ce3a2c7a 100644 --- a/lib/gtc-contract/status/gtc-contract-table.tsx +++ b/lib/gtc-contract/status/gtc-contract-table.tsx @@ -27,6 +27,7 @@ import { UpdateGtcDocumentSheet } from "./update-gtc-document-sheet" import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog" import { CreateNewRevisionDialog } from "./create-new-revision-dialog" import { useRouter } from "next/navigation" +import { CloneGtcDocumentDialog } from "./clone-gtc-document-dialog" interface GtcDocumentsTableProps { promises: Promise< @@ -42,6 +43,8 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) { const [{ data, pageCount }, projects, users] = React.use(promises) const router = useRouter() + console.log(data) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null) @@ -169,6 +172,16 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) { originalDocument={rowAction?.row.original ?? null} /> + + {/* 복제 다이얼로그 */} + {rowAction?.type === "clone" && ( + <CloneGtcDocumentDialog + sourceDocument={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + /> + )} + {/* <CreateGtcDocumentDialog /> */} </> ) diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx index cd02a3e5..89415284 100644 --- a/lib/gtc-contract/status/gtc-documents-table-columns.tsx +++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx @@ -24,7 +24,7 @@ import { type GtcDocumentWithRelations } from "@/db/schema/gtc" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>> - router: AppRouterInstance // ← 추가 + router: AppRouterInstance } /** GTC Documents 테이블 컬럼 정의 (그룹 헤더 제거) */ @@ -75,12 +75,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef accessorKey: "project", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />, cell: ({ row }) => { - const project = row.original.project - if (!project) return <span className="text-muted-foreground">-</span> + const projectName = row.original.projectName + const projectCode = row.original.projectCode + if (!projectName) return <span className="text-muted-foreground">-</span> return ( <div className="flex flex-col min-w-0"> - <span className="font-medium truncate">{project.name}</span> - <span className="text-xs text-muted-foreground">{project.code}</span> + <span className="font-medium truncate">{projectName}</span> + <span className="text-xs text-muted-foreground">{projectCode}</span> </div> ) }, @@ -195,6 +196,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef setRowAction({ row, type: "createRevision" }) } + const handleClone = () => { + setRowAction({ row, type: "clone" }) + } + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -206,26 +211,30 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef <Ellipsis className="size-4" aria-hidden /> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuContent align="end" className="w-44"> <DropdownMenuItem onSelect={handleViewDetails}> - <Eye className="mr-2 h-4 w-4" /> - 상세 + {/* <Eye className="mr-2 h-4 w-4" /> */} + 상세보기 </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}> - 수정 + 수정하기 </DropdownMenuItem> <DropdownMenuItem onSelect={handleCreateNewRevision}> - 새 리비전 생성 + 리비전 생성하기 + </DropdownMenuItem> + + <DropdownMenuItem onSelect={handleClone}> + 복제하기 </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}> - 삭제 - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + 삭제하기 + {/* <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> */} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -241,4 +250,4 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ...auditColumns, actionsColumn, ] -} +}
\ No newline at end of file diff --git a/lib/gtc-contract/validations.ts b/lib/gtc-contract/validations.ts index d00d795b..0566c1cb 100644 --- a/lib/gtc-contract/validations.ts +++ b/lib/gtc-contract/validations.ts @@ -70,4 +70,35 @@ export const createNewRevisionSchema = z.object({ export type GetGtcDocumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> export type CreateGtcDocumentSchema = z.infer<typeof createGtcDocumentSchema> export type UpdateGtcDocumentSchema = z.infer<typeof updateGtcDocumentSchema> -export type CreateNewRevisionSchema = z.infer<typeof createNewRevisionSchema>
\ No newline at end of file +export type CreateNewRevisionSchema = z.infer<typeof createNewRevisionSchema> + + + +// 복제용 schema +export const cloneGtcDocumentSchema = z.object({ + sourceDocumentId: z.number().min(1, "원본 문서 ID가 필요합니다."), + type: z.enum(["standard", "project"]), + projectId: z.number().nullable().optional(), + title: z.string().optional(), + editReason: z.string().optional(), +}).refine((data) => { + // 프로젝트 타입인 경우 projectId가 필수 + if (data.type === "project") { + return data.projectId !== null && data.projectId !== undefined + } + return true +}, { + message: "프로젝트 타입인 경우 프로젝트를 선택해야 합니다.", + path: ["projectId"] +}).refine((data) => { + // 표준 타입인 경우 projectId는 null이어야 함 + if (data.type === "standard") { + return data.projectId === null || data.projectId === undefined + } + return true +}, { + message: "표준 타입인 경우 프로젝트를 선택할 수 없습니다.", + path: ["projectId"] +}) + +export type CloneGtcDocumentSchema = z.infer<typeof cloneGtcDocumentSchema>
\ No newline at end of file diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index b78d0fc3..9eaa2a40 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -2,9 +2,9 @@ "use server" import { revalidatePath, unstable_cache } from "next/cache" -import { and, asc, desc, eq, ilike, or, count, avg, inArray } from "drizzle-orm" +import { and, asc, desc, eq, ilike, or, count, avg, inArray, sql } from "drizzle-orm" import db from "@/db/db" -import { documentAttachments, documents, enhancedDocumentsView, issueStages, revisions, simplifiedDocumentsView, type EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { documentAttachments, documentStagesOnlyView, documents, enhancedDocumentsView, issueStages, revisions, simplifiedDocumentsView, type EnhancedDocumentsView } from "@/db/schema/vendorDocu" import { filterColumns } from "@/lib/filter-columns" import type { CreateDocumentInput, @@ -23,6 +23,7 @@ import { GetVendorShipDcoumentsSchema } from "./validations" import { contracts, users, vendors } from "@/db/schema" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { countDocumentStagesOnly, selectDocumentStagesOnly } from "./repository" // 스키마 타입 정의 export interface GetEnhancedDocumentsSchema { @@ -1181,4 +1182,7 @@ export async function getDocumentDetails(documentId: number) { console.error("Error fetching user vendor document stats:", err) return { stats: {}, totalDocuments: 0, primaryDrawingKind: null } } - }
\ No newline at end of file + } + + + diff --git a/lib/vendor-document-list/plant/document-stage-actions.ts b/lib/vendor-document-list/plant/document-stage-actions.ts new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-actions.ts diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx new file mode 100644 index 00000000..732a4bed --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -0,0 +1,789 @@ +"use client" + +import React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { DocumentStagesOnlyView } from "@/db/schema" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2 } from "lucide-react" +import { toast } from "sonner" +import { + getDocumentNumberTypes, + getDocumentNumberTypeConfigs, + getComboBoxOptions, + getDocumentClasses, + createDocument, + updateStage +} from "./document-stages-service" + +// ============================================================================= +// 1. Add Document Dialog (Updated with fixed header/footer and English text) +// ============================================================================= +interface AddDocumentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +export function AddDocumentDialog({ + open, + onOpenChange, + contractId, + projectType +}: AddDocumentDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [documentNumberTypes, setDocumentNumberTypes] = React.useState<any[]>([]) + const [documentClasses, setDocumentClasses] = React.useState<any[]>([]) + const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) + const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + const [formData, setFormData] = React.useState({ + documentNumberTypeId: "", + documentClassId: "", + title: "", + pic: "", + vendorDocNumber: "", + fieldValues: {} as Record<string, string> + }) + + // Load initial data + React.useEffect(() => { + if (open) { + loadInitialData() + } + }, [open]) + + const loadInitialData = async () => { + setIsLoading(true) + try { + const [typesResult, classesResult] = await Promise.all([ + getDocumentNumberTypes(), + getDocumentClasses() + ]) + + if (typesResult.success) { + setDocumentNumberTypes(typesResult.data) + } + if (classesResult.success) { + setDocumentClasses(classesResult.data) + } + } catch (error) { + toast.error("Error loading data.") + } finally { + setIsLoading(false) + } + } + + // Handle document type change + const handleDocumentTypeChange = async (documentNumberTypeId: string) => { + setFormData({ + ...formData, + documentNumberTypeId, + fieldValues: {} + }) + + if (documentNumberTypeId) { + const configsResult = await getDocumentNumberTypeConfigs(Number(documentNumberTypeId)) + if (configsResult.success) { + setSelectedTypeConfigs(configsResult.data) + + // Pre-load combobox options + const comboBoxPromises = configsResult.data + .filter(config => config.codeGroup?.controlType === 'combobox') + .map(async (config) => { + const optionsResult = await getComboBoxOptions(config.codeGroupId!) + return { + codeGroupId: config.codeGroupId, + options: optionsResult.success ? optionsResult.data : [] + } + }) + + const comboBoxResults = await Promise.all(comboBoxPromises) + const newComboBoxOptions: Record<number, any[]> = {} + comboBoxResults.forEach(result => { + if (result.codeGroupId) { + newComboBoxOptions[result.codeGroupId] = result.options + } + }) + setComboBoxOptions(newComboBoxOptions) + } + } else { + setSelectedTypeConfigs([]) + setComboBoxOptions({}) + } + } + + // Handle field value change + const handleFieldValueChange = (fieldKey: string, value: string) => { + setFormData({ + ...formData, + fieldValues: { + ...formData.fieldValues, + [fieldKey]: value + } + }) + } + + // Generate document number preview + const generatePreviewDocNumber = () => { + if (selectedTypeConfigs.length === 0) return "" + + let preview = "" + selectedTypeConfigs.forEach((config, index) => { + const fieldKey = `field_${config.sdq}` + const value = formData.fieldValues[fieldKey] || "[value]" + preview += value + if (index < selectedTypeConfigs.length - 1) { + preview += "-" + } + }) + return preview + } + + const handleSubmit = async () => { + if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title) { + toast.error("Please fill in all required fields.") + return + } + + setIsLoading(true) + try { + const result = await createDocument({ + contractId, + documentNumberTypeId: Number(formData.documentNumberTypeId), + documentClassId: Number(formData.documentClassId), + title: formData.title, + fieldValues: formData.fieldValues, + pic: formData.pic, + vendorDocNumber: formData.vendorDocNumber, + }) + + if (result.success) { + toast.success("Document added successfully.") + onOpenChange(false) + resetForm() + } else { + toast.error(result.error || "Error adding document.") + } + } catch (error) { + toast.error("Error adding document.") + } finally { + setIsLoading(false) + } + } + + const resetForm = () => { + setFormData({ + documentNumberTypeId: "", + documentClassId: "", + title: "", + pic: "", + vendorDocNumber: "", + fieldValues: {} + }) + setSelectedTypeConfigs([]) + setComboBoxOptions({}) + } + + const isPlantProject = projectType === "plant" + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Add New Document</DialogTitle> + <DialogDescription> + Enter the basic information for the new document. + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="flex items-center justify-center py-8 flex-1"> + <Loader2 className="h-8 w-8 animate-spin" /> + </div> + ) : ( + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* Document Number Type Selection */} + <div className="grid gap-2"> + <Label htmlFor="documentNumberTypeId"> + Document Number Type <span className="text-red-500">*</span> + </Label> + <Select + value={formData.documentNumberTypeId} + onValueChange={handleDocumentTypeChange} + > + <SelectTrigger> + <SelectValue placeholder="Select document number type" /> + </SelectTrigger> + <SelectContent> + {documentNumberTypes.map((type) => ( + <SelectItem key={type.id} value={String(type.id)}> + {type.name} - {type.description} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Dynamic Fields */} + {selectedTypeConfigs.length > 0 && ( + <div className="border rounded-lg p-4 bg-blue-50/30"> + <Label className="text-sm font-medium text-blue-800 mb-3 block"> + Document Number Components + </Label> + <div className="grid gap-3"> + {selectedTypeConfigs.map((config) => ( + <div key={config.id} className="grid gap-2"> + <Label className="text-sm"> + {config.codeGroup?.description || config.description} + {config.required && <span className="text-red-500 ml-1">*</span>} + {config.remark && ( + <span className="text-xs text-gray-500 ml-2">({config.remark})</span> + )} + </Label> + + {config.codeGroup?.controlType === 'combobox' ? ( + <Select + value={formData.fieldValues[`field_${config.sdq}`] || ""} + onValueChange={(value) => handleFieldValueChange(`field_${config.sdq}`, value)} + > + <SelectTrigger> + <SelectValue placeholder="Select option" /> + </SelectTrigger> + <SelectContent> + {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( + <SelectItem key={option.id} value={option.code}> + {option.code} - {option.description} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : config.documentClass ? ( + <div className="p-2 bg-gray-100 rounded text-sm"> + {config.documentClass.code} - {config.documentClass.description} + </div> + ) : ( + <Input + value={formData.fieldValues[`field_${config.sdq}`] || ""} + onChange={(e) => handleFieldValueChange(`field_${config.sdq}`, e.target.value)} + placeholder="Enter value" + /> + )} + </div> + ))} + </div> + + {/* Document Number Preview */} + <div className="mt-3 p-2 bg-white border rounded"> + <Label className="text-xs text-gray-600">Document Number Preview:</Label> + <div className="font-mono text-sm font-medium text-blue-600"> + {generatePreviewDocNumber()} + </div> + </div> + </div> + )} + + {/* Document Class Selection */} + <div className="grid gap-2"> + <Label htmlFor="documentClassId"> + Document Class <span className="text-red-500">*</span> + </Label> + <Select + value={formData.documentClassId} + onValueChange={(value) => setFormData({ ...formData, documentClassId: value })} + > + <SelectTrigger> + <SelectValue placeholder="Select document class" /> + </SelectTrigger> + <SelectContent> + {documentClasses.map((cls) => ( + <SelectItem key={cls.id} value={String(cls.id)}> + {cls.code} - {cls.description} + </SelectItem> + ))} + </SelectContent> + </Select> + {formData.documentClassId && ( + <p className="text-xs text-gray-600"> + Options from the selected class will be automatically created as stages. + </p> + )} + </div> + + {/* Document Title */} + <div className="grid gap-2"> + <Label htmlFor="title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Input + id="title" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + placeholder="Enter document title" + /> + </div> + + {/* Additional Information */} + {isPlantProject && ( + <div className="grid gap-2"> + <Label htmlFor="vendorDocNumber">Vendor Document Number</Label> + <Input + id="vendorDocNumber" + value={formData.vendorDocNumber} + onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })} + placeholder="Vendor provided document number" + /> + </div> + )} + </div> + </div> + )} + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> + Cancel + </Button> + <Button onClick={handleSubmit} disabled={isLoading}> + {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + Add Document + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// 2. Edit Document Dialog (Updated with English text) +// ============================================================================= +interface EditDocumentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: DocumentStagesOnlyView | null + contractId: number + projectType: "ship" | "plant" +} + +export function EditDocumentDialog({ + open, + onOpenChange, + document, + contractId, + projectType +}: EditDocumentDialogProps) { + const [formData, setFormData] = React.useState({ + title: "", + pic: "", + vendorDocNumber: "", + }) + + React.useEffect(() => { + if (document) { + setFormData({ + title: document.title || "", + pic: document.pic || "", + vendorDocNumber: document.vendorDocNumber || "", + }) + } + }, [document]) + + const handleSubmit = async () => { + try { + // TODO: API call to update document + toast.success("Document updated successfully.") + onOpenChange(false) + } catch (error) { + toast.error("Error updating document.") + } + } + + const isPlantProject = projectType === "plant" + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-[500px]"> + <SheetHeader> + <SheetTitle>Edit Document</SheetTitle> + <SheetDescription> + You can modify the basic information of the document. + </SheetDescription> + </SheetHeader> + + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label>Document Number</Label> + <div className="p-2 bg-gray-100 rounded text-sm font-mono"> + {document?.docNumber} + </div> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Input + id="edit-title" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + placeholder="Enter document title" + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + {isPlantProject && ( + <div className="grid gap-2"> + <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label> + <Input + id="edit-vendorDocNumber" + value={formData.vendorDocNumber} + onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })} + placeholder="Vendor provided document number" + /> + </div> + )} + <div className="grid gap-2"> + <Label htmlFor="edit-pic">PIC</Label> + <Input + id="edit-pic" + value={formData.pic} + onChange={(e) => setFormData({ ...formData, pic: e.target.value })} + placeholder="Person in charge" + /> + </div> + </div> + </div> + + <SheetFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={handleSubmit}> + Save Changes + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +} + +// ============================================================================= +// 3. Edit Stage Dialog (Updated with English text) +// ============================================================================= +interface EditStageDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: DocumentStagesOnlyView | null + stageId: number | null +} + +export function EditStageDialog({ + open, + onOpenChange, + document, + stageId +}: EditStageDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [formData, setFormData] = React.useState({ + stageName: "", + planDate: "", + actualDate: "", + stageStatus: "PLANNED", + assigneeName: "", + priority: "MEDIUM", + notes: "" + }) + + // Load stage information by stageId + React.useEffect(() => { + if (document && stageId) { + const stage = document.allStages?.find(s => s.id === stageId) + if (stage) { + setFormData({ + stageName: stage.stageName || "", + planDate: stage.planDate || "", + actualDate: stage.actualDate || "", + stageStatus: stage.stageStatus || "PLANNED", + assigneeName: stage.assigneeName || "", + priority: stage.priority || "MEDIUM", + notes: stage.notes || "" + }) + } + } + }, [document, stageId]) + + const handleSubmit = async () => { + if (!stageId) return + + setIsLoading(true) + try { + const result = await updateStage({ + stageId, + ...formData + }) + + if (result.success) { + toast.success("Stage updated successfully.") + onOpenChange(false) + } else { + toast.error(result.error || "Error updating stage.") + } + } catch (error) { + toast.error("Error updating stage.") + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px] h-[70vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Edit Stage</DialogTitle> + <DialogDescription> + You can modify stage information. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="edit-stageName">Stage Name</Label> + <div className="p-2 bg-gray-100 rounded text-sm"> + {formData.stageName} + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="grid gap-2"> + <Label htmlFor="edit-planDate"> + <Calendar className="inline w-4 h-4 mr-1" /> + Plan Date + </Label> + <Input + id="edit-planDate" + type="date" + value={formData.planDate} + onChange={(e) => setFormData({ ...formData, planDate: e.target.value })} + /> + </div> + <div className="grid gap-2"> + <Label htmlFor="edit-actualDate"> + <Calendar className="inline w-4 h-4 mr-1" /> + Actual Date + </Label> + <Input + id="edit-actualDate" + type="date" + value={formData.actualDate} + onChange={(e) => setFormData({ ...formData, actualDate: e.target.value })} + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="grid gap-2"> + <Label htmlFor="edit-stageStatus">Status</Label> + <Select + value={formData.stageStatus} + onValueChange={(value) => setFormData({ ...formData, stageStatus: value })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="PLANNED">Planned</SelectItem> + <SelectItem value="IN_PROGRESS">In Progress</SelectItem> + <SelectItem value="SUBMITTED">Submitted</SelectItem> + <SelectItem value="COMPLETED">Completed</SelectItem> + </SelectContent> + </Select> + </div> + <div className="grid gap-2"> + <Label htmlFor="edit-priority"> + <Target className="inline w-4 h-4 mr-1" /> + Priority + </Label> + <Select + value={formData.priority} + onValueChange={(value) => setFormData({ ...formData, priority: value })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="HIGH">High</SelectItem> + <SelectItem value="MEDIUM">Medium</SelectItem> + <SelectItem value="LOW">Low</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-assigneeName"> + <User className="inline w-4 h-4 mr-1" /> + Assignee + </Label> + <Input + id="edit-assigneeName" + value={formData.assigneeName} + onChange={(e) => setFormData({ ...formData, assigneeName: e.target.value })} + placeholder="Enter assignee name" + /> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-notes">Notes</Label> + <Textarea + id="edit-notes" + value={formData.notes} + onChange={(e) => setFormData({ ...formData, notes: e.target.value })} + placeholder="Additional notes" + rows={3} + /> + </div> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> + Cancel + </Button> + <Button onClick={handleSubmit} disabled={isLoading}> + {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + Save Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// 4. Excel Import Dialog (Updated with English text) +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + setFile(selectedFile) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("Please select a file.") + return + } + + setIsUploading(true) + try { + // TODO: API call to upload and process Excel file + toast.success("Excel file imported successfully.") + onOpenChange(false) + setFile(null) + } catch (error) { + toast.error("Error importing Excel file.") + } finally { + setIsUploading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Import Excel File + </DialogTitle> + <DialogDescription> + Upload an Excel file containing document list for batch registration. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Select Excel File</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 mt-1"> + Selected file: {file.name} + </p> + )} + </div> + + <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> + <h4 className="font-medium text-blue-800 mb-2">File Format Guide</h4> + <div className="text-sm text-blue-700 space-y-1"> + <p>• First row must be header row</p> + <p>• Required columns: Document Number, Document Title, Document Class</p> + {projectType === "plant" && ( + <p>• Optional columns: Vendor Document Number, PIC</p> + )} + <p>• Supported formats: .xlsx, .xls</p> + </div> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={handleImport} disabled={!file || isUploading}> + {isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + {isUploading ? "Importing..." : "Import"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stage-validations.ts b/lib/vendor-document-list/plant/document-stage-validations.ts new file mode 100644 index 00000000..037293e3 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-validations.ts @@ -0,0 +1,339 @@ +// document-stage-validations.ts +import { z } from "zod" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { DocumentStagesOnlyView } from "@/db/schema" + +// ============================================================================= +// 1. 문서 관련 스키마들 +// ============================================================================= + +// 문서 생성 스키마 +export const createDocumentSchema = z.object({ + contractId: z.number().min(1, "계약 ID는 필수입니다"), + docNumber: z.string().min(1, "문서번호는 필수입니다").max(100, "문서번호는 100자를 초과할 수 없습니다"), + title: z.string().min(1, "문서명은 필수입니다").max(255, "문서명은 255자를 초과할 수 없습니다"), + drawingKind: z.enum(["B3", "B4", "B5"], { + required_error: "문서종류를 선택해주세요", + }), + vendorDocNumber: z.string().max(100).optional(), + pic: z.string().max(50).optional(), + issuedDate: z.string().date().optional(), + + // DOLCE 연동 정보 + drawingMoveGbn: z.string().max(50).optional(), + discipline: z.string().max(10).optional(), + externalDocumentId: z.string().max(100).optional(), + externalSystemType: z.string().max(20).optional(), + + // B4 전용 필드들 + cGbn: z.string().max(50).optional(), + dGbn: z.string().max(50).optional(), + degreeGbn: z.string().max(50).optional(), + deptGbn: z.string().max(50).optional(), + jGbn: z.string().max(50).optional(), + sGbn: z.string().max(50).optional(), + + // 추가 필드들 + shiDrawingNo: z.string().max(100).optional(), + manager: z.string().max(100).optional(), + managerENM: z.string().max(100).optional(), + managerNo: z.string().max(50).optional(), +}) + +// 문서 업데이트 스키마 +export const updateDocumentSchema = createDocumentSchema.partial().extend({ + id: z.number().min(1, "문서 ID는 필수입니다"), +}) + +// 문서 삭제 스키마 +export const deleteDocumentSchema = z.object({ + id: z.number().min(1, "문서 ID는 필수입니다"), +}) + +// ============================================================================= +// 2. 스테이지 관련 스키마들 +// ============================================================================= + +// 스테이지 생성 스키마 +export const createStageSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stageName: z.string().min(1, "스테이지명은 필수입니다").max(100, "스테이지명은 100자를 초과할 수 없습니다"), + planDate: z.string().date().optional(), + stageStatus: z.enum(["PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"], { + required_error: "스테이지 상태를 선택해주세요", + }).default("PLANNED"), + stageOrder: z.number().min(0).default(0), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), + assigneeId: z.number().optional(), + assigneeName: z.string().max(100).optional(), + reminderDays: z.number().min(0).max(30).default(3), + description: z.string().max(500).optional(), + notes: z.string().max(1000).optional(), +}) + +// 스테이지 업데이트 스키마 +export const updateStageSchema = createStageSchema.partial().extend({ + id: z.number().min(1, "스테이지 ID는 필수입니다"), + actualDate: z.string().date().optional(), +}) + +// 스테이지 삭제 스키마 +export const deleteStageSchema = z.object({ + id: z.number().min(1, "스테이지 ID는 필수입니다"), +}) + +// 스테이지 순서 변경 스키마 +export const reorderStagesSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stages: z.array(z.object({ + id: z.number().min(1), + stageOrder: z.number().min(0), + })).min(1, "최소 하나의 스테이지는 필요합니다"), +}) + +// ============================================================================= +// 3. 일괄 작업 스키마들 +// ============================================================================= + +// 일괄 문서 생성 스키마 (엑셀 임포트용) +export const bulkCreateDocumentsSchema = z.object({ + contractId: z.number().min(1, "계약 ID는 필수입니다"), + documents: z.array(createDocumentSchema.omit({ contractId: true })).min(1, "최소 하나의 문서는 필요합니다"), +}) + +// 일괄 스테이지 생성 스키마 +export const bulkCreateStagesSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stages: z.array(createStageSchema.omit({ documentId: true })).min(1, "최소 하나의 스테이지는 필요합니다"), +}) + +// 일괄 상태 업데이트 스키마 +export const bulkUpdateStatusSchema = z.object({ + stageIds: z.array(z.number().min(1)).min(1, "최소 하나의 스테이지를 선택해주세요"), + status: z.enum(["PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]), + actualDate: z.string().date().optional(), +}) + +// 일괄 담당자 지정 스키마 +export const bulkAssignSchema = z.object({ + stageIds: z.array(z.number().min(1)).min(1, "최소 하나의 스테이지를 선택해주세요"), + assigneeId: z.number().optional(), + assigneeName: z.string().max(100).optional(), +}) + +// ============================================================================= +// 4. 검색 및 필터링 스키마들 +// ============================================================================= + +// 검색 파라미터 스키마 (Zod 검증용) +export const searchParamsSchema = z.object({ + page: z.coerce.number().default(1), + perPage: z.coerce.number().default(10), + sort: z.string().optional(), + search: z.string().optional(), + filters: z.string().optional(), + drawingKind: z.enum(["all", "B3", "B4", "B5"]).default("all"), + stageStatus: z.enum(["all", "PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]).default("all"), + priority: z.enum(["all", "HIGH", "MEDIUM", "LOW"]).default("all"), + isOverdue: z.enum(["all", "true", "false"]).default("all"), + assignee: z.string().optional(), + dateFrom: z.string().date().optional(), + dateTo: z.string().date().optional(), + joinOperator: z.enum(["and", "or"]).default("and"), +}) + +// 문서 스테이지 전용 검색 파라미터 캐시 (nuqs용) +export const documentStageSearchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<DocumentStagesOnlyView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 문서 스테이지 전용 필터들 + drawingKind: parseAsStringEnum(["all", "B3", "B4", "B5"]).withDefault("all"), + stageStatus: parseAsStringEnum(["all", "PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]).withDefault("all"), + priority: parseAsStringEnum(["all", "HIGH", "MEDIUM", "LOW"]).withDefault("all"), + isOverdue: parseAsStringEnum(["all", "true", "false"]).withDefault("all"), + assignee: parseAsString.withDefault(""), + dateFrom: parseAsString.withDefault(""), + dateTo: parseAsString.withDefault(""), +}) + +// ============================================================================= +// 5. 엑셀 임포트 스키마들 +// ============================================================================= + +// 엑셀 문서 행 스키마 +export const excelDocumentRowSchema = z.object({ + "문서번호": z.string().min(1, "문서번호는 필수입니다"), + "문서명": z.string().min(1, "문서명은 필수입니다"), + "문서종류": z.enum(["B3", "B4", "B5"], { + required_error: "문서종류는 B3, B4, B5 중 하나여야 합니다", + }), + "벤더문서번호": z.string().optional(), + "PIC": z.string().optional(), + "발행일": z.string().optional(), + "벤더명": z.string().optional(), + "벤더코드": z.string().optional(), + // B4 전용 필드들 + "C구분": z.string().optional(), + "D구분": z.string().optional(), + "Degree구분": z.string().optional(), + "부서구분": z.string().optional(), + "S구분": z.string().optional(), + "J구분": z.string().optional(), +}) + +// 엑셀 스테이지 행 스키마 +export const excelStageRowSchema = z.object({ + "문서번호": z.string().min(1, "문서번호는 필수입니다"), + "스테이지명": z.string().min(1, "스테이지명은 필수입니다"), + "계획일": z.string().optional(), + "우선순위": z.enum(["HIGH", "MEDIUM", "LOW"]).optional(), + "담당자": z.string().optional(), + "설명": z.string().optional(), + "스테이지순서": z.coerce.number().optional(), +}) + +// 엑셀 임포트 결과 스키마 +export const excelImportResultSchema = z.object({ + totalRows: z.number(), + successCount: z.number(), + failureCount: z.number(), + errors: z.array(z.object({ + row: z.number(), + field: z.string().optional(), + message: z.string(), + })), + createdDocuments: z.array(z.object({ + id: z.number(), + docNumber: z.string(), + title: z.string(), + })), +}) + +// ============================================================================= +// 6. API 응답 스키마들 +// ============================================================================= + +// 표준 API 응답 스키마 +export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => + z.object({ + success: z.boolean(), + data: dataSchema.optional(), + error: z.string().optional(), + message: z.string().optional(), + }) + +// 페이지네이션 응답 스키마 +export const paginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => + z.object({ + data: z.array(dataSchema), + pageCount: z.number(), + total: z.number(), + page: z.number(), + perPage: z.number(), + }) + +// ============================================================================= +// 7. 타입 추출 +// ============================================================================= + +export type CreateDocumentInput = z.infer<typeof createDocumentSchema> +export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema> +export type DeleteDocumentInput = z.infer<typeof deleteDocumentSchema> + +export type CreateStageInput = z.infer<typeof createStageSchema> +export type UpdateStageInput = z.infer<typeof updateStageSchema> +export type DeleteStageInput = z.infer<typeof deleteStageSchema> +export type ReorderStagesInput = z.infer<typeof reorderStagesSchema> + +export type BulkCreateDocumentsInput = z.infer<typeof bulkCreateDocumentsSchema> +export type BulkCreateStagesInput = z.infer<typeof bulkCreateStagesSchema> +export type BulkUpdateStatusInput = z.infer<typeof bulkUpdateStatusSchema> +export type BulkAssignInput = z.infer<typeof bulkAssignSchema> + +export type SearchParamsInput = z.infer<typeof searchParamsSchema> +export type ExcelDocumentRow = z.infer<typeof excelDocumentRowSchema> +export type ExcelStageRow = z.infer<typeof excelStageRowSchema> +export type ExcelImportResult = z.infer<typeof excelImportResultSchema> + +// ============================================================================= +// 8. 유틸리티 함수들 +// ============================================================================= + +// 문서번호 유효성 검사 (프로젝트별 규칙) +export const validateDocNumber = (docNumber: string, projectType: "ship" | "plant") => { + if (projectType === "ship") { + // Ship 프로젝트: 특정 패턴 검사 + const shipPattern = /^[A-Z]{2,4}-\d{4}-\d{3}$/ + return shipPattern.test(docNumber) + } else { + // Plant 프로젝트: 더 유연한 패턴 + const plantPattern = /^[A-Z0-9-]{5,20}$/ + return plantPattern.test(docNumber) + } +} + +// B4 필드 유효성 검사 +export const validateB4Fields = (data: Partial<CreateDocumentInput>) => { + if (data.drawingKind === "B4") { + const requiredB4Fields = ["cGbn", "dGbn", "deptGbn"] + const missingFields = requiredB4Fields.filter(field => + !data[field as keyof typeof data] || + String(data[field as keyof typeof data]).trim() === "" + ) + + if (missingFields.length > 0) { + throw new Error(`B4 문서는 다음 필드가 필수입니다: ${missingFields.join(", ")}`) + } + } +} + +// 스테이지 순서 유효성 검사 +export const validateStageOrder = (stages: { id: number; stageOrder: number }[]) => { + const orders = stages.map(s => s.stageOrder) + const uniqueOrders = new Set(orders) + + if (orders.length !== uniqueOrders.size) { + throw new Error("스테이지 순서는 중복될 수 없습니다") + } + + const sortedOrders = [...orders].sort((a, b) => a - b) + const expectedOrders = Array.from({ length: orders.length }, (_, i) => i) + + if (JSON.stringify(sortedOrders) !== JSON.stringify(expectedOrders)) { + throw new Error("스테이지 순서는 0부터 연속된 숫자여야 합니다") + } +} + +// 날짜 유효성 검사 +export const validateDateRange = (startDate?: string, endDate?: string) => { + if (startDate && endDate) { + const start = new Date(startDate) + const end = new Date(endDate) + + if (start > end) { + throw new Error("시작일은 종료일보다 이전이어야 합니다") + } + + // 최대 1년 범위 제한 + const oneYearInMs = 365 * 24 * 60 * 60 * 1000 + if (end.getTime() - start.getTime() > oneYearInMs) { + throw new Error("날짜 범위는 최대 1년까지 가능합니다") + } + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx new file mode 100644 index 00000000..d39af4e8 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -0,0 +1,521 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { DocumentStagesOnlyView } from "@/db/schema" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + Ellipsis, + AlertTriangle, + Clock, + CheckCircle, + Calendar, + User, + Eye, + Edit, + Plus, + Trash2 +} from "lucide-react" +import { cn } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> + projectType: string +} + +// 유틸리티 함수들 +const getStatusColor = (status: string, isOverdue = false) => { + if (isOverdue) return 'destructive' + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'destructive' + case 'MEDIUM': return 'default' + case 'LOW': return 'secondary' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return 'Planned' + case 'IN_PROGRESS': return 'In Progress' + case 'SUBMITTED': return 'Submitted' + case 'UNDER_REVIEW': return 'Under Review' + case 'APPROVED': return 'Approved' + case 'REJECTED': return 'Rejected' + case 'COMPLETED': return 'Completed' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return 'High' + case 'MEDIUM': return 'Medium' + case 'LOW': return 'Low' + default: priority + } +} + +// 마감일 정보 컴포넌트 (콤팩트) +const DueDateInfo = ({ + daysUntilDue, + isOverdue, + className = "" +}: { + daysUntilDue: number | null + isOverdue: boolean + className?: string +}) => { + if (isOverdue && daysUntilDue !== null && daysUntilDue < 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-red-600 text-xs", className)}> + <AlertTriangle className="w-3 h-3" /> + {Math.abs(daysUntilDue)}d overdue + </span> + ) + } + + if (daysUntilDue === 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-orange-600 text-xs", className)}> + <Clock className="w-3 h-3" /> + Due today + </span> + ) + } + + if (daysUntilDue && daysUntilDue > 0 && daysUntilDue <= 3) { + return ( + <span className={cn("inline-flex items-center gap-1 text-orange-600 text-xs", className)}> + <Clock className="w-3 h-3" /> + {daysUntilDue}d left + </span> + ) + } + + if (daysUntilDue && daysUntilDue > 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-gray-500 text-xs", className)}> + <Calendar className="w-3 h-3" /> + {daysUntilDue}d + </span> + ) + } + + return ( + <span className={cn("inline-flex items-center gap-1 text-green-600 text-xs", className)}> + <CheckCircle className="w-3 h-3" /> + Done + </span> + ) +} + +export function getDocumentStagesColumns({ + setRowAction, + projectType +}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { + const isPlantProject = projectType === "plant" + + const columns: ColumnDef<DocumentStagesOnlyView>[] = [ + // 체크박스 선택 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <span className="text-sm font-medium">{doc.projectCode}</span> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Project" + }, + }, + + + + // 문서번호 + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Number" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Document Number" + }, + }, + + // 문서명 (PIC 포함) + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Name" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate text-sm" title={doc.title}> + {doc.title} + </div> + {doc.pic && ( + <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded mt-1 inline-block"> + PIC: {doc.pic} + </span> + )} + </div> + ) + }, + size: 220, + enableResizing: true, + meta: { + excelHeader: "Document Name" + }, + }, + ] + + // Plant 프로젝트용 추가 컬럼들 + if (isPlantProject) { + columns.push( + // 벤더 문서번호 + { + accessorKey: "vendorDocNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Doc No." /> + ), + cell: ({ row }) => { + const doc = row.original + return doc.vendorDocNumber ? ( + <span className="font-mono text-sm text-blue-600">{doc.vendorDocNumber}</span> + ) : ( + <span className="text-gray-400 text-sm">-</span> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "Vendor Doc No." + }, + }, + + ) + } + + // 나머지 공통 컬럼들 + columns.push( + // 현재 스테이지 (상태, 담당자 한 줄) + { + accessorKey: "currentStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Current Stage" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStageName) { + return ( + <Button + size="sm" + variant="outline" + onClick={(e) => { + e.stopPropagation() + setRowAction({ row, type: "add_stage" }) + }} + className="h-6 text-xs" + > + <Plus className="w-3 h-3 mr-1" /> + Add stage + </Button> + ) + } + + return ( + <div className="flex items-center gap-2"> + <span className="text-sm font-medium truncate" title={doc.currentStageName}> + {doc.currentStageName} + </span> + <Badge + variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + className="text-xs px-1.5 py-0" + > + {getStatusText(doc.currentStageStatus || '')} + </Badge> + {doc.currentStageAssigneeName && ( + <span className="text-xs text-gray-500 flex items-center gap-1"> + <User className="w-3 h-3" /> + {doc.currentStageAssigneeName} + </span> + )} + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Current Stage" + }, + }, + + // 계획 일정 (한 줄) + { + accessorKey: "currentStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Plan Date" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStagePlanDate) return <span className="text-gray-400">-</span> + + return ( + <div className="flex items-center gap-2"> + <span className="text-sm">{formatDate(doc.currentStagePlanDate, 'MM/dd')}</span> + <DueDateInfo + daysUntilDue={doc.daysUntilDue} + isOverdue={doc.isOverdue || false} + /> + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "Plan Date" + }, + }, + + // 우선순위 + 진행률 (콤팩트) + { + accessorKey: "progressPercentage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> + ), + cell: ({ row }) => { + const doc = row.original + const progress = doc.progressPercentage || 0 + const completed = doc.completedStages || 0 + const total = doc.totalStages || 0 + + return ( + <div className="flex items-center gap-2"> + {doc.currentStagePriority && ( + <Badge + variant={getPriorityColor(doc.currentStagePriority)} + className="text-xs px-1.5 py-0" + > + {getPriorityText(doc.currentStagePriority)} + </Badge> + )} + <div className="flex items-center gap-1"> + <Progress value={progress} className="w-12 h-1.5" /> + <span className="text-xs text-gray-600 min-w-[2rem]"> + {progress}% + </span> + </div> + <span className="text-xs text-gray-500"> + ({completed}/{total}) + </span> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Progress" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => ( + <span className="text-xs text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 80, + enableResizing: true, + meta: { + excelHeader: "Updated At" + }, + }, + + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const doc = row.original + const hasStages = doc.totalStages && doc.totalStages > 0 + + const viewActions = [ + { + key: "view", + label: "View Stage Details", + icon: Eye, + action: () => setRowAction({ row, type: "view" }), + show: hasStages + } + ] + + const manageActions = [ + { + key: "edit_document", + label: "Edit Document", + icon: Edit, + action: () => setRowAction({ row, type: "edit_document" }), + show: true + } + ] + + const dangerActions = [ + { + key: "delete", + label: "Delete Document", + icon: Trash2, + action: () => setRowAction({ row, type: "delete" }), + show: true, + className: "text-red-600", + shortcut: "⌘⌫" + } + ] + + const hasViewActions = viewActions.some(action => action.show) + const hasManageActions = manageActions.some(action => action.show) + const hasDangerActions = dangerActions.some(action => action.show) + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-6 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-3" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + {hasViewActions && ( + <> + {viewActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + {hasManageActions && <DropdownMenuSeparator />} + </> + )} + + {manageActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + + {hasDangerActions && ( + <> + <DropdownMenuSeparator /> + {dangerActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ) + + return columns +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx new file mode 100644 index 00000000..2f6b637c --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -0,0 +1,136 @@ +"use client" + +import React from "react" +import { DocumentStagesOnlyView } from "@/db/schema" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { + Calendar, + CheckCircle, + Edit, + FileText +} from "lucide-react" +import { formatDate } from "@/lib/utils" +import { cn } from "@/lib/utils" + +interface DocumentStagesExpandedContentProps { + document: DocumentStagesOnlyView + onEditStage: (stageId: number) => void + projectType: "ship" | "plant" +} + +// 상태별 색상 및 텍스트 유틸리티 +const getStatusVariant = (status: string) => { + switch (status) { + case 'COMPLETED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출' + case 'COMPLETED': return '완료' + case 'REJECTED': return '반려' + default: return status + } +} + +export function DocumentStagesExpandedContent({ + document, + onEditStage, + projectType +}: DocumentStagesExpandedContentProps) { + const stages = document.allStages || [] + const sortedStages = stages.sort((a, b) => a.stageOrder - b.stageOrder) + + return ( + <div className="bg-gray-50 border-t p-3"> + {stages.length === 0 ? ( + <div className="text-center py-3 text-gray-500 text-sm"> + 등록된 스테이지가 없습니다. + </div> + ) : ( + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2"> + {sortedStages.map((stage, index) => { + const isCurrentStage = stage.id === document.currentStageId + const planDate = stage.planDate ? new Date(stage.planDate) : null + const actualDate = stage.actualDate ? new Date(stage.actualDate) : null + + return ( + <div + key={stage.id} + className={cn( + "relative p-2 rounded-md border text-xs transition-colors hover:shadow-sm", + isCurrentStage + ? "bg-blue-50 border-blue-200" + : "bg-white border-gray-200" + )} + > + {/* 스테이지 순서 */} + <div className="absolute -top-1 -left-1 bg-gray-600 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-medium"> + {index + 1} + </div> + + {/* 스테이지명 */} + <div className="mb-2 pr-6"> + <div className="font-medium text-sm truncate" title={stage.stageName}> + {stage.stageName} + </div> + {isCurrentStage && ( + <Badge variant="default" className="text-xs px-1 py-0 mt-1"> + 현재 + </Badge> + )} + </div> + + {/* 상태 */} + <div className="mb-2"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs px-1.5 py-0" + > + {getStatusText(stage.stageStatus)} + </Badge> + </div> + + {/* 날짜 정보 */} + <div className="space-y-1 text-xs text-gray-600 mb-2"> + {planDate && ( + <div className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + <span>계획: {formatDate(planDate.toISOString(), 'MM/dd')}</span> + </div> + )} + {actualDate && ( + <div className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3 text-green-500" /> + <span>실적: {formatDate(actualDate.toISOString(), 'MM/dd')}</span> + </div> + )} + </div> + + {/* 편집 버튼 */} + <Button + size="sm" + variant="ghost" + onClick={() => onEditStage(stage.id)} + className="absolute top-1 right-1 h-5 w-5 p-0 hover:bg-gray-100" + > + <Edit className="h-3 w-3" /> + </Button> + </div> + ) + })} + </div> + )} + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts new file mode 100644 index 00000000..2fd20fa4 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -0,0 +1,1097 @@ +// document-stage-actions.ts +"use server" + +import { revalidatePath, revalidateTag } from "next/cache" +import { redirect } from "next/navigation" +import db from "@/db/db" +import { codeGroups, comboBoxSettings, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema" +import { and, eq, asc, desc, sql, inArray, max } from "drizzle-orm" +import { + createDocumentSchema, + updateDocumentSchema, + deleteDocumentSchema, + createStageSchema, + updateStageSchema, + deleteStageSchema, + reorderStagesSchema, + bulkCreateDocumentsSchema, + bulkUpdateStatusSchema, + bulkAssignSchema, + validateDocNumber, + validateB4Fields, + validateStageOrder, + type CreateDocumentInput, + type UpdateDocumentInput, + type CreateStageInput, + type UpdateStageInput, + type ExcelImportResult, +} from "./document-stage-validations" +import { unstable_noStore as noStore } from "next/cache" +import { filterColumns } from "@/lib/filter-columns" +import { GetEnhancedDocumentsSchema } from "../enhanced-document-service" +import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" + +// ============================================================================= +// 1. 문서 관련 액션들 +// ============================================================================= + +// 문서 생성 +// export async function createDocument(input: CreateDocumentInput) { +// noStore() + +// try { +// // 입력값 검증 +// const validatedData = createDocumentSchema.parse(input) + +// // 프로젝트 타입 확인 (계약 정보에서 가져와야 함) +// const contract = await db.query.contracts.findFirst({ +// where: eq(documents.contractId, validatedData.contractId), +// with: { project: true } +// }) + +// if (!contract) { +// throw new Error("계약 정보를 찾을 수 없습니다") +// } + +// const projectType = contract.project?.type === "plant" ? "plant" : "ship" + +// // 문서번호 유효성 검사 +// if (!validateDocNumber(validatedData.docNumber, projectType)) { +// throw new Error(`${projectType === "ship" ? "선박" : "플랜트"} 프로젝트의 문서번호 형식에 맞지 않습니다`) +// } + +// // B4 필드 유효성 검사 +// validateB4Fields(validatedData) + +// // 문서번호 중복 검사 +// const existingDoc = await db.query.documents.findFirst({ +// where: and( +// eq(documents.contractId, validatedData.contractId), +// eq(documents.docNumber, validatedData.docNumber), +// eq(documents.status, "ACTIVE") +// ) +// }) + +// if (existingDoc) { +// throw new Error("이미 존재하는 문서번호입니다") +// } + +// // 문서 생성 +// const [newDocument] = await db.insert(documents).values({ +// contractId: validatedData.contractId, +// docNumber: validatedData.docNumber, +// title: validatedData.title, +// drawingKind: validatedData.drawingKind, +// vendorDocNumber: validatedData.vendorDocNumber || null, +// pic: validatedData.pic || null, +// issuedDate: validatedData.issuedDate || null, +// drawingMoveGbn: validatedData.drawingMoveGbn || null, +// discipline: validatedData.discipline || null, +// externalDocumentId: validatedData.externalDocumentId || null, +// externalSystemType: validatedData.externalSystemType || null, +// cGbn: validatedData.cGbn || null, +// dGbn: validatedData.dGbn || null, +// degreeGbn: validatedData.degreeGbn || null, +// deptGbn: validatedData.deptGbn || null, +// jGbn: validatedData.jGbn || null, +// sGbn: validatedData.sGbn || null, +// shiDrawingNo: validatedData.shiDrawingNo || null, +// manager: validatedData.manager || null, +// managerENM: validatedData.managerENM || null, +// managerNo: validatedData.managerNo || null, +// status: "ACTIVE", +// createdAt: new Date(), +// updatedAt: new Date(), +// }).returning() + +// // 캐시 무효화 +// revalidateTag(`documents-${validatedData.contractId}`) +// revalidatePath(`/contracts/${validatedData.contractId}/documents`) + +// return { +// success: true, +// data: newDocument, +// message: "문서가 성공적으로 생성되었습니다" +// } + +// } catch (error) { +// console.error("Error creating document:", error) +// return { +// success: false, +// error: error instanceof Error ? error.message : "문서 생성 중 오류가 발생했습니다" +// } +// } +// } + +// 문서 수정 +export async function updateDocument(input: UpdateDocumentInput) { + noStore() + + try { + const validatedData = updateDocumentSchema.parse(input) + + // 문서 존재 확인 + const existingDoc = await db.query.documents.findFirst({ + where: eq(documents.id, validatedData.id) + }) + + if (!existingDoc) { + throw new Error("문서를 찾을 수 없습니다") + } + + // B4 필드 유효성 검사 (drawingKind 변경 시) + if (validatedData.drawingKind) { + validateB4Fields(validatedData) + } + + // 문서번호 중복 검사 (문서번호 변경 시) + if (validatedData.docNumber && validatedData.docNumber !== existingDoc.docNumber) { + const duplicateDoc = await db.query.documents.findFirst({ + where: and( + eq(documents.contractId, existingDoc.contractId), + eq(documents.docNumber, validatedData.docNumber), + eq(documents.status, "ACTIVE") + ) + }) + + if (duplicateDoc) { + throw new Error("이미 존재하는 문서번호입니다") + } + } + + // 문서 업데이트 + const [updatedDocument] = await db + .update(documents) + .set({ + ...validatedData, + updatedAt: new Date(), + }) + .where(eq(documents.id, validatedData.id)) + .returning() + + // 캐시 무효화 + revalidateTag(`documents-${existingDoc.contractId}`) + revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + + return { + success: true, + data: updatedDocument, + message: "문서가 성공적으로 수정되었습니다" + } + + } catch (error) { + console.error("Error updating document:", error) + return { + success: false, + error: error instanceof Error ? error.message : "문서 수정 중 오류가 발생했습니다" + } + } +} + +// 문서 삭제 (소프트 삭제) +export async function deleteDocument(input: { id: number }) { + noStore() + + try { + const validatedData = deleteDocumentSchema.parse(input) + + // 문서 존재 확인 + const existingDoc = await db.query.documents.findFirst({ + where: eq(documents.id, validatedData.id) + }) + + if (!existingDoc) { + throw new Error("문서를 찾을 수 없습니다") + } + + // 연관된 스테이지 확인 + const relatedStages = await db.query.issueStages.findMany({ + where: eq(issueStages.documentId, validatedData.id) + }) + + if (relatedStages.length > 0) { + throw new Error("연관된 스테이지가 있는 문서는 삭제할 수 없습니다. 먼저 스테이지를 삭제해주세요.") + } + + // 소프트 삭제 (상태 변경) + await db + .update(documents) + .set({ + status: "DELETED", + updatedAt: new Date(), + }) + .where(eq(documents.id, validatedData.id)) + + // 캐시 무효화 + revalidateTag(`documents-${existingDoc.contractId}`) + revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + + return { + success: true, + message: "문서가 성공적으로 삭제되었습니다" + } + + } catch (error) { + console.error("Error deleting document:", error) + return { + success: false, + error: error instanceof Error ? error.message : "문서 삭제 중 오류가 발생했습니다" + } + } +} + +// ============================================================================= +// 2. 스테이지 관련 액션들 +// ============================================================================= + +// 스테이지 생성 +export async function createStage(input: CreateStageInput) { + noStore() + + try { + const validatedData = createStageSchema.parse(input) + + // 문서 존재 확인 + const document = await db.query.documents.findFirst({ + where: eq(documents.id, validatedData.documentId) + }) + + if (!document) { + throw new Error("문서를 찾을 수 없습니다") + } + + // 스테이지명 중복 검사 + const existingStage = await db.query.issueStages.findFirst({ + where: and( + eq(issueStages.documentId, validatedData.documentId), + eq(issueStages.stageName, validatedData.stageName) + ) + }) + + if (existingStage) { + throw new Error("이미 존재하는 스테이지명입니다") + } + + // 스테이지 순서 자동 설정 (제공되지 않은 경우) + let stageOrder = validatedData.stageOrder + if (stageOrder === 0 || stageOrder === undefined) { + const maxOrderResult = await db + .select({ maxOrder: max(issueStages.stageOrder) }) + .from(issueStages) + .where(eq(issueStages.documentId, validatedData.documentId)) + + stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1 + } + + // 스테이지 생성 + const [newStage] = await db.insert(issueStages).values({ + documentId: validatedData.documentId, + stageName: validatedData.stageName, + planDate: validatedData.planDate || null, + stageStatus: validatedData.stageStatus, + stageOrder, + priority: validatedData.priority, + assigneeId: validatedData.assigneeId || null, + assigneeName: validatedData.assigneeName || null, + reminderDays: validatedData.reminderDays, + description: validatedData.description || null, + notes: validatedData.notes || null, + createdAt: new Date(), + updatedAt: new Date(), + }).returning() + + // 캐시 무효화 + revalidateTag(`documents-${document.contractId}`) + revalidateTag(`document-${validatedData.documentId}`) + revalidatePath(`/contracts/${document.contractId}/documents`) + + return { + success: true, + data: newStage, + message: "스테이지가 성공적으로 생성되었습니다" + } + + } catch (error) { + console.error("Error creating stage:", error) + return { + success: false, + error: error instanceof Error ? error.message : "스테이지 생성 중 오류가 발생했습니다" + } + } +} + +// 스테이지 수정 +export async function updateStage(input: UpdateStageInput) { + noStore() + + try { + const validatedData = updateStageSchema.parse(input) + + // 스테이지 존재 확인 + const existingStage = await db.query.issueStages.findFirst({ + where: eq(issueStages.id, validatedData.id), + with: { + document: true + } + }) + + if (!existingStage) { + throw new Error("스테이지를 찾을 수 없습니다") + } + + // 스테이지명 중복 검사 (스테이지명 변경 시) + if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { + const duplicateStage = await db.query.issueStages.findFirst({ + where: and( + eq(issueStages.documentId, existingStage.documentId), + eq(issueStages.stageName, validatedData.stageName) + ) + }) + + if (duplicateStage) { + throw new Error("이미 존재하는 스테이지명입니다") + } + } + + // 스테이지 업데이트 + const [updatedStage] = await db + .update(issueStages) + .set({ + ...validatedData, + updatedAt: new Date(), + }) + .where(eq(issueStages.id, validatedData.id)) + .returning() + + // 캐시 무효화 + revalidateTag(`documents-${existingStage.document.contractId}`) + revalidateTag(`document-${existingStage.documentId}`) + revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) + + return { + success: true, + data: updatedStage, + message: "스테이지가 성공적으로 수정되었습니다" + } + + } catch (error) { + console.error("Error updating stage:", error) + return { + success: false, + error: error instanceof Error ? error.message : "스테이지 수정 중 오류가 발생했습니다" + } + } +} + +// 스테이지 삭제 +export async function deleteStage(input: { id: number }) { + noStore() + + try { + const validatedData = deleteStageSchema.parse(input) + + // 스테이지 존재 확인 + const existingStage = await db.query.issueStages.findFirst({ + where: eq(issueStages.id, validatedData.id), + with: { + document: true + } + }) + + if (!existingStage) { + throw new Error("스테이지를 찾을 수 없습니다") + } + + // 연관된 리비전 확인 (향후 구현 시) + // const relatedRevisions = await db.query.revisions.findMany({ + // where: eq(revisions.issueStageId, validatedData.id) + // }) + + // if (relatedRevisions.length > 0) { + // throw new Error("연관된 리비전이 있는 스테이지는 삭제할 수 없습니다") + // } + + // 스테이지 삭제 + await db.delete(issueStages).where(eq(issueStages.id, validatedData.id)) + + // 스테이지 순서 재정렬 + const remainingStages = await db.query.issueStages.findMany({ + where: eq(issueStages.documentId, existingStage.documentId), + orderBy: [issueStages.stageOrder] + }) + + for (let i = 0; i < remainingStages.length; i++) { + if (remainingStages[i].stageOrder !== i) { + await db + .update(issueStages) + .set({ stageOrder: i, updatedAt: new Date() }) + .where(eq(issueStages.id, remainingStages[i].id)) + } + } + + // 캐시 무효화 + revalidateTag(`documents-${existingStage.document.contractId}`) + revalidateTag(`document-${existingStage.documentId}`) + revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) + + return { + success: true, + message: "스테이지가 성공적으로 삭제되었습니다" + } + + } catch (error) { + console.error("Error deleting stage:", error) + return { + success: false, + error: error instanceof Error ? error.message : "스테이지 삭제 중 오류가 발생했습니다" + } + } +} + +// 스테이지 순서 변경 +export async function reorderStages(input: any) { + noStore() + + try { + const validatedData = reorderStagesSchema.parse(input) + + // 스테이지 순서 유효성 검사 + validateStageOrder(validatedData.stages) + + // 문서 존재 확인 + const document = await db.query.documents.findFirst({ + where: eq(documents.id, validatedData.documentId) + }) + + if (!document) { + throw new Error("문서를 찾을 수 없습니다") + } + + // 스테이지들이 해당 문서에 속하는지 확인 + const stageIds = validatedData.stages.map(s => s.id) + const existingStages = await db.query.issueStages.findMany({ + where: and( + eq(issueStages.documentId, validatedData.documentId), + inArray(issueStages.id, stageIds) + ) + }) + + if (existingStages.length !== validatedData.stages.length) { + throw new Error("일부 스테이지가 해당 문서에 속하지 않습니다") + } + + // 트랜잭션으로 순서 업데이트 + await db.transaction(async (tx) => { + for (const stage of validatedData.stages) { + await tx + .update(issueStages) + .set({ + stageOrder: stage.stageOrder, + updatedAt: new Date() + }) + .where(eq(issueStages.id, stage.id)) + } + }) + + // 캐시 무효화 + revalidateTag(`documents-${document.contractId}`) + revalidateTag(`document-${validatedData.documentId}`) + revalidatePath(`/contracts/${document.contractId}/documents`) + + return { + success: true, + message: "스테이지 순서가 성공적으로 변경되었습니다" + } + + } catch (error) { + console.error("Error reordering stages:", error) + return { + success: false, + error: error instanceof Error ? error.message : "스테이지 순서 변경 중 오류가 발생했습니다" + } + } +} + +// ============================================================================= +// 3. 일괄 작업 액션들 +// ============================================================================= + +// 일괄 문서 생성 (엑셀 임포트) +export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult> { + noStore() + + try { + const validatedData = bulkCreateDocumentsSchema.parse(input) + + const result: ExcelImportResult = { + totalRows: validatedData.documents.length, + successCount: 0, + failureCount: 0, + errors: [], + createdDocuments: [] + } + + // 트랜잭션으로 일괄 처리 + await db.transaction(async (tx) => { + for (let i = 0; i < validatedData.documents.length; i++) { + const docData = validatedData.documents[i] + + try { + // 문서번호 중복 검사 + const existingDoc = await tx.query.documents.findFirst({ + where: and( + eq(documents.contractId, validatedData.contractId), + eq(documents.docNumber, docData.docNumber), + eq(documents.status, "ACTIVE") + ) + }) + + if (existingDoc) { + result.errors.push({ + row: i + 2, // 엑셀 행 번호 (헤더 포함) + field: "docNumber", + message: `문서번호 '${docData.docNumber}'가 이미 존재합니다` + }) + result.failureCount++ + continue + } + + // 문서 생성 + const [newDoc] = await tx.insert(documents).values({ + contractId: validatedData.contractId, + docNumber: docData.docNumber, + title: docData.title, + drawingKind: docData.drawingKind, + vendorDocNumber: docData.vendorDocNumber || null, + pic: docData.pic || null, + issuedDate: docData.issuedDate || null, + cGbn: docData.cGbn || null, + dGbn: docData.dGbn || null, + degreeGbn: docData.degreeGbn || null, + deptGbn: docData.deptGbn || null, + jGbn: docData.jGbn || null, + sGbn: docData.sGbn || null, + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + }).returning() + + result.createdDocuments.push({ + id: newDoc.id, + docNumber: newDoc.docNumber, + title: newDoc.title + }) + result.successCount++ + + } catch (error) { + result.errors.push({ + row: i + 2, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }) + result.failureCount++ + } + } + }) + + // 캐시 무효화 + revalidateTag(`documents-${validatedData.contractId}`) + revalidatePath(`/contracts/${validatedData.contractId}/documents`) + + return result + + } catch (error) { + console.error("Error bulk creating documents:", error) + throw new Error("일괄 문서 생성 중 오류가 발생했습니다") + } +} + +// 일괄 상태 업데이트 +export async function bulkUpdateStageStatus(input: any) { + noStore() + + try { + const validatedData = bulkUpdateStatusSchema.parse(input) + + // 스테이지들 존재 확인 + const existingStages = await db.query.issueStages.findMany({ + where: inArray(issueStages.id, validatedData.stageIds), + with: { document: true } + }) + + if (existingStages.length !== validatedData.stageIds.length) { + throw new Error("일부 스테이지를 찾을 수 없습니다") + } + + // 일괄 업데이트 + await db + .update(issueStages) + .set({ + stageStatus: validatedData.status, + actualDate: validatedData.actualDate || null, + updatedAt: new Date() + }) + .where(inArray(issueStages.id, validatedData.stageIds)) + + // 관련된 계약들의 캐시 무효화 + const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] + for (const contractId of contractIds) { + revalidateTag(`documents-${contractId}`) + revalidatePath(`/contracts/${contractId}/documents`) + } + + return { + success: true, + message: `${validatedData.stageIds.length}개 스테이지의 상태가 업데이트되었습니다` + } + + } catch (error) { + console.error("Error bulk updating stage status:", error) + return { + success: false, + error: error instanceof Error ? error.message : "일괄 상태 업데이트 중 오류가 발생했습니다" + } + } +} + +// 일괄 담당자 지정 +export async function bulkAssignStages(input: any) { + noStore() + + try { + const validatedData = bulkAssignSchema.parse(input) + + // 스테이지들 존재 확인 + const existingStages = await db.query.issueStages.findMany({ + where: inArray(issueStages.id, validatedData.stageIds), + with: { document: true } + }) + + if (existingStages.length !== validatedData.stageIds.length) { + throw new Error("일부 스테이지를 찾을 수 없습니다") + } + + // 일괄 담당자 지정 + await db + .update(issueStages) + .set({ + assigneeId: validatedData.assigneeId || null, + assigneeName: validatedData.assigneeName || null, + updatedAt: new Date() + }) + .where(inArray(issueStages.id, validatedData.stageIds)) + + // 관련된 계약들의 캐시 무효화 + const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] + for (const contractId of contractIds) { + revalidateTag(`documents-${contractId}`) + revalidatePath(`/contracts/${contractId}/documents`) + } + + return { + success: true, + message: `${validatedData.stageIds.length}개 스테이지에 담당자가 지정되었습니다` + } + + } catch (error) { + console.error("Error bulk assigning stages:", error) + return { + success: false, + error: error instanceof Error ? error.message : "일괄 담당자 지정 중 오류가 발생했습니다" + } + } +} + + +// 문서번호 타입 목록 조회 +export async function getDocumentNumberTypes() { + try { + const types = await db + .select() + .from(documentNumberTypes) + .where(eq(documentNumberTypes.isActive, true)) + .orderBy(asc(documentNumberTypes.name)) + + return { success: true, data: types } + } catch (error) { + console.error("문서번호 타입 조회 실패:", error) + return { success: false, error: "문서번호 타입을 불러올 수 없습니다." } + } +} + +// 문서번호 타입 설정 조회 +export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) { + try { + const configs = await db + .select({ + id: documentNumberTypeConfigs.id, + sdq: documentNumberTypeConfigs.sdq, + description: documentNumberTypeConfigs.description, + remark: documentNumberTypeConfigs.remark, + codeGroupId: documentNumberTypeConfigs.codeGroupId, + documentClassId: documentNumberTypeConfigs.documentClassId, + codeGroup: { + id: codeGroups.id, + groupId: codeGroups.groupId, + description: codeGroups.description, + controlType: codeGroups.controlType, + }, + documentClass: { + id: documentClasses.id, + code: documentClasses.code, + description: documentClasses.description, + } + }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + .where( + and( + eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), + eq(documentNumberTypeConfigs.isActive, true) + ) + ) + .orderBy(asc(documentNumberTypeConfigs.sdq)) + + console.log(configs,"configs") + + return { success: true, data: configs } + } catch (error) { + console.error("문서번호 타입 설정 조회 실패:", error) + return { success: false, error: "문서번호 설정을 불러올 수 없습니다." } + } +} + +// 콤보박스 옵션 조회 +export async function getComboBoxOptions(codeGroupId: number) { + console.log(codeGroupId,"codeGroupId") + try { + const settings = await db + .select({ + id: comboBoxSettings.id, + code: comboBoxSettings.code, + description: comboBoxSettings.description, + remark: comboBoxSettings.remark, + }) + .from(comboBoxSettings) + .where(eq(comboBoxSettings.codeGroupId, codeGroupId)) + .orderBy(asc(comboBoxSettings.code)) + + console.log("settings",settings) + + return { success: true, data: settings } + } catch (error) { + console.error("콤보박스 옵션 조회 실패:", error) + return { success: false, error: "콤보박스 옵션을 불러올 수 없습니다." } + } +} + +// 문서 클래스 목록 조회 +export async function getDocumentClasses() { + try { + const classes = await db + .select() + .from(documentClasses) + .where(eq(documentClasses.isActive, true)) + .orderBy(asc(documentClasses.description)) + + return { success: true, data: classes } + } catch (error) { + console.error("문서 클래스 조회 실패:", error) + return { success: false, error: "문서 클래스를 불러올 수 없습니다." } + } +} + +// 문서 클래스 옵션 조회 (스테이지로 사용) +export async function getDocumentClassOptions(documentClassId: number) { + try { + const options = await db + .select() + .from(documentClassOptions) + .where( + and( + eq(documentClassOptions.documentClassId, documentClassId), + eq(documentClassOptions.isActive, true) + ) + ) + .orderBy(asc(documentClassOptions.sortOrder)) + + return { success: true, data: options } + } catch (error) { + console.error("문서 클래스 옵션 조회 실패:", error) + return { success: false, error: "문서 클래스 옵션을 불러올 수 없습니다." } + } +} + +// 문서번호 생성 +export async function generateDocumentNumber(configs: any[], values: Record<string, string>) { + let docNumber = "" + + configs.forEach((config) => { + const value = values[`field_${config.sdq}`] || "" + if (value) { + docNumber += value + // 구분자가 필요한 경우 추가 (하이픈 등) + if (config.sdq < configs.length) { + docNumber += "-" + } + } + }) + + return docNumber.replace(/-$/, "") // 마지막 하이픈 제거 +} + +// 문서 생성 +export async function createDocument(data: { + contractId: number + documentNumberTypeId: number + documentClassId: number + title: string + fieldValues: Record<string, string> + pic?: string + vendorDocNumber?: string +}) { + try { + // 1. 문서번호 타입 설정 조회 + const configsResult = await getDocumentNumberTypeConfigs(data.documentNumberTypeId) + if (!configsResult.success) { + return { success: false, error: configsResult.error } + } + + // 2. 문서번호 생성 + const documentNumber = generateDocumentNumber(configsResult.data, data.fieldValues) + + // 3. 문서 생성 (실제 documents 테이블에 INSERT) + // TODO: 실제 documents 테이블 스키마에 맞게 수정 필요 + /* + const [document] = await db.insert(documents).values({ + contractId: data.contractId, + docNumber: documentNumber, + title: data.title, + documentClassId: data.documentClassId, + pic: data.pic, + vendorDocNumber: data.vendorDocNumber, + }).returning() + */ + + // 4. 문서 클래스의 옵션들을 스테이지로 자동 생성 + const stageOptionsResult = await getDocumentClassOptions(data.documentClassId) + if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { + // TODO: 실제 stages 테이블에 스테이지들 생성 + /* + const stageInserts = stageOptionsResult.data.map((option, index) => ({ + documentId: document.id, + stageName: option.optionValue, + stageOrder: option.sortOrder || index + 1, + stageStatus: 'PLANNED', + // 기본값들... + })) + + await db.insert(documentStages).values(stageInserts) + */ + } + + revalidatePath(`/contracts/${data.contractId}/documents`) + + return { + success: true, + data: { + documentNumber, + // document + } + } + } catch (error) { + console.error("문서 생성 실패:", error) + return { success: false, error: "문서 생성 중 오류가 발생했습니다." } + } +} + +// 스테이지 업데이트 +// export async function updateStage(data: { +// stageId: number +// stageName?: string +// planDate?: string +// actualDate?: string +// stageStatus?: string +// assigneeName?: string +// priority?: string +// notes?: string +// }) { +// try { +// // TODO: 실제 stages 테이블 업데이트 +// /* +// await db +// .update(documentStages) +// .set({ +// ...data, +// updatedAt: new Date(), +// }) +// .where(eq(documentStages.id, data.stageId)) +// */ + +// revalidatePath("/contracts/[contractId]/documents", "page") + +// return { success: true } +// } catch (error) { +// console.error("스테이지 업데이트 실패:", error) +// return { success: false, error: "스테이지 업데이트 중 오류가 발생했습니다." } +// } +// } + +export async function getDocumentStagesOnly( + input: GetEnhancedDocumentsSchema, + contractId: number +) { + try { + const offset = (input.page - 1) * input.perPage + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: documentStagesOnlyView, + filters: input.filters || [], + joinOperator: input.joinOperator || "and", + }) + + // 전역 검색 처리 + let globalWhere + if (input.search) { + const searchTerm = `%${input.search}%` + globalWhere = or( + ilike(documentStagesOnlyView.title, searchTerm), + ilike(documentStagesOnlyView.docNumber, searchTerm), + ilike(documentStagesOnlyView.currentStageName, searchTerm), + ilike(documentStagesOnlyView.currentStageAssigneeName, searchTerm), + ilike(documentStagesOnlyView.vendorDocNumber, searchTerm), + ilike(documentStagesOnlyView.pic, searchTerm) + ) + } + + // 최종 WHERE 조건 + const finalWhere = and( + advancedWhere, + globalWhere, + eq(documentStagesOnlyView.contractId, contractId) + ) + + // 정렬 처리 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(documentStagesOnlyView[item.id]) + : asc(documentStagesOnlyView[item.id]) + ) + : [desc(documentStagesOnlyView.createdAt)] + + // 트랜잭션 실행 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectDocumentStagesOnly(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + + const total = await countDocumentStagesOnly(tx, finalWhere) + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount, total } + } catch (err) { + console.error("Error fetching document stages only:", err) + return { data: [], pageCount: 0, total: 0 } + } +} + +// 단일 문서의 스테이지 정보 조회 +export async function getDocumentStagesOnlyById(documentId: number) { + try { + const result = await db + .select() + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.documentId, documentId)) + .limit(1) + + return result[0] || null + } catch (err) { + console.error("Error fetching document stages by id:", err) + return null + } +} + +// 특정 계약의 문서 개수 조회 (빠른 카운트) +export async function getDocumentStagesOnlyCount(contractId: number) { + try { + const result = await db + .select({ count: sql<number>`count(*)` }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + + return result[0]?.count ?? 0 + } catch (err) { + console.error("Error counting document stages:", err) + return 0 + } +} + +// 진행률별 문서 통계 조회 +export async function getDocumentProgressStats(contractId: number) { + try { + const result = await db + .select({ + totalDocuments: sql<number>`count(*)`, + completedDocuments: sql<number>`count(case when progress_percentage = 100 then 1 end)`, + inProgressDocuments: sql<number>`count(case when progress_percentage > 0 and progress_percentage < 100 then 1 end)`, + notStartedDocuments: sql<number>`count(case when progress_percentage = 0 then 1 end)`, + overdueDocuments: sql<number>`count(case when is_overdue = true then 1 end)`, + avgProgress: sql<number>`round(avg(progress_percentage), 2)`, + }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + + return result[0] || { + totalDocuments: 0, + completedDocuments: 0, + inProgressDocuments: 0, + notStartedDocuments: 0, + overdueDocuments: 0, + avgProgress: 0, + } + } catch (err) { + console.error("Error fetching document progress stats:", err) + return { + totalDocuments: 0, + completedDocuments: 0, + inProgressDocuments: 0, + notStartedDocuments: 0, + overdueDocuments: 0, + avgProgress: 0, + } + } +} + +// 스테이지별 문서 분포 조회 +export async function getDocumentsByStageStats(contractId: number) { + try { + const result = await db + .select({ + stageName: documentStagesOnlyView.currentStageName, + stageStatus: documentStagesOnlyView.currentStageStatus, + documentCount: sql<number>`count(*)`, + overdueCount: sql<number>`count(case when is_overdue = true then 1 end)`, + }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + .groupBy( + documentStagesOnlyView.currentStageName, + documentStagesOnlyView.currentStageStatus + ) + .orderBy(sql`count(*) desc`) + + return result + } catch (err) { + console.error("Error fetching documents by stage stats:", err) + return [] + } +} diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx new file mode 100644 index 00000000..736a7467 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -0,0 +1,449 @@ +"use client" + +import React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { getDocumentStagesOnly } from "./document-stages-service" +import type { DocumentStagesOnlyView } from "@/db/schema" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, + Plus, + FileSpreadsheet +} from "lucide-react" +import { getDocumentStagesColumns } from "./document-stages-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { DocumentStagesExpandedContent } from "./document-stages-expanded-content" +import { AddDocumentDialog } from "./document-stage-dialogs" +import { EditDocumentDialog } from "./document-stage-dialogs" +import { EditStageDialog } from "./document-stage-dialogs" +import { ExcelImportDialog } from "./document-stage-dialogs" + +interface DocumentStagesTableProps { + promises: Promise<[Awaited<ReturnType<typeof getDocumentStagesOnly>>]> + contractId: number + projectType: "ship" | "plant" +} + +export function DocumentStagesTable({ + promises, + contractId, + projectType, +}: DocumentStagesTableProps) { + const [{ data, pageCount, total }] = React.use(promises) + + console.log(data) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // 다이얼로그 상태들 + const [addDocumentOpen, setAddDocumentOpen] = React.useState(false) + const [editDocumentOpen, setEditDocumentOpen] = React.useState(false) + const [editStageOpen, setEditStageOpen] = React.useState(false) + const [excelImportOpen, setExcelImportOpen] = React.useState(false) + + // 선택된 항목들 + const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null) + const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null) + + // 컬럼 정의 + const columns = React.useMemo( + () => getDocumentStagesColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + switch (action.type) { + case "edit_document": + setEditDocumentOpen(true) + break + case "edit_stage": + if (action.meta?.stageId) { + setSelectedStageId(action.meta.stageId) + setEditStageOpen(true) + } + break + case "view": + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + }, + projectType + }), + [expandedRows, projectType] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // 핸들러 함수들 + const handleNewDocument = () => { + setAddDocumentOpen(true) + } + + const handleExcelImport = () => { + setExcelImportOpen(true) + } + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_complete') { + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + toast.success(`${stageIds.length}개 스테이지가 완료 처리되었습니다.`) + } + } else if (action === 'bulk_assign') { + toast.info("일괄 담당자 지정 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + const closeAllDialogs = () => { + setAddDocumentOpen(false) + setEditDocumentOpen(false) + setEditStageOpen(false) + setExcelImportOpen(false) + setSelectedDocument(null) + setSelectedStageId(null) + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 문서 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 테이블 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <DocumentStagesExpandedContent + document={document} + onEditStage={(stageId) => { + setSelectedDocument(document) + setSelectedStageId(stageId) + setEditStageOpen(true) + }} + projectType={projectType} + /> + )} + expandedRowClassName="!p-0" + excludeFromClick={[ + 'actions', + 'select' + ]} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button onClick={handleNewDocument} size="sm"> + <Plus className="mr-2 h-4 w-4" /> + 문서 추가 + </Button> + <Button onClick={handleExcelImport} variant="outline" size="sm"> + <FileSpreadsheet className="mr-2 h-4 w-4" /> + 엑셀 가져오기 + </Button> + </div> + </DataTableAdvancedToolbar> + </ExpandableDataTable> + </div> + </div> + + {/* 다이얼로그들 */} + <AddDocumentDialog + open={addDocumentOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setAddDocumentOpen(open) + }} + contractId={contractId} + projectType={projectType} + /> + + <EditDocumentDialog + open={editDocumentOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDocumentOpen(open) + }} + document={selectedDocument} + contractId={contractId} + projectType={projectType} + /> + + <EditStageDialog + open={editStageOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditStageOpen(open) + }} + document={selectedDocument} + stageId={selectedStageId} + /> + + <ExcelImportDialog + open={excelImportOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setExcelImportOpen(open) + }} + contractId={contractId} + projectType={projectType} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts new file mode 100644 index 00000000..3ddb7195 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -0,0 +1,788 @@ +// excel-import-export.ts +"use client" + +import ExcelJS from 'exceljs' +import { + excelDocumentRowSchema, + excelStageRowSchema, + type ExcelDocumentRow, + type ExcelStageRow, + type ExcelImportResult, + type CreateDocumentInput +} from './document-stage-validations' +import { DocumentStagesOnlyView } from '@/db/schema' + +// ============================================================================= +// 1. 엑셀 템플릿 생성 및 다운로드 +// ============================================================================= + +// 문서 템플릿 생성 +export async function createDocumentTemplate(projectType: "ship" | "plant") { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("문서목록", { + properties: { defaultColWidth: 15 } + }) + + const baseHeaders = [ + "문서번호*", + "문서명*", + "문서종류*", + "PIC", + "발행일", + "설명" + ] + + const plantHeaders = [ + "벤더문서번호", + "벤더명", + "벤더코드" + ] + + const b4Headers = [ + "C구분", + "D구분", + "Degree구분", + "부서구분", + "S구분", + "J구분" + ] + + const headers = [ + ...baseHeaders, + ...(projectType === "plant" ? plantHeaders : []), + ...b4Headers + ] + + // 헤더 행 추가 및 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true, + size: 11 + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 필수 필드 표시 + if (cell.value && String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) + + // 샘플 데이터 추가 + const sampleData = projectType === "ship" ? [ + "SH-2024-001", + "기본 설계 도면", + "B3", + "김철수", + new Date("2024-01-15"), + "선박 기본 설계 관련 문서", + "", "", "", "", "", "" // B4 필드들 + ] : [ + "PL-2024-001", + "공정 설계 도면", + "B4", + "이영희", + new Date("2024-01-15"), + "플랜트 공정 설계 관련 문서", + "V-001", // 벤더문서번호 + "삼성엔지니어링", // 벤더명 + "SENG", // 벤더코드 + "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 필드들 + ] + + const sampleRow = worksheet.addRow(sampleData) + sampleRow.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 날짜 형식 설정 + if (cell.value instanceof Date) { + cell.numFmt = 'yyyy-mm-dd' + } + }) + + // 컬럼 너비 자동 조정 + worksheet.columns.forEach((column, index) => { + if (index < 6) { + column.width = headers[index].length + 5 + } else { + column.width = 12 + } + }) + + // 문서종류 드롭다운 설정 + const docTypeCol = headers.indexOf("문서종류*") + 1 + worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, { + type: 'list', + allowBlank: false, + formulae: ['"B3,B4,B5"'] + }) + + // Plant 프로젝트의 경우 우선순위 드롭다운 추가 + if (projectType === "plant") { + // 여기에 추가적인 드롭다운들을 설정할 수 있습니다 + } + + return workbook +} + +// 스테이지 템플릿 생성 +export async function createStageTemplate() { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("스테이지목록", { + properties: { defaultColWidth: 15 } + }) + + const headers = [ + "문서번호*", + "스테이지명*", + "계획일", + "우선순위", + "담당자", + "설명", + "스테이지순서" + ] + + // 헤더 행 추가 및 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF27AE60' } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true, + size: 11 + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 필수 필드 표시 + if (cell.value && String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) + + // 샘플 데이터 추가 + const sampleData = [ + [ + "SH-2024-001", + "초기 설계 검토", + new Date("2024-02-15"), + "HIGH", + "김철수", + "초기 설계안 검토 및 승인", + 0 + ], + [ + "SH-2024-001", + "상세 설계", + new Date("2024-03-15"), + "MEDIUM", + "이영희", + "상세 설계 작업 수행", + 1 + ] + ] + + sampleData.forEach(rowData => { + const row = worksheet.addRow(rowData) + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 날짜 형식 설정 + if (cell.value instanceof Date) { + cell.numFmt = 'yyyy-mm-dd' + } + }) + }) + + // 컬럼 너비 설정 + worksheet.columns = [ + { width: 15 }, // 문서번호 + { width: 20 }, // 스테이지명 + { width: 12 }, // 계획일 + { width: 10 }, // 우선순위 + { width: 15 }, // 담당자 + { width: 30 }, // 설명 + { width: 12 }, // 스테이지순서 + ] + + // 우선순위 드롭다운 설정 + worksheet.dataValidations.add('D2:D1000', { + type: 'list', + allowBlank: true, + formulae: ['"HIGH,MEDIUM,LOW"'] + }) + + return workbook +} + +// 템플릿 다운로드 함수 +export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") { + const workbook = await (type === "documents" + ? createDocumentTemplate(projectType) + : createStageTemplate()) + + const filename = type === "documents" + ? `문서_임포트_템플릿_${projectType}.xlsx` + : `스테이지_임포트_템플릿.xlsx` + + // 브라우저에서 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + + // 메모리 정리 + window.URL.revokeObjectURL(url) +} + +// ============================================================================= +// 2. 엑셀 파일 읽기 및 파싱 +// ============================================================================= + +// 엑셀 파일을 읽어서 JSON으로 변환 +export async function readExcelFile(file: File): Promise<any[]> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = async (e) => { + try { + const buffer = e.target?.result as ArrayBuffer + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(buffer) + + const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 + if (!worksheet) { + throw new Error('워크시트를 찾을 수 없습니다') + } + + const jsonData: any[] = [] + + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + const rowData: any[] = [] + row.eachCell({ includeEmpty: true }, (cell, colNumber) => { + let value = cell.value + + // 날짜 처리 + if (cell.type === ExcelJS.ValueType.Date) { + value = cell.value as Date + } + // 수식 결과값 처리 + else if (cell.type === ExcelJS.ValueType.Formula && cell.result) { + value = cell.result + } + // 하이퍼링크 처리 + else if (cell.type === ExcelJS.ValueType.Hyperlink) { + value = cell.value?.text || cell.value + } + + rowData[colNumber - 1] = value || "" + }) + + jsonData.push(rowData) + }) + + resolve(jsonData) + } catch (error) { + reject(new Error('엑셀 파일을 읽는 중 오류가 발생했습니다: ' + error)) + } + } + + reader.onerror = () => { + reject(new Error('파일을 읽을 수 없습니다')) + } + + reader.readAsArrayBuffer(file) + }) +} + +// 문서 데이터 유효성 검사 및 변환 +export function validateDocumentRows( + rawData: any[], + contractId: number, + projectType: "ship" | "plant" +): { validData: CreateDocumentInput[], errors: any[] } { + if (rawData.length < 2) { + throw new Error('데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다.') + } + + const headers = rawData[0] as string[] + const rows = rawData.slice(1) + + const validData: CreateDocumentInput[] = [] + const errors: any[] = [] + + // 필수 헤더 검사 + const requiredHeaders = ["문서번호", "문서명", "문서종류"] + const missingHeaders = requiredHeaders.filter(h => + !headers.some(header => header.includes(h.replace("*", ""))) + ) + + if (missingHeaders.length > 0) { + throw new Error(`필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`) + } + + // 헤더 인덱스 매핑 + const headerMap: Record<string, number> = {} + headers.forEach((header, index) => { + const cleanHeader = header.replace("*", "").trim() + headerMap[cleanHeader] = index + }) + + // 각 행 처리 + rows.forEach((row: any[], rowIndex) => { + try { + // 빈 행 스킵 + if (row.every(cell => !cell || String(cell).trim() === "")) { + return + } + + const rowData: any = { + contractId, + docNumber: String(row[headerMap["문서번호"]] || "").trim(), + title: String(row[headerMap["문서명"]] || "").trim(), + drawingKind: String(row[headerMap["문서종류"]] || "").trim(), + pic: String(row[headerMap["PIC"]] || "").trim() || undefined, + issuedDate: row[headerMap["발행일"]] ? + formatExcelDate(row[headerMap["발행일"]]) : undefined, + } + + // Plant 프로젝트 전용 필드 + if (projectType === "plant") { + rowData.vendorDocNumber = String(row[headerMap["벤더문서번호"]] || "").trim() || undefined + } + + // B4 전용 필드들 + const b4Fields = ["C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분"] + const b4FieldMap = { + "C구분": "cGbn", + "D구분": "dGbn", + "Degree구분": "degreeGbn", + "부서구분": "deptGbn", + "S구분": "sGbn", + "J구분": "jGbn" + } + + b4Fields.forEach(field => { + if (headerMap[field] !== undefined) { + const value = String(row[headerMap[field]] || "").trim() + if (value) { + rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value + } + } + }) + + // 유효성 검사 + const validatedData = excelDocumentRowSchema.parse({ + "문서번호": rowData.docNumber, + "문서명": rowData.title, + "문서종류": rowData.drawingKind, + "벤더문서번호": rowData.vendorDocNumber, + "PIC": rowData.pic, + "발행일": rowData.issuedDate, + "C구분": rowData.cGbn, + "D구분": rowData.dGbn, + "Degree구분": rowData.degreeGbn, + "부서구분": rowData.deptGbn, + "S구분": rowData.sGbn, + "J구분": rowData.jGbn, + }) + + // CreateDocumentInput 형태로 변환 + const documentInput: CreateDocumentInput = { + contractId, + docNumber: validatedData["문서번호"], + title: validatedData["문서명"], + drawingKind: validatedData["문서종류"], + vendorDocNumber: validatedData["벤더문서번호"], + pic: validatedData["PIC"], + issuedDate: validatedData["발행일"], + cGbn: validatedData["C구분"], + dGbn: validatedData["D구분"], + degreeGbn: validatedData["Degree구분"], + deptGbn: validatedData["부서구분"], + sGbn: validatedData["S구분"], + jGbn: validatedData["J구분"], + } + + validData.push(documentInput) + + } catch (error) { + errors.push({ + row: rowIndex + 2, // 엑셀 행 번호 (헤더 포함) + message: error instanceof Error ? error.message : "알 수 없는 오류", + data: row + }) + } + }) + + return { validData, errors } +} + +// 엑셀 날짜 형식 변환 +function formatExcelDate(value: any): string | undefined { + if (!value) return undefined + + // ExcelJS에서 Date 객체로 처리된 경우 + if (value instanceof Date) { + return value.toISOString().split('T')[0] + } + + // 이미 문자열 날짜 형식인 경우 + if (typeof value === 'string') { + const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/) + if (dateMatch) return value + + // 다른 형식 시도 + const date = new Date(value) + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0] + } + } + + // 엑셀 시리얼 날짜인 경우 + if (typeof value === 'number') { + // ExcelJS는 이미 Date 객체로 변환해주므로 이 경우는 드물지만 + // 1900년 1월 1일부터의 일수로 계산 + const excelEpoch = new Date(1900, 0, 1) + const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000) + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0] + } + } + + return undefined +} + +// ============================================================================= +// 3. 데이터 익스포트 +// ============================================================================= + +// 문서 데이터를 엑셀로 익스포트 +export function exportDocumentsToExcel( + documents: DocumentStagesOnlyView[], + projectType: "ship" | "plant" +) { + const headers = [ + "문서번호", + "문서명", + "문서종류", + "PIC", + "발행일", + "현재스테이지", + "스테이지상태", + "계획일", + "담당자", + "우선순위", + "진행률(%)", + "완료스테이지", + "전체스테이지", + "지연여부", + "남은일수", + "생성일", + "수정일" + ] + + // Plant 프로젝트 전용 헤더 추가 + if (projectType === "plant") { + headers.splice(3, 0, "벤더문서번호", "벤더명", "벤더코드") + } + + const data = documents.map(doc => { + const baseData = [ + doc.docNumber, + doc.title, + doc.drawingKind || "", + doc.pic || "", + doc.issuedDate || "", + doc.currentStageName || "", + getStatusText(doc.currentStageStatus || ""), + doc.currentStagePlanDate || "", + doc.currentStageAssigneeName || "", + getPriorityText(doc.currentStagePriority || ""), + doc.progressPercentage || 0, + doc.completedStages || 0, + doc.totalStages || 0, + doc.isOverdue ? "예" : "아니오", + doc.daysUntilDue || "", + doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "", + doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : "" + ] + + // Plant 프로젝트 데이터 추가 + if (projectType === "plant") { + baseData.splice(3, 0, + doc.vendorDocNumber || "", + doc.vendorName || "", + doc.vendorCode || "" + ) + } + + return baseData + }) + + const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) + + // 컬럼 너비 설정 + const colWidths = [ + { wch: 15 }, // 문서번호 + { wch: 30 }, // 문서명 + { wch: 10 }, // 문서종류 + ...(projectType === "plant" ? [ + { wch: 15 }, // 벤더문서번호 + { wch: 20 }, // 벤더명 + { wch: 10 }, // 벤더코드 + ] : []), + { wch: 10 }, // PIC + { wch: 12 }, // 발행일 + { wch: 15 }, // 현재스테이지 + { wch: 10 }, // 스테이지상태 + { wch: 12 }, // 계획일 + { wch: 10 }, // 담당자 + { wch: 8 }, // 우선순위 + { wch: 8 }, // 진행률 + { wch: 8 }, // 완료스테이지 + { wch: 8 }, // 전체스테이지 + { wch: 8 }, // 지연여부 + { wch: 8 }, // 남은일수 + { wch: 12 }, // 생성일 + { wch: 12 }, // 수정일 + ] + + worksheet['!cols'] = colWidths + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, "문서목록") + + const filename = `문서목록_${new Date().toISOString().split('T')[0]}.xlsx` + XLSX.writeFile(workbook, filename) +} + +// 스테이지 상세 데이터를 엑셀로 익스포트 +export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { + const headers = [ + "문서번호", + "문서명", + "스테이지명", + "스테이지상태", + "스테이지순서", + "계획일", + "담당자", + "우선순위", + "설명", + "노트", + "알림일수" + ] + + const data: any[] = [] + + documents.forEach(doc => { + if (doc.allStages && doc.allStages.length > 0) { + doc.allStages.forEach(stage => { + data.push([ + doc.docNumber, + doc.title, + stage.stageName, + getStatusText(stage.stageStatus), + stage.stageOrder, + stage.planDate || "", + stage.assigneeName || "", + getPriorityText(stage.priority), + stage.description || "", + stage.notes || "", + stage.reminderDays || "" + ]) + }) + } else { + // 스테이지가 없는 문서도 포함 + data.push([ + doc.docNumber, + doc.title, + "", "", "", "", "", "", "", "", "" + ]) + } + }) + + const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) + + // 컬럼 너비 설정 + worksheet['!cols'] = [ + { wch: 15 }, // 문서번호 + { wch: 30 }, // 문서명 + { wch: 20 }, // 스테이지명 + { wch: 12 }, // 스테이지상태 + { wch: 8 }, // 스테이지순서 + { wch: 12 }, // 계획일 + { wch: 10 }, // 담당자 + { wch: 8 }, // 우선순위 + { wch: 25 }, // 설명 + { wch: 25 }, // 노트 + { wch: 8 }, // 알림일수 + ] + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, "스테이지상세") + + const filename = `스테이지상세_${new Date().toISOString().split('T')[0]}.xlsx` + XLSX.writeFile(workbook, filename) +} + +// ============================================================================= +// 4. 유틸리티 함수들 +// ============================================================================= + +function getStatusText(status: string): string { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +function getPriorityText(priority: string): string { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +// 파일 크기 검증 +export function validateFileSize(file: File, maxSizeMB: number = 10): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +// 파일 확장자 검증 +export function validateFileExtension(file: File): boolean { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) +} + +// ExcelJS 워크북의 유효성 검사 +export async function validateExcelWorkbook(file: File): Promise<{ + isValid: boolean + error?: string + worksheetCount?: number + firstWorksheetName?: string +}> { + try { + const buffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(buffer) + + const worksheets = workbook.worksheets + if (worksheets.length === 0) { + return { + isValid: false, + error: '워크시트가 없는 파일입니다' + } + } + + const firstWorksheet = worksheets[0] + if (firstWorksheet.rowCount < 2) { + return { + isValid: false, + error: '데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다' + } + } + + return { + isValid: true, + worksheetCount: worksheets.length, + firstWorksheetName: firstWorksheet.name + } + } catch (error) { + return { + isValid: false, + error: `파일을 읽을 수 없습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}` + } + } +} + +// 셀 값을 안전하게 문자열로 변환 +export function getCellValueAsString(cell: ExcelJS.Cell): string { + if (!cell.value) return "" + + if (cell.value instanceof Date) { + return cell.value.toISOString().split('T')[0] + } + + if (typeof cell.value === 'object' && 'text' in cell.value) { + return cell.value.text || "" + } + + if (typeof cell.value === 'object' && 'result' in cell.value) { + return String(cell.value.result || "") + } + + return String(cell.value) +} + +// 엑셀 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) +export function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +}
\ No newline at end of file diff --git a/lib/vendor-document-list/repository.ts b/lib/vendor-document-list/repository.ts index 43adf7ca..4eab3853 100644 --- a/lib/vendor-document-list/repository.ts +++ b/lib/vendor-document-list/repository.ts @@ -1,5 +1,5 @@ import db from "@/db/db"; -import { documentStagesView } from "@/db/schema/vendorDocu"; +import { documentStagesOnlyView, documentStagesView } from "@/db/schema/vendorDocu"; import { eq, inArray, @@ -42,3 +42,37 @@ export async function countVendorDocuments( const res = await tx.select({ count: count() }).from(documentStagesView).where(where); return res[0]?.count ?? 0; } + + + +export async function selectDocumentStagesOnly( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(documentStagesOnlyView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +/** 총 개수 count */ +export async function countDocumentStagesOnly( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(documentStagesOnlyView).where(where); + return res[0]?.count ?? 0; +} + + + diff --git a/lib/vendor-document-list/validations.ts b/lib/vendor-document-list/validations.ts index acd101ed..88e68d67 100644 --- a/lib/vendor-document-list/validations.ts +++ b/lib/vendor-document-list/validations.ts @@ -19,8 +19,6 @@ export const searchParamsCache = createSearchParamsCache({ sort: getSortingStateParser<DocumentStagesView>().withDefault([ { id: "createdAt", desc: true }, ]), - title: parseAsString.withDefault(""), - docNumber: parseAsString.withDefault(""), // advanced filter filters: getFiltersStateParser().withDefault([]), |
