feat: 제품검사 요청서 양식 기반 렌더링 + Lazy Snapshot
- FqcRequestDocumentContent: template 66 기반 동적 렌더링 컴포넌트 - 결재라인, 기본정보, 입력사항(4섹션), 사전고지 테이블 - group_name 기반 3단 헤더 (오픈사이즈 발주/시공 병합) - InspectionRequestModal: FQC 모드 전환 + EAV 문서 로드 + Lazy Snapshot - fqcActions: getFqcRequestTemplate, patchDocumentSnapshot, description/groupName 타입 - types/actions: requestDocumentId 필드 추가 및 매핑 - InspectionDetail: requestDocumentId prop 전달
This commit is contained in:
@@ -1247,6 +1247,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
open={requestDocOpen}
|
||||
onOpenChange={setRequestDocOpen}
|
||||
data={inspection ? buildRequestDocumentData(inspection) : null}
|
||||
requestDocumentId={inspection?.requestDocumentId}
|
||||
/>
|
||||
|
||||
{/* 제품검사성적서 모달 */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
if (!data) return [];
|
||||
// column_id가 있는 데이터만 필터 → row_index로 그룹핑
|
||||
const columnData = data.filter(d => d.columnId !== null);
|
||||
if (columnData.length === 0) return [];
|
||||
|
||||
const rowMap = new Map<number, Record<string, string>>();
|
||||
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 (
|
||||
<DocumentWrapper fontSize="text-[11px]">
|
||||
{/* 헤더: 제목 + 결재란 */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">
|
||||
{template.title || template.name}
|
||||
</h1>
|
||||
<div className="text-[10px] space-y-1">
|
||||
<div className="flex gap-4">
|
||||
{documentNo && <span>문서번호: <strong>{documentNo}</strong></span>}
|
||||
{createdDate && <span>작성일자: <strong>{createdDate}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructionApprovalTable
|
||||
approvers={{
|
||||
writer: approvalLines[0]
|
||||
? { name: '', department: approvalLines[0].department }
|
||||
: undefined,
|
||||
approver1: approvalLines[1]
|
||||
? { name: '', department: approvalLines[1].department }
|
||||
: undefined,
|
||||
approver2: approvalLines[2]
|
||||
? { name: '', department: approvalLines[2].department }
|
||||
: undefined,
|
||||
approver3: approvalLines[3]
|
||||
? { name: '', department: approvalLines[3].department }
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<DocumentTable header="기본 정보" headerVariant="light" spacing="mb-4">
|
||||
<tbody>
|
||||
{fieldPairs.map(([left, right], idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={idx < fieldPairs.length - 1 ? 'border-b border-gray-300' : ''}
|
||||
>
|
||||
<td className={lbl}>{left.label}</td>
|
||||
<td className={right ? `${val} border-r border-gray-300` : val} colSpan={right ? 1 : 3}>
|
||||
{getFieldValue(documentData, left.fieldKey) || '-'}
|
||||
</td>
|
||||
{right && (
|
||||
<>
|
||||
<td className={lbl}>{right.label}</td>
|
||||
<td className={val}>
|
||||
{getFieldValue(documentData, right.fieldKey) || '-'}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
|
||||
{/* 입력사항: 동적 섹션 */}
|
||||
{inputSections.length > 0 && (
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">
|
||||
입력사항
|
||||
</div>
|
||||
{inputSections.map((section, sIdx) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className={sIdx < inputSections.length - 1 ? 'border-b border-gray-300' : ''}
|
||||
>
|
||||
<div className="bg-gray-100 px-2 py-1 font-medium border-b border-gray-300">
|
||||
{section.title || section.name}
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{buildSectionRows(section, documentData).map((row, rIdx) => (
|
||||
<tr
|
||||
key={rIdx}
|
||||
className={rIdx < buildSectionRows(section, documentData).length - 1 ? 'border-b border-gray-300' : ''}
|
||||
>
|
||||
{row.map((cell, cIdx) => (
|
||||
<td
|
||||
key={cIdx}
|
||||
className={
|
||||
cell.isLabel
|
||||
? `${subLbl}${cell.width ? ` ${cell.width}` : ''}`
|
||||
: cIdx < row.length - 1
|
||||
? `${val} border-r border-gray-300`
|
||||
: val
|
||||
}
|
||||
>
|
||||
{cell.value || '-'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검사 요청 시 필독 (사전 고지 섹션의 description) */}
|
||||
{noticeSection?.description && (
|
||||
<DocumentTable header="검사 요청 시 필독" headerVariant="dark" spacing="mb-4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-[11px] leading-relaxed text-center">
|
||||
<p>{noticeSection.description}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
)}
|
||||
|
||||
{/* 검사대상 사전 고지 정보 테이블 */}
|
||||
{sortedColumns.length > 0 && (
|
||||
<DocumentTable
|
||||
header={noticeSection?.title || '검사대상 사전 고지 정보'}
|
||||
headerVariant="dark"
|
||||
spacing="mb-4"
|
||||
>
|
||||
<thead>
|
||||
{/* 그룹 헤더가 있으면 3단 헤더 */}
|
||||
{groupInfo.hasGroups ? (
|
||||
<>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
{groupInfo.topRow.map((cell, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`${DOC_STYLES.th}${i === groupInfo.topRow.length - 1 ? ' border-r-0' : ''}`}
|
||||
colSpan={cell.colSpan}
|
||||
rowSpan={cell.rowSpan}
|
||||
style={cell.width ? { width: cell.width } : undefined}
|
||||
>
|
||||
{cell.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
{groupInfo.midRow.map((cell, i) => (
|
||||
<th key={i} className={DOC_STYLES.th} colSpan={cell.colSpan}>
|
||||
{cell.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
{groupInfo.botRow.map((cell, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={DOC_STYLES.th}
|
||||
style={cell.width ? { width: cell.width } : undefined}
|
||||
>
|
||||
{cell.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</>
|
||||
) : (
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
{sortedColumns.map((col, i) => (
|
||||
<th
|
||||
key={col.id}
|
||||
className={`${DOC_STYLES.th}${i === sortedColumns.length - 1 ? ' border-r-0' : ''}`}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.length > 0 ? (
|
||||
tableRows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="border-b border-gray-300">
|
||||
{sortedColumns.map((col, cIdx) => (
|
||||
<td
|
||||
key={col.id}
|
||||
className={
|
||||
cIdx === sortedColumns.length - 1
|
||||
? DOC_STYLES.td
|
||||
: DOC_STYLES.tdCenter
|
||||
}
|
||||
>
|
||||
{col.label === 'No.' ? rIdx + 1 : (row[col.label] || '-')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={sortedColumns.length} className="px-2 py-4 text-center text-gray-400">
|
||||
검사대상 사전 고지 정보가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</DocumentTable>
|
||||
)}
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="mt-8 text-center text-[10px]">
|
||||
<p>위 내용과 같이 제품검사를 요청합니다.</p>
|
||||
<div className="mt-6">
|
||||
<p>{createdDate || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 유틸 함수 =====
|
||||
|
||||
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<string, typeof columns>();
|
||||
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<string, string[]>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const snapshotSentRef = useRef(false);
|
||||
|
||||
// FQC 양식/문서 상태
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(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 (
|
||||
<DocumentViewer
|
||||
@@ -28,12 +118,31 @@ export function InspectionRequestModal({
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
pdfMeta={{
|
||||
documentNumber: data.documentNumber,
|
||||
createdDate: data.createdDate,
|
||||
}}
|
||||
pdfMeta={pdfMeta}
|
||||
>
|
||||
<InspectionRequestDocument data={data} />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">양식 로딩 중...</span>
|
||||
</div>
|
||||
) : useFqcMode ? (
|
||||
<div ref={contentWrapperRef}>
|
||||
<FqcRequestDocumentContent
|
||||
template={fqcDocument?.template ?? fqcTemplate}
|
||||
documentData={fqcDocument?.data}
|
||||
documentNo={documentNo}
|
||||
createdDate={createdDate}
|
||||
readonly={true}
|
||||
/>
|
||||
</div>
|
||||
) : data ? (
|
||||
<InspectionRequestDocument data={data} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p>요청서 데이터가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { InspectionRequestModal } from './InspectionRequestModal';
|
||||
export { InspectionReportDocument } from './InspectionReportDocument';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FqcDocumentContent } from './FqcDocumentContent';
|
||||
export { FqcRequestDocumentContent } from './FqcRequestDocumentContent';
|
||||
|
||||
@@ -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<DocumentTemplateApi, FqcTemplate>({
|
||||
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: '스냅샷 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -154,6 +154,9 @@ export interface ProductInspection {
|
||||
|
||||
// 수주 설정
|
||||
orderItems: OrderSettingItem[];
|
||||
|
||||
// EAV 요청서 문서 ID (자동생성)
|
||||
requestDocumentId?: number | null;
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
Reference in New Issue
Block a user