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:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 || '수정에 실패했습니다.');
|
||||
|
||||
@@ -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) : [],
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface WorkOrder {
|
||||
instruction?: string; // 지시사항
|
||||
salesOrderNo?: string; // 수주번호
|
||||
createdAt: string;
|
||||
// 공정 설정 (작업자 화면용)
|
||||
processOptions?: {
|
||||
needsInspection?: boolean;
|
||||
needsWorkLog?: boolean;
|
||||
};
|
||||
// 개소별 아이템 그룹 (작업자 화면용)
|
||||
nodeGroups?: WorkOrderNodeGroup[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user