From 295585d8b6bc6fd9de5d40bd3145bdada557e8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 21:43:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=84=9C=20=EC=96=91=EC=8B=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=A0=8C=EB=8D=94=EB=A7=81=20+=20Lazy=20Snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FqcRequestDocumentContent: template 66 기반 동적 렌더링 컴포넌트 - 결재라인, 기본정보, 입력사항(4섹션), 사전고지 테이블 - group_name 기반 3단 헤더 (오픈사이즈 발주/시공 병합) - InspectionRequestModal: FQC 모드 전환 + EAV 문서 로드 + Lazy Snapshot - fqcActions: getFqcRequestTemplate, patchDocumentSnapshot, description/groupName 타입 - types/actions: requestDocumentId 필드 추가 및 매핑 - InspectionDetail: requestDocumentId prop 전달 --- .../InspectionManagement/InspectionDetail.tsx | 1 + .../quality/InspectionManagement/actions.ts | 2 + .../documents/FqcRequestDocumentContent.tsx | 461 ++++++++++++++++++ .../documents/InspectionRequestModal.tsx | 123 ++++- .../InspectionManagement/documents/index.ts | 1 + .../InspectionManagement/fqcActions.ts | 47 ++ .../quality/InspectionManagement/types.ts | 3 + 7 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx diff --git a/src/components/quality/InspectionManagement/InspectionDetail.tsx b/src/components/quality/InspectionManagement/InspectionDetail.tsx index 2e7319a8..7aecaeb3 100644 --- a/src/components/quality/InspectionManagement/InspectionDetail.tsx +++ b/src/components/quality/InspectionManagement/InspectionDetail.tsx @@ -1247,6 +1247,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) { open={requestDocOpen} onOpenChange={setRequestDocOpen} data={inspection ? buildRequestDocumentData(inspection) : null} + requestDocumentId={inspection?.requestDocumentId} /> {/* 제품검사성적서 모달 */} diff --git a/src/components/quality/InspectionManagement/actions.ts b/src/components/quality/InspectionManagement/actions.ts index 6cd2174b..2d81752e 100644 --- a/src/components/quality/InspectionManagement/actions.ts +++ b/src/components/quality/InspectionManagement/actions.ts @@ -96,6 +96,7 @@ interface ProductInspectionApi { construction_height: number; change_reason: string; }>; + request_document_id: number | null; created_at: string; updated_at: string; } @@ -233,6 +234,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection { changeReason: item.change_reason, inspectionData: item.inspection_data || undefined, })), + requestDocumentId: api.request_document_id ?? null, }; } diff --git a/src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx b/src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx new file mode 100644 index 00000000..64684a9d --- /dev/null +++ b/src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx @@ -0,0 +1,461 @@ +'use client'; + +/** + * 제품검사 요청서 - 양식 기반 동적 렌더링 + * + * template (ID 66) 구조를 기반으로 렌더링: + * - approvalLines: 결재라인 (작성/승인) + * - basicFields: 기본 정보 필드 (수주처, 업체명 등) + * - sections[0-3]: 입력사항 (건축공사장, 자재유통업자, 공사시공자, 공사감리자) + * - sections[4]: 검사대상 사전 고지 정보 (description + columns 테이블) + * - columns: 사전 고지 테이블 컬럼 (8개, group_name으로 병합 헤더) + */ + +import { + ConstructionApprovalTable, + DocumentWrapper, + DocumentTable, + DOC_STYLES, +} from '@/components/document-system'; +import type { FqcTemplate, FqcDocumentData } from '../fqcActions'; + +interface FqcRequestDocumentContentProps { + template: FqcTemplate; + documentData?: FqcDocumentData[]; + documentNo?: string; + createdDate?: string; + readonly?: boolean; +} + +/** 라벨 셀 */ +const lbl = `${DOC_STYLES.label} w-28`; +/** 서브 라벨 셀 */ +const subLbl = 'bg-gray-50 px-2 py-1 font-medium border-r border-gray-300 w-28'; +/** 값 셀 */ +const val = DOC_STYLES.value; + +/** EAV 데이터에서 field_key로 값 조회 */ +function getFieldValue( + data: FqcDocumentData[] | undefined, + fieldKey: string, +): string { + if (!data) return ''; + const found = data.find(d => d.fieldKey === fieldKey && d.sectionId === null); + return found?.fieldValue || ''; +} + +/** EAV 데이터에서 섹션 아이템 값 조회 */ +function getSectionItemValue( + data: FqcDocumentData[] | undefined, + sectionId: number, + fieldKey: string, +): string { + if (!data) return ''; + const found = data.find( + d => d.sectionId === sectionId && d.fieldKey === fieldKey + ); + return found?.fieldValue || ''; +} + +/** EAV 데이터에서 테이블 행 데이터 조회 */ +function getTableRows( + data: FqcDocumentData[] | undefined, + columns: FqcTemplate['columns'], +): Array> { + if (!data) return []; + // column_id가 있는 데이터만 필터 → row_index로 그룹핑 + const columnData = data.filter(d => d.columnId !== null); + if (columnData.length === 0) return []; + + const rowMap = new Map>(); + for (const d of columnData) { + if (!rowMap.has(d.rowIndex)) rowMap.set(d.rowIndex, {}); + const row = rowMap.get(d.rowIndex)!; + row[d.fieldKey] = d.fieldValue || ''; + } + + return Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([, row]) => row); +} + +export function FqcRequestDocumentContent({ + template, + documentData, + documentNo, + createdDate, +}: FqcRequestDocumentContentProps) { + const { approvalLines, basicFields, sections, columns } = template; + + // 섹션 분리: 입력사항 섹션 (items 있는 것) vs 사전 고지 섹션 (items 없는 것) + const inputSections = sections.filter(s => s.items.length > 0); + const noticeSections = sections.filter(s => s.items.length === 0); + const noticeSection = noticeSections[0]; // 검사대상 사전 고지 정보 + + // 기본필드를 2열로 배치하기 위한 페어링 + const sortedFields = [...basicFields].sort((a, b) => a.sortOrder - b.sortOrder); + const fieldPairs: Array<[typeof sortedFields[0], typeof sortedFields[0] | undefined]> = []; + for (let i = 0; i < sortedFields.length; i += 2) { + fieldPairs.push([sortedFields[i], sortedFields[i + 1]]); + } + + // 테이블 행 데이터 + const tableRows = getTableRows(documentData, columns); + const sortedColumns = [...columns].sort((a, b) => a.sortOrder - b.sortOrder); + + // group_name으로 컬럼 그룹 분석 (병합 헤더용) + const groupInfo = buildGroupInfo(sortedColumns); + + return ( + + {/* 헤더: 제목 + 결재란 */} +
+
+

+ {template.title || template.name} +

+
+
+ {documentNo && 문서번호: {documentNo}} + {createdDate && 작성일자: {createdDate}} +
+
+
+ + +
+ + {/* 기본 정보 */} + + + {fieldPairs.map(([left, right], idx) => ( + + {left.label} + + {getFieldValue(documentData, left.fieldKey) || '-'} + + {right && ( + <> + {right.label} + + {getFieldValue(documentData, right.fieldKey) || '-'} + + + )} + + ))} + + + + {/* 입력사항: 동적 섹션 */} + {inputSections.length > 0 && ( +
+
+ 입력사항 +
+ {inputSections.map((section, sIdx) => ( +
+
+ {section.title || section.name} +
+ + + {buildSectionRows(section, documentData).map((row, rIdx) => ( + + {row.map((cell, cIdx) => ( + + ))} + + ))} + +
+ {cell.value || '-'} +
+
+ ))} +
+ )} + + {/* 검사 요청 시 필독 (사전 고지 섹션의 description) */} + {noticeSection?.description && ( + + + + +

{noticeSection.description}

+ + + +
+ )} + + {/* 검사대상 사전 고지 정보 테이블 */} + {sortedColumns.length > 0 && ( + + + {/* 그룹 헤더가 있으면 3단 헤더 */} + {groupInfo.hasGroups ? ( + <> + + {groupInfo.topRow.map((cell, i) => ( + + {cell.label} + + ))} + + + {groupInfo.midRow.map((cell, i) => ( + + {cell.label} + + ))} + + + {groupInfo.botRow.map((cell, i) => ( + + {cell.label} + + ))} + + + ) : ( + + {sortedColumns.map((col, i) => ( + + {col.label} + + ))} + + )} + + + {tableRows.length > 0 ? ( + tableRows.map((row, rIdx) => ( + + {sortedColumns.map((col, cIdx) => ( + + {col.label === 'No.' ? rIdx + 1 : (row[col.label] || '-')} + + ))} + + )) + ) : ( + + + 검사대상 사전 고지 정보가 없습니다. + + + )} + + + )} + + {/* 서명 영역 */} +
+

위 내용과 같이 제품검사를 요청합니다.

+
+

{createdDate || ''}

+
+
+
+ ); +} + +// ===== 유틸 함수 ===== + +interface CellInfo { + isLabel: boolean; + value: string; + width?: string; +} + +/** 섹션 아이템을 2열 레이아웃의 행으로 변환 */ +function buildSectionRows( + section: FqcTemplate['sections'][0], + data?: FqcDocumentData[], +): CellInfo[][] { + const items = [...section.items].sort((a, b) => a.sortOrder - b.sortOrder); + const rows: CellInfo[][] = []; + + // 3개 아이템이면 한 행에 3개, 그 외 2개씩 + if (items.length === 3) { + rows.push( + items.map((item, i) => [ + { isLabel: true, value: item.itemName, width: i === 2 ? 'w-20' : undefined }, + { isLabel: false, value: getSectionItemValue(data, section.id, item.itemName) }, + ]).flat() + ); + } else { + for (let i = 0; i < items.length; i += 2) { + const left = items[i]; + const right = items[i + 1]; + const row: CellInfo[] = [ + { isLabel: true, value: left.itemName }, + { isLabel: false, value: getSectionItemValue(data, section.id, left.itemName) }, + ]; + if (right) { + row.push( + { isLabel: true, value: right.itemName }, + { isLabel: false, value: getSectionItemValue(data, section.id, right.itemName) }, + ); + } + rows.push(row); + } + } + return rows; +} + +interface HeaderCell { + label: string; + colSpan?: number; + rowSpan?: number; + width?: string; +} + +interface GroupInfo { + hasGroups: boolean; + topRow: HeaderCell[]; + midRow: HeaderCell[]; + botRow: HeaderCell[]; +} + +/** 컬럼 group_name을 분석하여 3단 헤더 구조 생성 */ +function buildGroupInfo(columns: FqcTemplate['columns']): GroupInfo { + const groups = columns.filter(c => c.groupName); + if (groups.length === 0) return { hasGroups: false, topRow: [], midRow: [], botRow: [] }; + + // group_name별로 그룹핑 + const groupMap = new Map(); + for (const col of columns) { + if (col.groupName) { + if (!groupMap.has(col.groupName)) groupMap.set(col.groupName, []); + groupMap.get(col.groupName)!.push(col); + } + } + + // 오픈사이즈(발주규격), 오픈사이즈(시공후규격) 패턴 감지 + // group_name 패턴: "오픈사이즈(발주규격)", "오픈사이즈(시공후규격)" + const parentGroups = new Map(); + for (const gName of groupMap.keys()) { + const match = gName.match(/^(.+?)\((.+?)\)$/); + if (match) { + const parent = match[1]; + if (!parentGroups.has(parent)) parentGroups.set(parent, []); + parentGroups.get(parent)!.push(gName); + } + } + + const topRow: HeaderCell[] = []; + const midRow: HeaderCell[] = []; + const botRow: HeaderCell[] = []; + + let colIdx = 0; + while (colIdx < columns.length) { + const col = columns[colIdx]; + + if (!col.groupName) { + // 그룹이 없는 독립 컬럼 → rowSpan=3 + topRow.push({ label: col.label, rowSpan: 3, width: col.width || undefined }); + colIdx++; + } else { + // 그룹 컬럼 → 상위 그룹 확인 + const match = col.groupName.match(/^(.+?)\((.+?)\)$/); + if (match) { + const parentName = match[1]; + const subGroups = parentGroups.get(parentName) || []; + // 상위 그룹의 모든 하위 컬럼 수 + let totalCols = 0; + for (const sg of subGroups) { + totalCols += groupMap.get(sg)!.length; + } + topRow.push({ label: parentName, colSpan: totalCols }); + + // 중간행: 각 하위 그룹 + for (const sg of subGroups) { + const subMatch = sg.match(/\((.+?)\)$/); + const subLabel = subMatch ? subMatch[1] : sg; + const subCols = groupMap.get(sg)!; + midRow.push({ label: subLabel, colSpan: subCols.length }); + + // 하단행: 실제 컬럼 라벨 + for (const sc of subCols) { + // 라벨에서 그룹 프리픽스 제거 (발주 가로 → 가로) + const cleanLabel = sc.label.replace(/^(발주|시공)\s*/, ''); + botRow.push({ label: cleanLabel, width: sc.width || undefined }); + } + } + + // 이 그룹에 속한 컬럼 수만큼 건너뛰기 + colIdx += totalCols; + } else { + // 단순 그룹 (parentGroup 없이) + const gCols = groupMap.get(col.groupName)!; + topRow.push({ label: col.groupName, colSpan: gCols.length, rowSpan: 2 }); + for (const gc of gCols) { + botRow.push({ label: gc.label, width: gc.width || undefined }); + } + colIdx += gCols.length; + } + } + } + + return { hasGroups: true, topRow, midRow, botRow }; +} diff --git a/src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx b/src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx index 99f1f9be..a87adac6 100644 --- a/src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx +++ b/src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx @@ -2,25 +2,115 @@ /** * 제품검사요청서 모달 - * DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공 + * + * 양식 기반 전환: + * - getFqcRequestTemplate()로 template 66 조회 + * - requestDocumentId가 있으면 EAV 문서 로드 → FqcRequestDocumentContent로 렌더링 + * - Lazy Snapshot: rendered_html이 없으면 렌더링 완료 후 자동 캡처/저장 + * - Fallback: template 로드 실패 시 기존 InspectionRequestDocument 사용 */ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Loader2, AlertCircle } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; +import { FqcRequestDocumentContent } from './FqcRequestDocumentContent'; import { InspectionRequestDocument } from './InspectionRequestDocument'; +import { getFqcRequestTemplate, getFqcDocument, patchDocumentSnapshot } from '../fqcActions'; import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types'; +import type { FqcTemplate, FqcDocument } from '../fqcActions'; interface InspectionRequestModalProps { open: boolean; onOpenChange: (open: boolean) => void; data: InspectionRequestDocumentType | null; + /** EAV 요청서 문서 ID (API에서 자동생성된 Document ID) */ + requestDocumentId?: number | null; } export function InspectionRequestModal({ open, onOpenChange, data, + requestDocumentId, }: InspectionRequestModalProps) { - if (!data) return null; + const contentWrapperRef = useRef(null); + const snapshotSentRef = useRef(false); + + // FQC 양식/문서 상태 + const [fqcTemplate, setFqcTemplate] = useState(null); + const [fqcDocument, setFqcDocument] = useState(null); + const [templateLoadFailed, setTemplateLoadFailed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const useFqcMode = !templateLoadFailed && !!fqcTemplate; + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setTemplateLoadFailed(false); + setFqcDocument(null); + snapshotSentRef.current = false; + } + }, [open]); + + // 양식 로드 + useEffect(() => { + if (!open || fqcTemplate || templateLoadFailed) return; + setIsLoading(true); + getFqcRequestTemplate() + .then(result => { + if (result.success && result.data) { + setFqcTemplate(result.data); + } else { + setTemplateLoadFailed(true); + } + }) + .finally(() => setIsLoading(false)); + }, [open, fqcTemplate, templateLoadFailed]); + + // EAV 문서 로드 (requestDocumentId가 있는 경우) + useEffect(() => { + if (!open || !requestDocumentId || !fqcTemplate) return; + setIsLoading(true); + getFqcDocument(requestDocumentId) + .then(result => { + if (result.success && result.data) { + setFqcDocument(result.data); + } + }) + .finally(() => setIsLoading(false)); + }, [open, requestDocumentId, fqcTemplate]); + + // Lazy Snapshot: FQC 모드 렌더링 완료 후 rendered_html 캡처 + const captureSnapshot = useCallback(() => { + if (snapshotSentRef.current || !requestDocumentId || !contentWrapperRef.current) return; + + // 렌더링 완료 대기 (다음 프레임) + requestAnimationFrame(() => { + const html = contentWrapperRef.current?.innerHTML; + if (html && html.length > 50) { + snapshotSentRef.current = true; + patchDocumentSnapshot(requestDocumentId, html).catch(() => { + // 실패해도 UI에 영향 없음 + }); + } + }); + }, [requestDocumentId]); + + // FQC 문서 렌더링 완료 시 스냅샷 캡처 + useEffect(() => { + if (!useFqcMode || isLoading || !fqcDocument) return; + captureSnapshot(); + }, [useFqcMode, isLoading, fqcDocument, captureSnapshot]); + + if (!data && !useFqcMode) return null; + + const documentNo = fqcDocument?.documentNo ?? data?.documentNumber; + const createdDate = data?.createdDate; + + const pdfMeta = documentNo + ? { documentNumber: documentNo, createdDate: createdDate ?? '' } + : undefined; return ( - + {isLoading ? ( +
+ + 양식 로딩 중... +
+ ) : useFqcMode ? ( +
+ +
+ ) : data ? ( + + ) : ( +
+ +

요청서 데이터가 없습니다.

+
+ )}
); } diff --git a/src/components/quality/InspectionManagement/documents/index.ts b/src/components/quality/InspectionManagement/documents/index.ts index cf79d5a3..21e7b892 100644 --- a/src/components/quality/InspectionManagement/documents/index.ts +++ b/src/components/quality/InspectionManagement/documents/index.ts @@ -3,3 +3,4 @@ export { InspectionRequestModal } from './InspectionRequestModal'; export { InspectionReportDocument } from './InspectionReportDocument'; export { InspectionReportModal } from './InspectionReportModal'; export { FqcDocumentContent } from './FqcDocumentContent'; +export { FqcRequestDocumentContent } from './FqcRequestDocumentContent'; diff --git a/src/components/quality/InspectionManagement/fqcActions.ts b/src/components/quality/InspectionManagement/fqcActions.ts index a67ede59..2662817e 100644 --- a/src/components/quality/InspectionManagement/fqcActions.ts +++ b/src/components/quality/InspectionManagement/fqcActions.ts @@ -26,6 +26,8 @@ interface TemplateItemApi { measurement_type: string; frequency: string; sort_order: number; + category: string | null; + method: string | null; } /** 양식 섹션 */ @@ -45,6 +47,7 @@ interface TemplateColumnApi { label: string; column_type: string; width: string | null; + group_name: string | null; sort_order: number; } @@ -158,12 +161,15 @@ export interface FqcTemplateItem { measurementType: string; frequency: string; sortOrder: number; + category: string; + method: string; } export interface FqcTemplateSection { id: number; name: string; title: string | null; + description: string | null; imagePath: string | null; sortOrder: number; items: FqcTemplateItem[]; @@ -174,6 +180,7 @@ export interface FqcTemplateColumn { label: string; columnType: string; width: string | null; + groupName: string | null; sortOrder: number; } @@ -276,6 +283,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate { id: s.id, name: s.name, title: s.title, + description: s.description ?? null, imagePath: s.image_path, sortOrder: s.sort_order, items: (s.items || []).map(item => ({ @@ -287,6 +295,8 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate { measurementType: item.measurement_type, frequency: item.frequency, sortOrder: item.sort_order, + category: item.category || '', + method: item.method || '', })), })), columns: (api.columns || []).map(c => ({ @@ -294,6 +304,7 @@ function transformTemplate(api: DocumentTemplateApi): FqcTemplate { label: c.label, columnType: c.column_type, width: c.width, + groupName: c.group_name ?? null, sortOrder: c.sort_order, })), }; @@ -346,6 +357,7 @@ function transformFqcStatus(api: FqcStatusResponse): FqcStatus { // ===== Server Actions ===== const FQC_TEMPLATE_ID = 65; +const FQC_REQUEST_TEMPLATE_ID = 66; /** * FQC 양식 상세 조회 @@ -364,6 +376,23 @@ export async function getFqcTemplate(): Promise<{ }); } +/** + * 제품검사 요청서 양식 상세 조회 + * GET /v1/document-templates/{id} + */ +export async function getFqcRequestTemplate(): Promise<{ + success: boolean; + data?: FqcTemplate; + error?: string; + __authError?: boolean; +}> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/document-templates/${FQC_REQUEST_TEMPLATE_ID}`), + transform: transformTemplate, + errorMessage: '제품검사 요청서 양식 조회에 실패했습니다.', + }); +} + /** * FQC 문서 일괄생성 * POST /v1/documents/bulk-create-fqc @@ -431,6 +460,7 @@ export async function saveFqcDocument(params: { templateId?: number; itemId?: number; title?: string; + renderedHtml?: string; data: Array<{ section_id?: number | null; column_id?: number | null; @@ -451,6 +481,7 @@ export async function saveFqcDocument(params: { if (params.documentId) body.document_id = params.documentId; if (params.itemId) body.item_id = params.itemId; if (params.title) body.title = params.title; + if (params.renderedHtml) body.rendered_html = params.renderedHtml; return executeServerAction({ url: buildApiUrl('/api/v1/documents/upsert'), @@ -458,4 +489,20 @@ export async function saveFqcDocument(params: { body, errorMessage: 'FQC 검사 데이터 저장에 실패했습니다.', }); +} + +/** + * 문서 rendered_html 스냅샷 저장 (Lazy Snapshot) + * PATCH /v1/documents/{id}/snapshot + */ +export async function patchDocumentSnapshot( + documentId: number, + renderedHtml: string, +): Promise<{ success: boolean; error?: string }> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/documents/${documentId}/snapshot`), + method: 'PATCH', + body: { rendered_html: renderedHtml }, + errorMessage: '스냅샷 저장에 실패했습니다.', + }); } \ No newline at end of file diff --git a/src/components/quality/InspectionManagement/types.ts b/src/components/quality/InspectionManagement/types.ts index 51831a00..47fd1748 100644 --- a/src/components/quality/InspectionManagement/types.ts +++ b/src/components/quality/InspectionManagement/types.ts @@ -154,6 +154,9 @@ export interface ProductInspection { // 수주 설정 orderItems: OrderSettingItem[]; + + // EAV 요청서 문서 ID (자동생성) + requestDocumentId?: number | null; } // ===== 통계 =====