- eslint.config.mjs 규칙 강화 및 정리 - 전역 unused import/변수 제거 (312개 파일) - next.config.ts, middleware, proxy route 개선 - CopyableCell molecule 추가 - 회계/결재/HR/생산/건설/품질/영업 등 전 도메인 lint 정리 - IntegratedListTemplateV2, DataTable, MobileCard 등 공통 컴포넌트 개선 - execute-server-action 에러 핸들링 보강
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
'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 };
|
|
}
|