Files
sam-react-prod/src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx

462 lines
16 KiB
TypeScript
Raw Normal View History

'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 };
}