feat(WEB): 공정 단계 설정(검사여부/연결정보/완료정보) → WorkerScreen 연동

- WorkStepData 타입에 stepProgressId, needsInspection, connectionType, connectionTarget, completionType 추가
- getWorkOrderDetail step 변환에서 needs_inspection, connection_type, completion_type 추출
- PROCESS_STEPS 폴백 시 processListCache 단계 설정 매칭하여 enrichStep 헬퍼로 주입
- handleStepClick에 connectionType='팝업' + connectionTarget='중간검사' 분기 추가
- handleInspectionComplete에서 completionType='검사완료 시 완료' 단계 toggleStepProgress API 호출
- TemplateInspectionContent: reference_attribute → workItem 치수 연동
- InspectionInputModal: workItemDimensions prop으로 실제 치수 기반 설계값 표시
This commit is contained in:
2026-02-11 14:30:46 +09:00
parent 8a993bc0c3
commit 5104a8b012
13 changed files with 831 additions and 409 deletions

View File

@@ -132,7 +132,7 @@ export function DevToolbar() {
// 숨김 상태일 때 작은 버튼만 표시
if (!isVisible) {
return (
<div className="fixed bottom-9 right-4 z-[9999]">
<div className="fixed bottom-12 right-4 z-[9999]">
<Button
size="sm"
variant="outline"
@@ -185,10 +185,10 @@ export function DevToolbar() {
const hasFlowData = flowData.quoteId || flowData.orderId || flowData.workOrderId || flowData.lotNo;
return (
<div className="fixed bottom-9 left-1/2 -translate-x-1/2 z-[9999] max-w-[calc(100vw-1rem)]">
<div className="fixed bottom-12 left-1/2 -translate-x-1/2 z-[9999] w-[calc(100vw-1rem)] sm:w-auto sm:max-w-[calc(100vw-1rem)]">
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-2xl overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-3 py-2 bg-yellow-100 border-b border-yellow-300">
<div className="flex items-center justify-between px-2 sm:px-3 py-1.5 sm:py-2 bg-yellow-100 border-b border-yellow-300">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
DEV MODE
@@ -257,7 +257,7 @@ export function DevToolbar() {
{/* 판매/생산 플로우 버튼 영역 */}
{isExpanded && (
<div className="flex flex-wrap items-center gap-2 px-3 py-3">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 sm:py-3">
{FLOW_STEPS.map((step, index) => {
const Icon = step.icon;
const isActive = activePage === step.type;
@@ -331,7 +331,7 @@ export function DevToolbar() {
{/* 2행: 회계 + 기준정보 + 자재 버튼 영역 */}
{isExpanded && (
<div className="flex flex-wrap items-center gap-2 px-3 pb-3 border-t border-yellow-300 pt-3">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 px-2 sm:px-3 pb-2 sm:pb-3 border-t border-yellow-300 pt-2 sm:pt-3">
{/* 회계 */}
<span className="text-xs text-yellow-600 font-medium mr-1">:</span>
{ACCOUNTING_STEPS.map((step) => {

View File

@@ -202,6 +202,33 @@ export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps)
</Badge>
</div>
</div>
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
<div className="space-y-1">
<div className="text-sm text-muted-foreground"> </div>
<Badge variant={process.needsInspection ? 'default' : 'secondary'}>
{process.needsInspection ? '사용' : '미사용'}
</Badge>
</div>
{process.needsInspection && (
<div className="space-y-1">
<div className="text-sm text-muted-foreground"> </div>
<div className="font-medium">{process.documentTemplateName || '-'}</div>
</div>
)}
<div className="space-y-1">
<div className="text-sm text-muted-foreground"> </div>
<Badge variant={process.needsWorkLog ? 'default' : 'secondary'}>
{process.needsWorkLog ? '사용' : '미사용'}
</Badge>
</div>
{process.needsWorkLog && (
<div className="space-y-1">
<div className="text-sm text-muted-foreground"> </div>
<div className="font-medium">{process.workLogTemplateName || '-'}</div>
</div>
)}
</div>
</CardContent>
</Card>

View File

@@ -70,6 +70,9 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
const [isLoading, setIsLoading] = useState(false);
// 중간검사/작업일지 설정 (Process 레벨)
const [needsInspection, setNeedsInspection] = useState(
initialData?.needsInspection ?? false
);
const [documentTemplateId, setDocumentTemplateId] = useState<number | undefined>(
initialData?.documentTemplateId
);
@@ -320,7 +323,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
processType,
processCategory: processCategory || undefined,
department,
documentTemplateId: documentTemplateId || undefined,
documentTemplateId: needsInspection ? (documentTemplateId || undefined) : undefined,
needsInspection,
needsWorkLog,
workLogTemplateId: needsWorkLog ? workLogTemplateId : undefined,
classificationRules: classificationRules.map((rule) => ({
@@ -486,27 +490,44 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
</Select>
</div>
</div>
{/* Row 3: 중간검사양식 | 작업일지여부 | 작업일지양식 */}
{/* Row 3: 중간검사여부 | 중간검사양식 | 작업일지여부 | 작업일지양식 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
<div className="space-y-2">
<Label> </Label>
<Label> </Label>
<Select
key={`doc-template-${documentTemplateId ?? 'none'}`}
value={documentTemplateId ? String(documentTemplateId) : ''}
onValueChange={(v) => setDocumentTemplateId(v ? Number(v) : undefined)}
value={needsInspection ? '사용' : '미사용'}
onValueChange={(v) => setNeedsInspection(v === '사용')}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{documentTemplates.map((tmpl) => (
<SelectItem key={tmpl.id} value={String(tmpl.id)}>
{tmpl.name} ({tmpl.category})
</SelectItem>
))}
<SelectItem value="사용"></SelectItem>
<SelectItem value="미사용"></SelectItem>
</SelectContent>
</Select>
</div>
{needsInspection && (
<div className="space-y-2">
<Label> </Label>
<Select
key={`doc-template-${documentTemplateId ?? 'none'}`}
value={documentTemplateId ? String(documentTemplateId) : ''}
onValueChange={(v) => setDocumentTemplateId(v ? Number(v) : undefined)}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{documentTemplates.map((tmpl) => (
<SelectItem key={tmpl.id} value={String(tmpl.id)}>
{tmpl.name} ({tmpl.category})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<Select
@@ -760,6 +781,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
manager,
useProductionDate,
isActive,
needsInspection,
documentTemplateId,
needsWorkLog,
workLogTemplateId,

View File

@@ -320,6 +320,8 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
{ key: 'processName', label: '공정명', className: 'min-w-[200px]' },
{ key: 'department', label: '담당부서', className: 'w-[120px]' },
{ key: 'items', label: '품목', className: 'w-[80px] text-center' },
{ key: 'inspection', label: '중간검사', className: 'w-[90px] text-center' },
{ key: 'workLog', label: '작업일지', className: 'w-[90px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
],
@@ -338,6 +340,8 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</>
),
@@ -450,6 +454,16 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
<TableCell>{process.processName}</TableCell>
<TableCell>{process.department}</TableCell>
<TableCell className="text-center">{itemCount > 0 ? itemCount : '-'}</TableCell>
<TableCell className="text-center">
<Badge variant={process.needsInspection ? 'default' : 'secondary'}>
{process.needsInspection ? '사용' : '미사용'}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={process.needsWorkLog ? 'default' : 'secondary'}>
{process.needsWorkLog ? '사용' : '미사용'}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Badge
variant={process.status === '사용중' ? 'default' : 'secondary'}
@@ -503,6 +517,8 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="담당부서" value={process.department} />
<InfoField label="품목" value={itemCount > 0 ? `${itemCount}` : '-'} />
<InfoField label="중간검사" value={process.needsInspection ? '사용' : '미사용'} />
<InfoField label="작업일지" value={process.needsWorkLog ? '사용' : '미사용'} />
</div>
}
/>

View File

@@ -145,7 +145,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
const result = await updateProcessStep(processId, initialData.id, stepData);
if (result.success) {
toast.success('단계가 수정되었습니다.');
router.push(`/ko/master-data/process-management/${processId}`);
router.push(`/ko/master-data/process-management/${processId}/steps/${initialData.id}`);
return { success: true };
} else {
toast.error(result.error || '수정에 실패했습니다.');

View File

@@ -23,9 +23,12 @@ interface ApiProcess {
work_log_template: string | null;
document_template_id: number | null;
document_template?: { id: number; name: string; category: string } | null;
needs_work_log: boolean;
work_log_template_id: number | null;
work_log_template_relation?: { id: number; name: string; category: string } | null;
options?: {
needs_inspection?: boolean;
needs_work_log?: boolean;
} | null;
required_workers: number;
equipment_info: string | null;
work_steps: string[] | null;
@@ -95,9 +98,10 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
workLogTemplate: apiData.work_log_template ?? undefined,
documentTemplateId: apiData.document_template_id ?? undefined,
documentTemplateName: apiData.document_template?.name ?? undefined,
needsWorkLog: apiData.needs_work_log ?? false,
workLogTemplateId: apiData.work_log_template_id ?? undefined,
workLogTemplateName: apiData.work_log_template_relation?.name ?? undefined,
needsInspection: apiData.options?.needs_inspection ?? false,
needsWorkLog: apiData.options?.needs_work_log ?? false,
classificationRules: [...patternRules, ...individualRules],
requiredWorkers: apiData.required_workers,
equipmentInfo: apiData.equipment_info ?? undefined,
@@ -190,8 +194,11 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
use_production_date: data.useProductionDate ?? false,
work_log_template: data.workLogTemplate || null,
document_template_id: data.documentTemplateId || null,
needs_work_log: data.needsWorkLog ?? false,
work_log_template_id: data.workLogTemplateId || null,
options: {
needs_inspection: data.needsInspection ?? false,
needs_work_log: data.needsWorkLog ?? false,
},
required_workers: data.requiredWorkers,
equipment_info: data.equipmentInfo || null,
work_steps: data.workSteps ? data.workSteps.split(',').map((s) => s.trim()).filter(Boolean) : [],

View File

@@ -31,6 +31,11 @@ export interface WorkOrder {
instruction?: string; // 지시사항
salesOrderNo?: string; // 수주번호
createdAt: string;
// 공정 설정 (작업자 화면용)
processOptions?: {
needsInspection?: boolean;
needsWorkLog?: boolean;
};
// 개소별 아이템 그룹 (작업자 화면용)
nodeGroups?: WorkOrderNodeGroup[];
}

View File

@@ -3,28 +3,31 @@
/**
* 템플릿 기반 중간검사 성적서 콘텐츠
*
* DocumentTemplate의 sections/items에서 measurement_type별 입력 셀을 자동 생성:
* - checkbox → 양호/불량 토글
* - numeric → 기준값 표시 + 측정값 입력
* - single_value → 단일값 입력
* - substitute → 성적서 대체 배지
* - text → 자유 텍스트 입력
* mng 미리보기(buildDocumentPreviewHtml) 레이아웃 기준:
* - 헤더: KD + 회사명(좌) | 제목(중앙) | 결재라인(우)
* - 기본필드: 2열 배치 (15:35:15:35)
* - 이미지 섹션: items 없는 섹션 → 이미지 표시
* - DATA 테이블: template.columns 기반 헤더, work items 행
* - 푸터: 비고(좌) + 종합판정(우) 병렬 배치
*
* 컬럼 column_type별 셀 렌더링:
* - text (일련번호/NO): 행 번호
* - check: 양호/불량 토글
* - complex (sub_labels): 기준값 표시 + 측정값 입력 / OK·NG 토글
* - select (판정): 자동 계산 적/부
*/
import { useState, forwardRef, useImperativeHandle, useEffect, Fragment } from 'react';
import { useState, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react';
import type { WorkOrder } from '../types';
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
import type { InspectionDataMap } from './InspectionReportModal';
import type {
InspectionTemplateFormat,
InspectionTemplateSectionItem,
InspectionTolerance,
} from '@/components/production/WorkerScreen/types';
import {
type InspectionContentRef,
InspectionCheckbox,
InspectionLayout,
InspectionFooter,
JudgmentCell,
calculateOverallResult,
getFullDate,
@@ -53,15 +56,25 @@ interface TemplateInspectionContentProps {
// ===== 유틸 =====
function formatTolerance(tol: InspectionTolerance | null): string {
if (!tol) return '-';
if (tol.type === 'symmetric') return `± ${tol.value}`;
if (tol.type === 'asymmetric') return `+${tol.plus} / -${tol.minus}`;
if (tol.type === 'range') return `${tol.min} ~ ${tol.max}`;
return '-';
/** field_values.reference_attribute에서 작업 아이템의 실제 치수를 resolve */
function resolveReferenceValue(
item: InspectionTemplateSectionItem,
workItem?: WorkItemData
): number | null {
if (!item.field_values || !workItem) return null;
const refAttr = item.field_values.reference_attribute;
if (typeof refAttr !== 'string') return null;
const mapping: Record<string, number | undefined> = {
width: workItem.width,
height: workItem.height,
length: workItem.width,
};
return mapping[refAttr] ?? null;
}
function formatStandard(item: InspectionTemplateSectionItem): string {
function formatStandard(item: InspectionTemplateSectionItem, workItem?: WorkItemData): string {
const refVal = resolveReferenceValue(item, workItem);
if (refVal !== null) return refVal.toLocaleString();
const sc = item.standard_criteria;
if (!sc) return item.standard || '-';
if (typeof sc === 'object') {
@@ -73,7 +86,9 @@ function formatStandard(item: InspectionTemplateSectionItem): string {
return String(sc);
}
function getNominalValue(item: InspectionTemplateSectionItem): number | null {
function getNominalValue(item: InspectionTemplateSectionItem, workItem?: WorkItemData): number | null {
const refVal = resolveReferenceValue(item, workItem);
if (refVal !== null) return refVal;
const sc = item.standard_criteria;
if (!sc || typeof sc !== 'object') {
if (typeof sc === 'string') {
@@ -86,28 +101,11 @@ function getNominalValue(item: InspectionTemplateSectionItem): number | null {
return null;
}
function formatFrequency(item: InspectionTemplateSectionItem): string {
if (item.frequency_n && item.frequency_c) return `n=${item.frequency_n}, c=${item.frequency_c}`;
if (item.frequency) return item.frequency;
return '-';
}
function getMeasurementLabel(type: string | null): string {
switch (type) {
case 'checkbox': return 'OK/NG';
case 'numeric': return '수치(3회)';
case 'single_value': return '단일값';
case 'substitute': return '대체';
case 'text': return '자유입력';
default: return type || '-';
}
}
/** 측정값이 공차 범위 내인지 판정 */
function isWithinTolerance(measured: number, item: InspectionTemplateSectionItem): boolean {
const nominal = getNominalValue(item);
function isWithinTolerance(measured: number, item: InspectionTemplateSectionItem, workItem?: WorkItemData): boolean {
const nominal = getNominalValue(item, workItem);
const tol = item.tolerance;
if (nominal === null || !tol) return true; // 기준 없으면 pass
if (nominal === null || !tol) return true;
switch (tol.type) {
case 'symmetric':
@@ -121,20 +119,69 @@ function isWithinTolerance(measured: number, item: InspectionTemplateSectionItem
}
}
/** 컬럼 라벨에서 번호 기호와 공백을 제거하여 비교용 키 생성 */
function normalizeLabel(label: string): string {
return label.replace(/[①②③④⑤⑥⑦⑧⑨⑩\s]/g, '').trim();
}
function isSerialColumn(label: string): boolean {
const l = label.trim().toLowerCase();
return l === 'no' || l === 'no.' || l === '일련번호';
}
function isJudgmentColumn(label: string): boolean {
return label.includes('판정');
}
// ===== 컴포넌트 =====
export const TemplateInspectionContent = forwardRef<InspectionContentRef, TemplateInspectionContentProps>(
function TemplateInspectionContent({ data: order, template, readOnly = false, workItems, inspectionDataMap }, ref) {
const fullDate = getFullDate();
const { documentNo, primaryAssignee } = getOrderInfo(order);
const { primaryAssignee } = getOrderInfo(order);
// 모든 섹션의 아이템을 평탄화 (DATA 테이블 컬럼용)
const allItems = template.sections.flatMap(s => s.items);
// 섹션 분류: 이미지 섹션(items 없음) vs 데이터 섹션(items 있음)
const imageSections = useMemo(
() => template.sections.filter(s => s.items.length === 0),
[template.sections]
);
const dataSections = useMemo(
() => template.sections.filter(s => s.items.length > 0),
[template.sections]
);
// 셀 값 상태: key = `${rowIdx}-${itemId}`
// 모든 데이터 섹션의 아이템을 평탄화
const allItems = useMemo(
() => dataSections.flatMap(s => s.items),
[dataSections]
);
// 컬럼 → 섹션 아이템 매핑 (라벨 정규화 비교)
const columnItemMap = useMemo(() => {
const map = new Map<number, InspectionTemplateSectionItem>();
for (const col of template.columns) {
const colKey = normalizeLabel(col.label);
const matched = allItems.find(item => {
const itemKey = normalizeLabel(item.item || item.category || '');
return itemKey === colKey;
});
if (matched) map.set(col.id, matched);
}
return map;
}, [template.columns, allItems]);
// complex 컬럼 존재 여부 (2행 헤더 필요 판단)
const hasComplexColumn = useMemo(
() => template.columns.some(c => c.column_type === 'complex' && c.sub_labels && c.sub_labels.length > 0),
[template.columns]
);
// 셀 값 상태: key = `${rowIdx}-${colId}`
const [cellValues, setCellValues] = useState<Record<string, CellValue>>({});
const [inadequateContent, setInadequateContent] = useState('');
const effectiveWorkItems = workItems || [];
// inspectionDataMap에서 초기값 복원
useEffect(() => {
if (!inspectionDataMap || !workItems) return;
@@ -142,41 +189,20 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
workItems.forEach((wi, rowIdx) => {
const itemData = inspectionDataMap.get(wi.id);
if (!itemData?.templateValues) return;
allItems.forEach(sectionItem => {
const key = `${rowIdx}-${sectionItem.id}`;
for (const col of template.columns) {
const sectionItem = columnItemMap.get(col.id);
if (!sectionItem) continue;
const key = `${rowIdx}-${col.id}`;
const val = itemData.templateValues?.[`item_${sectionItem.id}`];
if (val && typeof val === 'object') {
initial[key] = val as CellValue;
}
});
}
});
if (Object.keys(initial).length > 0) setCellValues(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inspectionDataMap, workItems]);
// ref로 데이터 수집 노출
useImperativeHandle(ref, () => ({
getInspectionData: () => {
const items = effectiveWorkItems.map((wi, idx) => ({
id: wi.id,
apiItemId: wi.apiItemId,
judgment: getRowJudgment(idx),
values: allItems.reduce((acc, sItem) => {
const key = `${idx}-${sItem.id}`;
acc[`item_${sItem.id}`] = cellValues[key] || null;
return acc;
}, {} as Record<string, CellValue | null>),
}));
return {
template_id: template.id,
items,
inadequateContent,
overall_result: overallResult,
};
},
}));
const updateCell = (key: string, update: Partial<CellValue>) => {
setCellValues(prev => ({
...prev,
@@ -189,31 +215,35 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
let hasAnyValue = false;
let hasFail = false;
for (const item of allItems) {
const key = `${rowIdx}-${item.id}`;
for (const col of template.columns) {
const sectionItem = columnItemMap.get(col.id);
if (!sectionItem) continue;
const key = `${rowIdx}-${col.id}`;
const cell = cellValues[key];
if (!cell) continue;
if (item.measurement_type === 'checkbox') {
const mType = sectionItem.measurement_type;
if (mType === 'checkbox' || col.column_type === 'check') {
if (cell.status === 'bad') hasFail = true;
if (cell.status) hasAnyValue = true;
} else if (item.measurement_type === 'numeric') {
} else if (mType === 'numeric') {
const measurements = cell.measurements || ['', '', ''];
for (const m of measurements) {
if (m) {
hasAnyValue = true;
const val = parseFloat(m);
if (!isNaN(val) && !isWithinTolerance(val, item)) hasFail = true;
if (!isNaN(val) && !isWithinTolerance(val, sectionItem, effectiveWorkItems[rowIdx])) hasFail = true;
}
}
} else if (item.measurement_type === 'single_value') {
} else if (mType === 'single_value') {
if (cell.value) {
hasAnyValue = true;
const val = parseFloat(cell.value);
if (!isNaN(val) && !isWithinTolerance(val, item)) hasFail = true;
if (!isNaN(val) && !isWithinTolerance(val, sectionItem, effectiveWorkItems[rowIdx])) hasFail = true;
}
} else if (item.measurement_type === 'substitute') {
// 성적서 대체는 항상 적합 취급
} else if (mType === 'substitute') {
hasAnyValue = true;
} else if (cell.value || cell.text) {
hasAnyValue = true;
@@ -224,141 +254,300 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
return hasFail ? '부' : '적';
};
const effectiveWorkItems = workItems || [];
// ref로 데이터 수집 노출
useImperativeHandle(ref, () => ({
getInspectionData: () => {
const items = effectiveWorkItems.map((wi, idx) => ({
id: wi.id,
apiItemId: wi.apiItemId,
judgment: getRowJudgment(idx),
values: template.columns.reduce((acc, col) => {
const sectionItem = columnItemMap.get(col.id);
if (!sectionItem) return acc;
const key = `${idx}-${col.id}`;
acc[`item_${sectionItem.id}`] = cellValues[key] || null;
return acc;
}, {} as Record<string, CellValue | null>),
}));
return {
template_id: template.id,
items,
inadequateContent,
overall_result: overallResult,
};
},
}));
// 종합판정
const judgments = effectiveWorkItems.map((_, idx) => getRowJudgment(idx));
const overallResult = calculateOverallResult(judgments);
// numeric 아이템의 DATA 열 colspan 계산
const getItemColSpan = (item: InspectionTemplateSectionItem) => {
if (item.measurement_type === 'numeric') return 2; // 기준 + 측정
// 컬럼별 colspan 계산 (complex 컬럼은 sub_labels 수)
const getColSpan = (col: (typeof template.columns)[0]) => {
if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) {
return col.sub_labels.length;
}
return 1;
};
const totalDataCols = allItems.reduce((sum, item) => sum + getItemColSpan(item), 0);
// 기본필드 값 해석 (field_key → WorkOrder 데이터 매핑)
const resolveFieldValue = (field: (typeof template.basic_fields)[0]) => {
if (!field) return '';
switch (field.field_key) {
case 'product_name': return order.items?.[0]?.productName || '-';
case 'specification': return field.default_value || '-';
case 'lot_no': return order.lotNo || '-';
case 'lot_size': return `${order.items?.length || 0} 개소`;
case 'client': return order.client || '-';
case 'site_name': return order.projectName || '-';
case 'inspection_date': return fullDate;
case 'inspector': return primaryAssignee;
default: return field.default_value || '(입력)';
}
};
// --- complex 컬럼 하위 셀 렌더링 ---
const renderComplexCells = (
col: (typeof template.columns)[0],
cellKey: string,
cell: CellValue | undefined,
workItem: WorkItemData,
) => {
if (!col.sub_labels) return null;
const sectionItem = columnItemMap.get(col.id);
let inputIdx = 0;
return col.sub_labels.map((subLabel, subIdx) => {
const sl = subLabel.toLowerCase();
// 도면치수/기준치 → 기준값 readonly 표시
if (sl.includes('도면') || sl.includes('기준')) {
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 px-2 py-1.5 text-center text-gray-500">
{sectionItem ? formatStandard(sectionItem, workItem) : '-'}
</td>
);
}
// OK/NG → 체크박스 토글
if (sl.includes('ok') || sl.includes('ng')) {
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
NG
</label>
</div>
</td>
);
}
// 측정값 → 입력 필드
const mIdx = inputIdx++;
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.measurements?.[mIdx] || ''}
onChange={e => {
const m: [string, string, string] = [
...(cell?.measurements || ['', '', '']),
] as [string, string, string];
m[mIdx] = e.target.value;
updateCell(cellKey, { measurements: m });
}}
readOnly={readOnly}
placeholder="측정값"
/>
</td>
);
});
};
return (
<InspectionLayout
title={template.title || template.name || '중간검사 성적서'}
documentNo={documentNo}
fullDate={fullDate}
primaryAssignee={primaryAssignee}
>
{/* 기본 정보 */}
<div className="p-6 bg-white">
{/* ===== 헤더: KD + 회사명(좌) | 제목(중앙) | 결재라인(우) ===== */}
<div className="flex justify-between items-start mb-4">
<div className="text-center" style={{ width: 80 }}>
<div className="text-2xl font-bold">KD</div>
{template.company_name && (
<div className="text-xs">{template.company_name}</div>
)}
</div>
<div className="flex-1 text-center">
<h1 className="text-xl font-bold tracking-widest">
{template.title || template.name || '중간검사 성적서'}
</h1>
</div>
<div>
{template.approval_lines?.length > 0 ? (
<table className="border-collapse text-xs">
<tbody>
<tr>
{template.approval_lines.map(line => (
<td key={`n-${line.id}`} className="border border-gray-400 px-3 py-1 bg-gray-100 text-center font-medium">
{line.name || '-'}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`d-${line.id}`} className="border border-gray-400 px-3 py-1 text-center">
<div className="text-gray-400 text-xs">{line.dept || ''}</div>
<div className="h-6" />
</td>
))}
</tr>
</tbody>
</table>
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
{/* ===== 기본 필드: 2열 배치 (15:35:15:35) ===== */}
{template.basic_fields?.length > 0 && (
<table className="w-full border-collapse text-xs mb-4">
<table className="w-full border-collapse text-xs mb-4" style={{ tableLayout: 'fixed' }}>
<tbody>
{template.basic_fields.map(field => (
<tr key={field.id}>
<td className="border border-gray-400 bg-gray-100 px-3 py-1.5 font-medium w-32">{field.label}</td>
<td className="border border-gray-400 px-3 py-1.5">
{field.field_key === 'product_name' ? order.items?.[0]?.productName || '-' :
field.field_key === 'lot_no' ? (order.lotNo || '-') :
field.field_key === 'quantity' ? String(order.items?.reduce((sum, i) => sum + (i.quantity || 0), 0) || 0) :
field.field_key === 'inspection_date' ? fullDate :
field.default_value || '-'}
</td>
</tr>
))}
{Array.from({ length: Math.ceil(template.basic_fields.length / 2) }, (_, rowIdx) => {
const f1 = template.basic_fields[rowIdx * 2];
const f2 = template.basic_fields[rowIdx * 2 + 1];
return (
<tr key={rowIdx}>
<td className="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style={{ width: '15%' }}>
{f1.label}
</td>
<td className="border border-gray-400 px-2 py-1.5" style={{ width: '35%' }}>
{resolveFieldValue(f1)}
</td>
{f2 ? (
<>
<td className="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style={{ width: '15%' }}>
{f2.label}
</td>
<td className="border border-gray-400 px-2 py-1.5" style={{ width: '35%' }}>
{resolveFieldValue(f2)}
</td>
</>
) : (
<td className="border border-gray-400 px-2 py-1.5" colSpan={2} />
)}
</tr>
);
})}
</tbody>
</table>
)}
{/* 검사 기준서 - 섹션별 */}
{template.sections.map(section => (
<div key={section.id} className="mb-4">
<div className="mb-1 font-bold text-sm"> {section.name}</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1.5 w-8">No</th>
<th className="border border-gray-400 px-2 py-1.5"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
<th className="border border-gray-400 px-2 py-1.5 w-16"></th>
<th className="border border-gray-400 px-2 py-1.5 w-16"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
</tr>
</thead>
<tbody>
{section.items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center">{idx + 1}</td>
<td className="border border-gray-400 px-2 py-1.5">{item.item || item.category || '-'}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatStandard(item)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatTolerance(item.tolerance)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{item.method_name || item.method || '-'}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{getMeasurementLabel(item.measurement_type)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatFrequency(item)}</td>
</tr>
))}
</tbody>
</table>
{/* ===== 이미지 섹션: items 없는 섹션 ===== */}
{imageSections.map(section => (
<div key={section.id} className="mb-3">
<p className="text-sm font-bold mb-1"> {section.title || section.name}</p>
{section.image_path ? (
<img
src={section.image_path.startsWith('http') ? section.image_path : `/storage/${section.image_path}`}
alt={section.title || section.name}
className="max-h-48 mx-auto border rounded"
/>
) : (
<div className="border border-dashed border-gray-300 rounded p-6 text-center text-gray-400 text-xs">
</div>
)}
</div>
))}
{/* 검사 DATA 테이블 */}
{allItems.length > 0 && effectiveWorkItems.length > 0 && (
<div className="mb-4">
<div className="mb-1 font-bold text-sm"> DATA</div>
{/* ===== DATA 테이블: columns 기반 헤더 + work items 행 ===== */}
{template.columns.length > 0 && effectiveWorkItems.length > 0 && (
<>
{dataSections.length > 0 && (
<p className="text-sm font-bold mb-1 mt-3"> {dataSections[0].title || dataSections[0].name}</p>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
{/* 상위 헤더: 항목 그룹 */}
{/* 상위 헤더 */}
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1.5 w-8" rowSpan={2}>No</th>
<th className="border border-gray-400 px-2 py-1.5 min-w-[80px]" rowSpan={2}></th>
{allItems.map(item => (
<th
key={item.id}
className="border border-gray-400 px-2 py-1 text-center"
colSpan={getItemColSpan(item)}
>
{item.item || item.category || '-'}
</th>
))}
<th className="border border-gray-400 px-2 py-1.5 w-10" rowSpan={2}></th>
</tr>
{/* 하위 헤더: numeric 아이템만 기준/측정 분할 */}
<tr className="bg-gray-50">
{allItems.map(item => {
if (item.measurement_type === 'numeric') {
return (
<Fragment key={item.id}>
<th className="border border-gray-400 px-1 py-1 text-center text-[10px]"></th>
<th className="border border-gray-400 px-1 py-1 text-center text-[10px]"></th>
</Fragment>
);
}
// checkbox, single_value, text, substitute: 단일 열
{template.columns.map(col => {
const isComplex = col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0;
return (
<th key={item.id} className="border border-gray-400 px-1 py-1 text-center text-[10px]">
{item.measurement_type === 'checkbox' ? '양호/불량' :
item.measurement_type === 'substitute' ? '대체' :
'입력'}
<th
key={col.id}
className="border border-gray-400 px-2 py-1.5"
colSpan={getColSpan(col)}
rowSpan={isComplex ? 1 : (hasComplexColumn ? 2 : 1)}
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
);
})}
</tr>
{/* 하위 헤더: complex 컬럼의 sub_labels */}
{hasComplexColumn && (
<tr className="bg-gray-100">
{template.columns.map(col => {
if (col.column_type !== 'complex' || !col.sub_labels || col.sub_labels.length === 0) {
return null; // rowSpan=2로 이미 커버
}
return col.sub_labels.map((subLabel, subIdx) => (
<th
key={`${col.id}-sh-${subIdx}`}
className="border border-gray-400 px-1 py-1 text-[10px]"
>
{subLabel}
</th>
));
})}
</tr>
)}
</thead>
<tbody>
{effectiveWorkItems.map((wi, rowIdx) => (
<tr key={wi.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center">{rowIdx + 1}</td>
<td className="border border-gray-400 px-2 py-1.5 whitespace-nowrap">{wi.itemName || '-'}</td>
{allItems.map(item => {
const key = `${rowIdx}-${item.id}`;
const cell = cellValues[key];
{template.columns.map(col => {
const cellKey = `${rowIdx}-${col.id}`;
const cell = cellValues[cellKey];
// checkbox → 양호/불량 토글
if (item.measurement_type === 'checkbox') {
// 일련번호/NO
if (isSerialColumn(col.label)) {
return (
<td key={item.id} className="border border-gray-400 p-1">
<td key={col.id} className="border border-gray-400 px-2 py-1.5 text-center">
{rowIdx + 1}
</td>
);
}
// 판정 (자동 계산)
if (isJudgmentColumn(col.label)) {
return <JudgmentCell key={col.id} judgment={getRowJudgment(rowIdx)} />;
}
// check → 양호/불량
if (col.column_type === 'check') {
return (
<td key={col.id} className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(key, { status: cell?.status === 'good' ? null : 'good' })}
onClick={() => updateCell(cellKey, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
@@ -366,7 +555,7 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(key, { status: cell?.status === 'bad' ? null : 'bad' })}
onClick={() => updateCell(cellKey, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
@@ -376,107 +565,110 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
);
}
// numeric → 기준값 + 측정값 입력
if (item.measurement_type === 'numeric') {
return (
<Fragment key={item.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center text-gray-500">
{formatStandard(item)}
</td>
<td className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.measurements?.[0] || ''}
onChange={e => {
const m: [string, string, string] = [
...(cell?.measurements || ['', '', '']),
] as [string, string, string];
m[0] = e.target.value;
updateCell(key, { measurements: m });
}}
readOnly={readOnly}
placeholder="측정값"
/>
</td>
</Fragment>
);
// complex → sub_labels 개수만큼 셀 생성
if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) {
return renderComplexCells(col, cellKey, cell, wi);
}
// substitute → 성적서 대체 표시
if (item.measurement_type === 'substitute') {
// select (판정 외) → 텍스트 입력
if (col.column_type === 'select') {
return (
<td key={item.id} className="border border-gray-400 px-2 py-1.5 text-center">
<span className="inline-block px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-[10px]">
</span>
<td key={col.id} className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.value || ''}
onChange={e => updateCell(cellKey, { value: e.target.value })}
readOnly={readOnly}
placeholder="-"
/>
</td>
);
}
// single_value, text, default → 입력 필드
// text/기타 → 텍스트 입력
return (
<td key={item.id} className="border border-gray-400 p-0.5">
<td key={col.id} className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.value || cell?.text || ''}
onChange={e => updateCell(key, { value: e.target.value })}
onChange={e => updateCell(cellKey, { value: e.target.value })}
readOnly={readOnly}
placeholder="-"
/>
</td>
);
})}
<JudgmentCell judgment={getRowJudgment(rowIdx)} />
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{/* 부적합 내용 + 종합판정 */}
<InspectionFooter
readOnly={readOnly}
overallResult={overallResult}
inadequateContent={inadequateContent}
onInadequateContentChange={setInadequateContent}
/>
{/* 결재라인 */}
{template.approval_lines?.length > 0 && (
<div className="mt-4">
<table className="border-collapse text-sm ml-auto">
{/* ===== 푸터: 비고(좌) + 종합판정(우) 병렬 배치 ===== */}
<div className="mt-4 flex gap-4">
<div className="flex-1">
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
{template.approval_lines.map(line => (
<td key={`role-${line.id}`} className="border border-gray-400 px-6 py-1 text-center">
{line.role || line.name}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`name-${line.id}`} className="border border-gray-400 px-6 py-3 text-center text-gray-400">
{line.name || '이름'}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`dept-${line.id}`} className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">
{line.dept || '부서명'}
</td>
))}
<td className="border border-gray-400 px-3 py-2 bg-gray-100 font-medium" style={{ width: 100 }}>
{template.footer_remark_label || '비고'}
</td>
<td className="border border-gray-400 px-3 py-2">
<textarea
value={inadequateContent}
onChange={e => !readOnly && setInadequateContent(e.target.value)}
disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none"
rows={2}
/>
</td>
</tr>
</tbody>
</table>
</div>
)}
</InspectionLayout>
<div>
<table className="border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-2 bg-gray-100 font-medium">
{template.footer_judgement_label || '종합판정'}
</td>
</tr>
<tr>
<td className="border border-gray-400 px-4 py-3 text-center">
{template.footer_judgement_options?.filter(o => o.trim()).length ? (
template.footer_judgement_options.filter(o => o.trim()).map((option, idx) => (
<span
key={idx}
className={`inline-block mx-1 px-2 py-0.5 border rounded ${
overallResult === option
? 'border-blue-500 bg-blue-50 text-blue-600 font-bold'
: overallResult && overallResult !== option
? 'border-gray-200 text-gray-300'
: 'border-gray-300 text-gray-500'
}`}
>
{option}
</span>
))
) : (
<span className={`font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '-'}
</span>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
);

View File

@@ -68,6 +68,8 @@ interface InspectionInputModalProps {
onComplete: (data: InspectionData) => void;
/** 문서 템플릿 데이터 (있으면 동적 폼 모드) */
templateData?: InspectionTemplateData;
/** 작업 아이템의 실제 치수 (reference_attribute 연동용) */
workItemDimensions?: { width?: number; height?: number };
}
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
@@ -258,84 +260,129 @@ function formatToleranceLabel(tolerance: ToleranceConfig): string {
}
}
/** reference_attribute에서 치수 resolve */
function resolveRefValue(
fieldValues: Record<string, unknown> | null,
dimensions?: { width?: number; height?: number }
): number | null {
if (!fieldValues || !dimensions) return null;
const refAttr = fieldValues.reference_attribute;
if (typeof refAttr !== 'string') return null;
const mapping: Record<string, number | undefined> = {
width: dimensions.width,
height: dimensions.height,
length: dimensions.width, // 스크린 '길이' = 폭(width)
};
return mapping[refAttr] ?? null;
}
function formatDimension(val: number | undefined): string {
if (val === undefined || val === null) return '-';
return val.toLocaleString();
}
// ===== 동적 폼 (템플릿 기반) =====
function DynamicInspectionForm({
template,
formValues,
onValueChange,
workItemDimensions,
}: {
template: NonNullable<InspectionTemplateData['template']>;
formValues: Record<string, unknown>;
onValueChange: (key: string, value: unknown) => void;
workItemDimensions?: { width?: number; height?: number };
}) {
// 템플릿 컬럼에서 check 타입 컬럼 추출
const checkColumns = template.columns?.filter(c => c.column_type === 'check') ?? [];
const hasCheckColumns = checkColumns.length > 0;
return (
<div className="space-y-5">
{template.sections.map((section) => (
<div key={section.id} className="space-y-3">
<span className="text-sm font-bold text-gray-800">{section.name}</span>
{section.items.map((item) => {
const itemLabel = item.item || item.name || '';
// ── check 컬럼이 있으면 OK/NG 토글 렌더링 ──
if (hasCheckColumns) {
return (
<div key={item.id} className="space-y-2">
<div className="space-y-0.5">
<span className="text-sm font-bold">{itemLabel}</span>
{item.standard && (
<p className="text-xs text-gray-500">{item.standard}</p>
)}
</div>
{checkColumns.map((col) => {
const fieldKey = `section_${section.id}_item_${item.id}_col_${col.id}`;
const value = formValues[fieldKey] as 'ok' | 'ng' | null | undefined;
return (
<div key={col.id} className="space-y-1">
{checkColumns.length > 1 && (
<span className="text-xs text-gray-500">{col.label}</span>
)}
<OkNgToggle
value={value ?? null}
onChange={(v) => onValueChange(fieldKey, v)}
/>
</div>
);
})}
</div>
);
}
// ── check 컬럼 없음: 기존 로직 (binary → 양호/불량, else → 숫자 입력) ──
const fieldKey = `section_${section.id}_item_${item.id}`;
const value = formValues[fieldKey];
if (item.measurement_type === 'binary' || item.type === 'boolean') {
// 양호/불량 토글
return (
<div key={item.id} className="space-y-1.5">
<span className="text-sm font-bold">{item.item || item.name}</span>
<div className="flex gap-2 flex-1">
<button
type="button"
onClick={() => onValueChange(fieldKey, 'good')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'good'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
<button
type="button"
onClick={() => onValueChange(fieldKey, 'bad')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'bad'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
</div>
<span className="text-sm font-bold">{itemLabel}</span>
<StatusToggle
value={(value as 'good' | 'bad' | null) ?? null}
onChange={(v) => onValueChange(fieldKey, v)}
/>
</div>
);
}
// 숫자 입력 (치수 등)
const toleranceLabel = item.tolerance ? ` (${formatToleranceLabel(item.tolerance)})` : '';
const numValue = value as number | null | undefined;
// 판정 표시
let itemJudgment: 'pass' | 'fail' | null = null;
if (item.tolerance && numValue != null && item.standard_criteria) {
let designValue: number | undefined;
if (item.standard_criteria) {
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const design = parseFloat(designStr);
if (!isNaN(design)) {
itemJudgment = evaluateTolerance(numValue, design, item.tolerance);
}
const parsed = parseFloat(designStr);
if (!isNaN(parsed)) designValue = parsed;
}
if (designValue === undefined) {
const refVal = resolveRefValue(item.field_values, workItemDimensions);
if (refVal !== null) designValue = refVal;
}
const placeholderStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '입력')
: (item.standard_criteria || '입력');
const designLabel = designValue !== undefined ? designValue.toLocaleString() : '';
const toleranceLabel = item.tolerance
? ` (${designLabel ? designLabel + ' ' : ''}${formatToleranceLabel(item.tolerance)})`
: designLabel ? ` (${designLabel})` : '';
let itemJudgment: 'pass' | 'fail' | null = null;
if (item.tolerance && numValue != null && designValue !== undefined) {
itemJudgment = evaluateTolerance(numValue, designValue, item.tolerance);
}
const placeholderStr = designValue !== undefined ? String(designValue) : '입력';
return (
<div key={item.id} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{item.item || item.name}{toleranceLabel}
{itemLabel}{toleranceLabel}
</span>
{itemJudgment && (
<span className={cn(
@@ -368,28 +415,54 @@ function DynamicInspectionForm({
// 동적 폼의 자동 판정 계산
function computeDynamicJudgment(
template: NonNullable<InspectionTemplateData['template']>,
formValues: Record<string, unknown>
formValues: Record<string, unknown>,
workItemDimensions?: { width?: number; height?: number }
): 'pass' | 'fail' | null {
let hasAnyValue = false;
let hasFail = false;
const checkColumns = template.columns?.filter(c => c.column_type === 'check') ?? [];
const hasCheckColumns = checkColumns.length > 0;
for (const section of template.sections) {
for (const item of section.items) {
// ── check 컬럼 기반 판정 ──
if (hasCheckColumns) {
for (const col of checkColumns) {
const key = `section_${section.id}_item_${item.id}_col_${col.id}`;
const val = formValues[key];
if (val != null) {
hasAnyValue = true;
if (val === 'ng') hasFail = true;
}
}
continue;
}
// ── 기존 로직: measurement_type 기반 판정 ──
const fieldKey = `section_${section.id}_item_${item.id}`;
const value = formValues[fieldKey];
if (item.measurement_type === 'binary' || item.type === 'boolean') {
if (value === 'bad') hasFail = true;
if (value != null) hasAnyValue = true;
} else if (item.tolerance && item.standard_criteria) {
} else if (item.tolerance) {
const numValue = value as number | null | undefined;
if (numValue != null) {
hasAnyValue = true;
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const design = parseFloat(designStr);
if (!isNaN(design)) {
let design: number | undefined;
if (item.standard_criteria) {
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const parsed = parseFloat(designStr);
if (!isNaN(parsed)) design = parsed;
}
if (design === undefined) {
const refVal = resolveRefValue(item.field_values, workItemDimensions);
if (refVal !== null) design = refVal;
}
if (design !== undefined) {
const result = evaluateTolerance(numValue, design, item.tolerance);
if (result === 'fail') hasFail = true;
}
@@ -413,6 +486,7 @@ export function InspectionInputModal({
initialData,
onComplete,
templateData,
workItemDimensions,
}: InspectionInputModalProps) {
// 템플릿 모드 여부
const useTemplateMode = !!(templateData?.has_template && templateData.template);
@@ -506,10 +580,10 @@ export function InspectionInputModal({
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
const autoJudgment = useMemo(() => {
if (useTemplateMode && templateData?.template) {
return computeDynamicJudgment(templateData.template, dynamicFormValues);
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
}
return computeJudgment(processType, formData);
}, [useTemplateMode, templateData, dynamicFormValues, processType, formData]);
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData]);
// 판정값 자동 동기화
useEffect(() => {
@@ -581,6 +655,7 @@ export function InspectionInputModal({
onValueChange={(key, value) =>
setDynamicFormValues((prev) => ({ ...prev, [key]: value }))
}
workItemDimensions={workItemDimensions}
/>
)}
@@ -598,20 +673,20 @@ export function InspectionInputModal({
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder="1,000"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.height)})</span>
<Input
type="number"
placeholder="1,000"
placeholder={formatDimension(workItemDimensions?.height)}
value={formData.width ?? ''}
onChange={(e) => handleNumberChange('width', e.target.value)}
className="h-11 rounded-lg border-gray-300"
@@ -664,20 +739,20 @@ export function InspectionInputModal({
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder="1,000"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.height)})</span>
<Input
type="number"
placeholder="1,000"
placeholder={formatDimension(workItemDimensions?.height)}
value={formData.width ?? ''}
onChange={(e) => handleNumberChange('width', e.target.value)}
className="h-11 rounded-lg border-gray-300"
@@ -822,10 +897,10 @@ export function InspectionInputModal({
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder="1,000"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"

View File

@@ -23,6 +23,10 @@ interface WorkOrderApiItem {
process_name: string;
process_code: string;
department?: string | null;
options?: {
needs_inspection?: boolean;
needs_work_log?: boolean;
} | null;
};
/** @deprecated process_id + process relation 사용 */
process_type?: 'screen' | 'slat' | 'bending';
@@ -151,7 +155,7 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
quantity: totalQuantity,
shutterCount: api.sales_order?.root_nodes_count || 0,
shutterCount: nodeGroups.length || api.sales_order?.root_nodes_count || 0,
dueDate,
priority: 5, // 기본 우선순위
status: mapApiStatus(api.status),
@@ -161,6 +165,10 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
instruction: api.memo || undefined,
salesOrderNo: api.sales_order?.order_no || undefined,
createdAt: api.created_at,
processOptions: {
needsInspection: api.process?.options?.needs_inspection ?? false,
needsWorkLog: api.process?.options?.needs_work_log ?? false,
},
nodeGroups,
};
}
@@ -419,18 +427,38 @@ export async function getWorkOrderDetail(
if (stepProgressList.length > 0) {
steps = stepProgressList
.filter((sp: { work_order_item_id: number | null }) => !sp.work_order_item_id || sp.work_order_item_id === item.id)
.map((sp: { id: number; process_step: { step_name: string; step_code: string } | null; status: string }) => ({
.map((sp: {
id: number;
process_step: {
step_name: string; step_code: string;
needs_inspection?: boolean; connection_type?: string; completion_type?: string;
} | null;
status: string;
}) => ({
id: String(sp.id),
name: sp.process_step?.step_name || '',
isMaterialInput: (sp.process_step?.step_code || '').includes('MAT') || (sp.process_step?.step_name || '').includes('자재투입'),
isInspection: sp.process_step?.needs_inspection || false,
isCompleted: sp.status === 'completed',
stepProgressId: sp.id,
needsInspection: sp.process_step?.needs_inspection || false,
connectionType: sp.process_step?.connection_type || undefined,
connectionTarget: undefined, // step_progress API에 미포함, processListCache에서 보완
completionType: sp.process_step?.completion_type || undefined,
}));
} else {
steps = processSteps.map((ps: { id: number; step_name: string; step_code: string }, si: number) => ({
steps = processSteps.map((ps: {
id: number; step_name: string; step_code: string;
needs_inspection?: boolean; connection_type?: string; completion_type?: string;
}, si: number) => ({
id: `${item.id}-step-${si}`,
name: ps.step_name,
isMaterialInput: ps.step_code.includes('MAT') || ps.step_name.includes('자재투입'),
isInspection: ps.needs_inspection || false,
isCompleted: false,
needsInspection: ps.needs_inspection || false,
connectionType: ps.connection_type || undefined,
completionType: ps.completion_type || undefined,
}));
}

View File

@@ -33,7 +33,7 @@ import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate } from './actions';
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, toggleStepProgress } from './actions';
import type { InspectionTemplateData } from './types';
import { getProcessList } from '@/components/process-management/actions';
import type { InspectionSetting, Process } from '@/types/process';
@@ -554,11 +554,35 @@ export default function WorkerScreen() {
}, [filteredWorkOrders]);
// ===== 선택된 작업지시의 개소별 WorkItemData 변환 + 목업 =====
// 현재 활성 공정의 단계 설정 (processListCache 기반)
const activeProcessSteps = useMemo(() => {
const process = processListCache.find((p) => p.id === activeTab);
return process?.steps || [];
}, [activeTab, processListCache]);
const workItems: WorkItemData[] = useMemo(() => {
const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey;
const stepsTemplate = PROCESS_STEPS[stepsKey];
// PROCESS_STEPS 폴백 step에 processListCache 설정 매칭하는 헬퍼
const enrichStep = (st: { name: string; isMaterialInput: boolean; isInspection?: boolean }, stepId: string, stepKey: string) => {
// 단계명으로 processListCache의 단계 설정 매칭
const matched = activeProcessSteps.find((ps) => ps.stepName === st.name);
return {
id: stepId,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: matched ? matched.needsInspection : (st.isInspection || false),
isCompleted: stepCompletionMap[stepKey] || false,
needsInspection: matched?.needsInspection,
connectionType: matched?.connectionType,
connectionTarget: matched?.connectionTarget,
completionType: matched?.completionType,
};
};
const apiItems: WorkItemData[] = [];
if (selectedOrder && selectedOrder.nodeGroups && selectedOrder.nodeGroups.length > 0) {
@@ -567,13 +591,7 @@ export default function WorkerScreen() {
const nodeKey = group.nodeId != null ? String(group.nodeId) : `unassigned-${index}`;
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${selectedOrder.id}-${nodeKey}-${st.name}`;
return {
id: `${selectedOrder.id}-${nodeKey}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
return enrichStep(st, `${selectedOrder.id}-${nodeKey}-step-${si}`, stepKey);
});
// 개소 내 아이템 이름 요약
@@ -639,13 +657,7 @@ export default function WorkerScreen() {
// nodeGroups가 없는 경우 폴백 (단일 항목)
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
const stepKey = `${selectedOrder.id}-${st.name}`;
return {
id: `${selectedOrder.id}-step-${si}`,
name: st.name,
isMaterialInput: st.isMaterialInput,
isInspection: st.isInspection,
isCompleted: stepCompletionMap[stepKey] || false,
};
return enrichStep(st, `${selectedOrder.id}-step-${si}`, stepKey);
});
apiItems.push({
id: selectedOrder.id,
@@ -685,7 +697,7 @@ export default function WorkerScreen() {
}));
return [...apiItems, ...mockItems];
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode]);
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps]);
// ===== 수주 정보 (사이드바 선택 항목 기반) =====
const orderInfo = useMemo(() => {
@@ -733,6 +745,53 @@ export default function WorkerScreen() {
// ===== 핸들러 =====
// 중간검사 버튼 클릭 핸들러 - 템플릿 로드 후 모달 열기
const handleInspectionClick = useCallback(async (itemId: string) => {
// 해당 아이템 찾기
const item = workItems.find((w) => w.id === itemId);
if (item) {
// 합성 WorkOrder 생성
const syntheticOrder: WorkOrder = {
id: item.id,
orderNo: item.itemCode,
productName: item.itemName,
processCode: item.processType,
processName: PROCESS_TAB_LABELS[item.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: item.quantity,
shutterCount: 0,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
setSelectedOrder(syntheticOrder);
setInspectionDimensions({ width: item.width, height: item.height });
// 실제 API 아이템인 경우 검사 템플릿 로딩 시도
if (item.workOrderId && !item.id.startsWith('mock-')) {
try {
const tplResult = await getInspectionTemplate(item.workOrderId);
if (tplResult.success && tplResult.data?.has_template) {
setInspectionTemplateData(tplResult.data);
} else {
setInspectionTemplateData(undefined);
}
} catch {
setInspectionTemplateData(undefined);
}
} else {
setInspectionTemplateData(undefined);
}
setIsInspectionInputModalOpen(true);
}
}, [workItems]);
// pill 클릭 핸들러
const handleStepClick = useCallback(
(itemId: string, step: WorkStepData) => {
@@ -768,6 +827,12 @@ export default function WorkerScreen() {
setIsMaterialModalOpen(true);
}
}
} else if (step.connectionType === '팝업' && step.connectionTarget === '중간검사') {
// 연결정보: 팝업 + 중간검사 → 중간검사 모달 열기
handleInspectionClick(itemId);
} else if (step.needsInspection || step.isInspection) {
// 검사 단계 (processListCache 설정 또는 하드코딩 폴백) → 중간검사 모달 열기
handleInspectionClick(itemId);
} else {
// 기타 → 완료/미완료 토글
const stepKey = `${itemId}-${step.name}`;
@@ -777,7 +842,7 @@ export default function WorkerScreen() {
}));
}
},
[workOrders, workItems]
[workOrders, workItems, handleInspectionClick]
);
// 자재 수정 핸들러
@@ -891,53 +956,6 @@ export default function WorkerScreen() {
};
}, [filteredWorkOrders, workItems]);
// 중간검사 버튼 클릭 핸들러 - 템플릿 로드 후 모달 열기
const handleInspectionClick = useCallback(async (itemId: string) => {
// 해당 아이템 찾기
const item = workItems.find((w) => w.id === itemId);
if (item) {
// 합성 WorkOrder 생성
const syntheticOrder: WorkOrder = {
id: item.id,
orderNo: item.itemCode,
productName: item.itemName,
processCode: item.processType,
processName: PROCESS_TAB_LABELS[item.processType],
client: '-',
projectName: '-',
assignees: [],
quantity: item.quantity,
shutterCount: 0,
dueDate: '',
priority: 5,
status: 'waiting',
isUrgent: false,
isDelayed: false,
createdAt: '',
};
setSelectedOrder(syntheticOrder);
setInspectionDimensions({ width: item.width, height: item.height });
// 실제 API 아이템인 경우 검사 템플릿 로딩 시도
if (item.workOrderId && !item.id.startsWith('mock-')) {
try {
const tplResult = await getInspectionTemplate(item.workOrderId);
if (tplResult.success && tplResult.data?.has_template) {
setInspectionTemplateData(tplResult.data);
} else {
setInspectionTemplateData(undefined);
}
} catch {
setInspectionTemplateData(undefined);
}
} else {
setInspectionTemplateData(undefined);
}
setIsInspectionInputModalOpen(true);
}
}, [workItems]);
// 현재 공정에 맞는 중간검사 타입 결정
const getInspectionProcessType = useCallback((): InspectionProcessType => {
if (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') {
@@ -970,7 +988,7 @@ export default function WorkerScreen() {
}
}, [getTargetOrder]);
// 중간검사 완료 핸들러 (API 저장 + 메모리 업데이트)
// 중간검사 완료 핸들러 (API 저장 + 메모리 업데이트 + 공정 단계 완료 처리)
const handleInspectionComplete = useCallback(async (data: InspectionData) => {
if (!selectedOrder) return;
@@ -981,10 +999,6 @@ export default function WorkerScreen() {
return next;
});
// 중간검사 step 완료 처리
const stepKey = selectedOrder.id.replace('-node-', '-') + '-중간검사';
setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true }));
// 실제 API item인 경우 서버에 저장
const targetItem = workItems.find((w) => w.id === selectedOrder.id);
if (targetItem?.apiItemId && targetItem?.workOrderId) {
@@ -1010,11 +1024,37 @@ export default function WorkerScreen() {
} catch {
// Document 저장 실패는 무시 (template 미연결 시 404 가능)
}
// 3. completionType='검사완료 시 완료'인 단계 자동 완료 처리
const inspectionStep = targetItem.steps.find(
(s) => (s.completionType === '검사완료 시 완료') || s.needsInspection || s.isInspection
);
if (inspectionStep?.stepProgressId) {
// 서버에 단계 완료 토글
try {
const toggleResult = await toggleStepProgress(targetItem.workOrderId, inspectionStep.stepProgressId);
if (toggleResult.success) {
// 로컬 상태도 동기화
const stepKey = `${selectedOrder.id}-${inspectionStep.name}`;
setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true }));
}
} catch {
// 단계 완료 실패 시 로컬만 업데이트
const stepKey = `${selectedOrder.id}-${inspectionStep.name}`;
setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true }));
}
} else {
// stepProgressId 없으면 로컬만 완료 처리 (목업 호환)
const stepKey = selectedOrder.id.replace('-node-', '-') + '-중간검사';
setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true }));
}
} catch {
toast.error('검사 데이터 저장 중 오류가 발생했습니다.');
}
} else {
// 목업 데이터는 메모리만 저장
// 목업 데이터는 메모리만 저장 + 로컬 완료 처리
const stepKey = selectedOrder.id.replace('-node-', '-') + '-중간검사';
setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true }));
toast.success('중간검사가 완료되었습니다.');
}
}, [selectedOrder, workItems, getInspectionProcessType]);

View File

@@ -112,6 +112,12 @@ export interface WorkStepData {
isMaterialInput: boolean; // 자재투입 단계 여부
isInspection?: boolean; // 중간검사 단계 여부
isCompleted: boolean; // 완료 여부
// 공정 단계 설정 연동 (공정관리 페이지의 단계 설정)
stepProgressId?: number; // work_order_step_progress.id (서버 완료 토글용)
needsInspection?: boolean; // 검사여부 (ProcessStep.needs_inspection)
connectionType?: string; // 연결 유형: '팝업' | '없음'
connectionTarget?: string; // 도달: '중간검사' 등
completionType?: string; // 완료 유형: '검사완료 시 완료' | '클릭 시 완료' | '선택 완료 시 완료'
}
// ===== 자재 투입 목록 항목 =====
@@ -244,6 +250,8 @@ export interface InspectionTemplateFormat {
sections: {
id: number;
name: string;
title?: string;
image_path?: string | null;
sort_order: number;
items: InspectionTemplateSectionItem[];
}[];
@@ -260,10 +268,10 @@ export interface InspectionTemplateFormat {
columns: {
id: number;
label: string;
input_type: string;
options: Record<string, unknown> | null;
column_type: string;
sub_labels: string[] | null;
group_name: string | null;
width: string | null;
is_required: boolean;
sort_order: number;
}[];
approval_lines: {
@@ -276,12 +284,10 @@ export interface InspectionTemplateFormat {
}[];
basic_fields: {
id: number;
field_key: string;
field_key: string | null;
label: string;
input_type: string;
options: Record<string, unknown> | null;
field_type: string;
default_value: string | null;
is_required: boolean;
sort_order: number;
}[];
}

View File

@@ -59,13 +59,16 @@ export interface Process {
department: string; // 담당부서
workLogTemplate?: string; // 작업일지 양식 (레거시 string)
// 중간검사/작업일지 설정 (Process 레벨)
// 검사/양식 FK
documentTemplateId?: number; // 중간검사 양식 ID
documentTemplateName?: string; // 중간검사 양식명 (표시용)
needsWorkLog: boolean; // 작업일지 여부
workLogTemplateId?: number; // 작업일지 양식 ID
workLogTemplateName?: string; // 작업일지 양식명 (표시용)
// 공정 설정 (options JSON)
needsInspection: boolean; // 중간검사 여부
needsWorkLog: boolean; // 작업일지 여부
// 자동 분류 규칙
classificationRules: ClassificationRule[];
@@ -107,8 +110,9 @@ export interface ProcessFormData {
useProductionDate?: boolean;
workLogTemplate?: string;
documentTemplateId?: number;
needsWorkLog: boolean;
workLogTemplateId?: number;
needsInspection: boolean;
needsWorkLog: boolean;
classificationRules: ClassificationRuleInput[];
requiredWorkers: number;
equipmentInfo?: string;