feat(WEB): 중간검사 문서 템플릿 동적 연동 - 공정관리 선택기 + Worker Screen 동적 폼
- ProcessStep 타입에 documentTemplateId/documentTemplateName 추가 - 공정관리 actions.ts: document_template_id 매핑 + getDocumentTemplates 서버 액션 - StepForm: 검사여부 사용 시 문서양식 선택 드롭다운 추가 - WorkerScreen actions.ts: getInspectionTemplate, saveInspectionDocument 서버 액션 추가 - InspectionInputModal: tolerance 기반 자동 판정 + 동적 폼(DynamicInspectionForm) 추가 - evaluateTolerance: symmetric/asymmetric/range 3가지 tolerance 판정 - 기존 공정별 하드코딩은 템플릿 없을 때 레거시 모드로 유지 - InspectionReportModal: 템플릿 모드 동적 렌더링 (기준서/DATA/결재라인) - WorkerScreen index: handleInspectionComplete에서 Document 저장 호출 추가
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* - 완료 정보: 유형(선택 완료 시 완료/클릭 시 완료)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
STEP_CONNECTION_TARGET_OPTIONS,
|
||||
DEFAULT_INSPECTION_SETTING,
|
||||
} from '@/types/process';
|
||||
import { createProcessStep, updateProcessStep } from './actions';
|
||||
import { createProcessStep, updateProcessStep, getDocumentTemplates } from './actions';
|
||||
import type { DocumentTemplateOption } from './actions';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import { InspectionSettingModal } from './InspectionSettingModal';
|
||||
import { InspectionPreviewModal } from './InspectionPreviewModal';
|
||||
@@ -104,6 +105,12 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
initialData?.completionType || '클릭 시 완료'
|
||||
);
|
||||
|
||||
// 문서양식 선택
|
||||
const [documentTemplateId, setDocumentTemplateId] = useState<number | undefined>(
|
||||
initialData?.documentTemplateId
|
||||
);
|
||||
const [documentTemplates, setDocumentTemplates] = useState<DocumentTemplateOption[]>([]);
|
||||
|
||||
// 검사 설정
|
||||
const [inspectionSetting, setInspectionSetting] = useState<InspectionSetting>(
|
||||
initialData?.inspectionSetting || DEFAULT_INSPECTION_SETTING
|
||||
@@ -118,6 +125,17 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
// 검사여부가 "사용"인지 확인
|
||||
const isInspectionEnabled = needsInspection === '사용';
|
||||
|
||||
// 검사여부가 "사용"이면 문서양식 목록 조회
|
||||
useEffect(() => {
|
||||
if (isInspectionEnabled && documentTemplates.length === 0) {
|
||||
getDocumentTemplates().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setDocumentTemplates(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isInspectionEnabled, documentTemplates.length]);
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!stepName.trim()) {
|
||||
@@ -131,6 +149,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
isRequired: isRequired === '필수',
|
||||
needsApproval: needsApproval === '필요',
|
||||
needsInspection: needsInspection === '사용',
|
||||
documentTemplateId: isInspectionEnabled ? documentTemplateId : undefined,
|
||||
isActive: isActive === '사용',
|
||||
order: initialData?.order || 0,
|
||||
connectionType,
|
||||
@@ -250,6 +269,33 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 문서양식 선택 (검사여부가 "사용"일 때만 표시) */}
|
||||
{isInspectionEnabled && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
중간검사 시 사용할 문서양식을 선택합니다. MNG에서 등록한 양식이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -338,6 +384,8 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
completionType,
|
||||
initialData?.stepCode,
|
||||
isInspectionEnabled,
|
||||
documentTemplateId,
|
||||
documentTemplates,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -519,6 +519,40 @@ export async function getItemTypeOptions(): Promise<Array<{ value: string; label
|
||||
return result.data.map((item) => ({ value: item.code, label: item.name }));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 문서 양식 (Document Template) API
|
||||
// ============================================================================
|
||||
|
||||
export interface DocumentTemplateOption {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 양식 목록 조회 (드롭다운용)
|
||||
*/
|
||||
export async function getDocumentTemplates(): Promise<{
|
||||
success: boolean;
|
||||
data?: DocumentTemplateOption[];
|
||||
error?: string;
|
||||
}> {
|
||||
interface ApiTemplateItem { id: number; name: string; category: string }
|
||||
const result = await executeServerAction<{ data: ApiTemplateItem[] }>({
|
||||
url: `${API_URL}/api/v1/document-templates?is_active=1&per_page=100`,
|
||||
errorMessage: '문서 양식 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data?.data) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.data.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 공정 단계 (Process Step) API
|
||||
// ============================================================================
|
||||
@@ -531,6 +565,12 @@ interface ApiProcessStep {
|
||||
is_required: boolean;
|
||||
needs_approval: boolean;
|
||||
needs_inspection: boolean;
|
||||
document_template_id: number | null;
|
||||
document_template?: {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
} | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
connection_type: string | null;
|
||||
@@ -548,6 +588,8 @@ function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep {
|
||||
isRequired: apiStep.is_required,
|
||||
needsApproval: apiStep.needs_approval,
|
||||
needsInspection: apiStep.needs_inspection,
|
||||
documentTemplateId: apiStep.document_template_id ?? undefined,
|
||||
documentTemplateName: apiStep.document_template?.name ?? undefined,
|
||||
isActive: apiStep.is_active,
|
||||
order: apiStep.sort_order,
|
||||
connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음',
|
||||
@@ -603,6 +645,7 @@ export async function createProcessStep(
|
||||
is_required: data.isRequired,
|
||||
needs_approval: data.needsApproval,
|
||||
needs_inspection: data.needsInspection,
|
||||
document_template_id: data.documentTemplateId || null,
|
||||
is_active: data.isActive,
|
||||
connection_type: data.connectionType || null,
|
||||
connection_target: data.connectionTarget || null,
|
||||
@@ -627,6 +670,7 @@ export async function updateProcessStep(
|
||||
if (data.isRequired !== undefined) apiData.is_required = data.isRequired;
|
||||
if (data.needsApproval !== undefined) apiData.needs_approval = data.needsApproval;
|
||||
if (data.needsInspection !== undefined) apiData.needs_inspection = data.needsInspection;
|
||||
if (data.documentTemplateId !== undefined) apiData.document_template_id = data.documentTemplateId || null;
|
||||
if (data.isActive !== undefined) apiData.is_active = data.isActive;
|
||||
if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null;
|
||||
if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null;
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { InspectionContentRef } from './ScreenInspectionContent';
|
||||
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
|
||||
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
|
||||
import type { InspectionSetting } from '@/types/process';
|
||||
import type { InspectionTemplateData } from '@/components/production/WorkerScreen/actions';
|
||||
|
||||
const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
@@ -53,6 +54,8 @@ interface InspectionReportModalProps {
|
||||
inspectionDataMap?: InspectionDataMap;
|
||||
/** 중간검사 설정 - 도해/검사기준 이미지 표시용 */
|
||||
inspectionSetting?: InspectionSetting;
|
||||
/** 문서 템플릿 데이터 (있으면 동적 렌더링 모드) */
|
||||
templateData?: InspectionTemplateData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +112,7 @@ export function InspectionReportModal({
|
||||
workItems: propWorkItems,
|
||||
inspectionDataMap: propInspectionDataMap,
|
||||
inspectionSetting,
|
||||
templateData,
|
||||
}: InspectionReportModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -258,10 +262,143 @@ export function InspectionReportModal({
|
||||
? '중간검사성적서 (조인트바)'
|
||||
: '중간검사 성적서';
|
||||
|
||||
// 템플릿 기반 동적 렌더링 여부
|
||||
const useTemplateMode = !!(templateData?.has_template && templateData.template);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!order) return null;
|
||||
|
||||
// 공통 props
|
||||
// 템플릿 모드: 동적 렌더링
|
||||
if (useTemplateMode && templateData?.template) {
|
||||
const tpl = templateData.template;
|
||||
return (
|
||||
<div className="p-6 bg-white space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold mb-2">기본 정보</h3>
|
||||
<table className="w-full border-collapse border text-xs">
|
||||
<tbody>
|
||||
{tpl.basic_fields?.map((field) => (
|
||||
<tr key={field.id}>
|
||||
<td className="border px-3 py-1.5 bg-gray-50 font-medium w-32">{field.name}</td>
|
||||
<td className="border 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) :
|
||||
'-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 검사 기준서 (sections) */}
|
||||
{tpl.sections?.map((section) => (
|
||||
<div key={section.id}>
|
||||
<h3 className="text-sm font-bold mb-2">{section.name}</h3>
|
||||
<table className="w-full border-collapse border text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border px-3 py-1.5 text-left w-8">No</th>
|
||||
<th className="border px-3 py-1.5 text-left">검사항목</th>
|
||||
<th className="border px-3 py-1.5 text-left w-24">기준</th>
|
||||
<th className="border px-3 py-1.5 text-left w-24">허용오차</th>
|
||||
<th className="border px-3 py-1.5 text-left w-20">방법</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.items?.map((item, idx) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border px-3 py-1.5">{idx + 1}</td>
|
||||
<td className="border px-3 py-1.5">{item.name}</td>
|
||||
<td className="border px-3 py-1.5">{item.standard_criteria || '-'}</td>
|
||||
<td className="border px-3 py-1.5">
|
||||
{item.tolerance ? (
|
||||
item.tolerance.type === 'symmetric' ? `± ${item.tolerance.value}` :
|
||||
item.tolerance.type === 'asymmetric' ? `+${item.tolerance.plus} / -${item.tolerance.minus}` :
|
||||
item.tolerance.type === 'range' ? `${item.tolerance.min} ~ ${item.tolerance.max}` : '-'
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="border px-3 py-1.5">{item.measurement_type || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 검사 DATA (columns) */}
|
||||
{tpl.columns && tpl.columns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold mb-2">검사 DATA</h3>
|
||||
<table className="w-full border-collapse border text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border px-3 py-1.5 text-left w-8">No</th>
|
||||
{tpl.columns.map((col) => (
|
||||
<th key={col.id} className="border px-3 py-1.5 text-left">{col.name}</th>
|
||||
))}
|
||||
<th className="border px-3 py-1.5 text-left w-16">판정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(effectiveWorkItems || []).map((item, idx) => {
|
||||
const itemData = effectiveInspectionDataMap?.get(item.id);
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td className="border px-3 py-1.5">{idx + 1}</td>
|
||||
{tpl.columns.map((col) => (
|
||||
<td key={col.id} className="border px-3 py-1.5">
|
||||
{itemData?.templateValues
|
||||
? String(itemData.templateValues[`col_${col.id}`] ?? '-')
|
||||
: '-'}
|
||||
</td>
|
||||
))}
|
||||
<td className="border px-3 py-1.5 font-bold">
|
||||
{itemData?.judgment === 'pass' ? (
|
||||
<span className="text-green-600">적합</span>
|
||||
) : itemData?.judgment === 'fail' ? (
|
||||
<span className="text-red-600">부적합</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 결재라인 */}
|
||||
{tpl.approval_lines && tpl.approval_lines.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold mb-2">결재</h3>
|
||||
<table className="w-full border-collapse border text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
{tpl.approval_lines.map((line) => (
|
||||
<th key={line.id} className="border px-3 py-2 text-center w-24">{line.role_name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{tpl.approval_lines.map((line) => (
|
||||
<td key={line.id} className="border px-3 py-4 text-center text-gray-400">
|
||||
(서명)
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 레거시 모드: 공정별 하드코딩 컴포넌트
|
||||
const commonProps = {
|
||||
ref: contentRef,
|
||||
data: order,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InspectionTemplateData } from './actions';
|
||||
|
||||
// 중간검사 공정 타입
|
||||
export type InspectionProcessType =
|
||||
@@ -53,6 +54,8 @@ export interface InspectionData {
|
||||
// 판정
|
||||
judgment: 'pass' | 'fail' | null;
|
||||
nonConformingContent: string;
|
||||
// 동적 폼 값 (템플릿 기반 검사 시)
|
||||
templateValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface InspectionInputModalProps {
|
||||
@@ -63,6 +66,8 @@ interface InspectionInputModalProps {
|
||||
specification?: string;
|
||||
initialData?: InspectionData;
|
||||
onComplete: (data: InspectionData) => void;
|
||||
/** 문서 템플릿 데이터 (있으면 동적 폼 모드) */
|
||||
templateData?: InspectionTemplateData;
|
||||
}
|
||||
|
||||
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
|
||||
@@ -224,6 +229,171 @@ function computeJudgment(processType: InspectionProcessType, data: InspectionDat
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Tolerance 기반 판정 유틸 =====
|
||||
type ToleranceConfig = NonNullable<NonNullable<InspectionTemplateData['template']>['sections'][number]['items'][number]['tolerance']>;
|
||||
|
||||
function evaluateTolerance(measured: number, design: number, tolerance: ToleranceConfig): 'pass' | 'fail' {
|
||||
switch (tolerance.type) {
|
||||
case 'symmetric':
|
||||
return Math.abs(measured - design) <= (tolerance.value ?? 0) ? 'pass' : 'fail';
|
||||
case 'asymmetric':
|
||||
return (measured >= design - (tolerance.minus ?? 0) && measured <= design + (tolerance.plus ?? 0)) ? 'pass' : 'fail';
|
||||
case 'range':
|
||||
return (measured >= (tolerance.min ?? -Infinity) && measured <= (tolerance.max ?? Infinity)) ? 'pass' : 'fail';
|
||||
default:
|
||||
return 'pass';
|
||||
}
|
||||
}
|
||||
|
||||
function formatToleranceLabel(tolerance: ToleranceConfig): string {
|
||||
switch (tolerance.type) {
|
||||
case 'symmetric':
|
||||
return `± ${tolerance.value}`;
|
||||
case 'asymmetric':
|
||||
return `+${tolerance.plus} / -${tolerance.minus}`;
|
||||
case 'range':
|
||||
return `${tolerance.min} ~ ${tolerance.max}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 동적 폼 (템플릿 기반) =====
|
||||
function DynamicInspectionForm({
|
||||
template,
|
||||
formValues,
|
||||
onValueChange,
|
||||
}: {
|
||||
template: NonNullable<InspectionTemplateData['template']>;
|
||||
formValues: Record<string, unknown>;
|
||||
onValueChange: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
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 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.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>
|
||||
</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) {
|
||||
const design = parseFloat(item.standard_criteria);
|
||||
if (!isNaN(design)) {
|
||||
itemJudgment = evaluateTolerance(numValue, design, item.tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.id} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold">
|
||||
{item.name}{toleranceLabel}
|
||||
</span>
|
||||
{itemJudgment && (
|
||||
<span className={cn(
|
||||
'text-xs font-bold px-2 py-0.5 rounded',
|
||||
itemJudgment === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
)}>
|
||||
{itemJudgment === 'pass' ? '적합' : '부적합'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={item.standard_criteria || '입력'}
|
||||
value={numValue ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === '' ? null : parseFloat(e.target.value);
|
||||
onValueChange(fieldKey, v);
|
||||
}}
|
||||
className="h-11 rounded-lg border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 폼의 자동 판정 계산
|
||||
function computeDynamicJudgment(
|
||||
template: NonNullable<InspectionTemplateData['template']>,
|
||||
formValues: Record<string, unknown>
|
||||
): 'pass' | 'fail' | null {
|
||||
let hasAnyValue = false;
|
||||
let hasFail = false;
|
||||
|
||||
for (const section of template.sections) {
|
||||
for (const item of section.items) {
|
||||
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) {
|
||||
const numValue = value as number | null | undefined;
|
||||
if (numValue != null) {
|
||||
hasAnyValue = true;
|
||||
const design = parseFloat(item.standard_criteria);
|
||||
if (!isNaN(design)) {
|
||||
const result = evaluateTolerance(numValue, design, item.tolerance);
|
||||
if (result === 'fail') hasFail = true;
|
||||
}
|
||||
}
|
||||
} else if (value != null) {
|
||||
hasAnyValue = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyValue) return null;
|
||||
return hasFail ? 'fail' : 'pass';
|
||||
}
|
||||
|
||||
export function InspectionInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -232,7 +402,11 @@ export function InspectionInputModal({
|
||||
specification = '',
|
||||
initialData,
|
||||
onComplete,
|
||||
templateData,
|
||||
}: InspectionInputModalProps) {
|
||||
// 템플릿 모드 여부
|
||||
const useTemplateMode = !!(templateData?.has_template && templateData.template);
|
||||
|
||||
const [formData, setFormData] = useState<InspectionData>({
|
||||
productName,
|
||||
specification,
|
||||
@@ -240,6 +414,9 @@ export function InspectionInputModal({
|
||||
nonConformingContent: '',
|
||||
});
|
||||
|
||||
// 동적 폼 값 (템플릿 모드용)
|
||||
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 절곡용 간격 포인트 초기화
|
||||
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
|
||||
Array(5).fill(null).map(() => ({ left: null, right: null }))
|
||||
@@ -312,11 +489,17 @@ export function InspectionInputModal({
|
||||
}
|
||||
|
||||
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
|
||||
setDynamicFormValues({});
|
||||
}
|
||||
}, [open, productName, specification, processType, initialData]);
|
||||
|
||||
// 자동 판정 계산
|
||||
const autoJudgment = useMemo(() => computeJudgment(processType, formData), [processType, formData]);
|
||||
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
|
||||
const autoJudgment = useMemo(() => {
|
||||
if (useTemplateMode && templateData?.template) {
|
||||
return computeDynamicJudgment(templateData.template, dynamicFormValues);
|
||||
}
|
||||
return computeJudgment(processType, formData);
|
||||
}, [useTemplateMode, templateData, dynamicFormValues, processType, formData]);
|
||||
|
||||
// 판정값 자동 동기화
|
||||
useEffect(() => {
|
||||
@@ -330,6 +513,8 @@ export function InspectionInputModal({
|
||||
const data: InspectionData = {
|
||||
...formData,
|
||||
gapPoints: processType === 'bending' ? gapPoints : undefined,
|
||||
// 동적 폼 값을 templateValues로 병합
|
||||
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
|
||||
};
|
||||
onComplete(data);
|
||||
onOpenChange(false);
|
||||
@@ -378,8 +563,21 @@ export function InspectionInputModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 동적 폼 (템플릿 기반) ===== */}
|
||||
{useTemplateMode && templateData?.template && (
|
||||
<DynamicInspectionForm
|
||||
template={templateData.template}
|
||||
formValues={dynamicFormValues}
|
||||
onValueChange={(key, value) =>
|
||||
setDynamicFormValues((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ===== 레거시: 공정별 하드코딩 검사 항목 (템플릿 없을 때만 표시) ===== */}
|
||||
|
||||
{/* ===== 재고생산 (bending_wip) 검사 항목 ===== */}
|
||||
{processType === 'bending_wip' && (
|
||||
{!useTemplateMode && processType === 'bending_wip' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 절곡상태</span>
|
||||
@@ -431,7 +629,7 @@ export function InspectionInputModal({
|
||||
)}
|
||||
|
||||
{/* ===== 스크린 검사 항목 ===== */}
|
||||
{processType === 'screen' && (
|
||||
{!useTemplateMode && processType === 'screen' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 가공상태</span>
|
||||
@@ -487,7 +685,7 @@ export function InspectionInputModal({
|
||||
)}
|
||||
|
||||
{/* ===== 슬랫 검사 항목 ===== */}
|
||||
{processType === 'slat' && (
|
||||
{!useTemplateMode && processType === 'slat' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 가공상태</span>
|
||||
@@ -539,7 +737,7 @@ export function InspectionInputModal({
|
||||
)}
|
||||
|
||||
{/* ===== 조인트바 검사 항목 ===== */}
|
||||
{processType === 'slat_jointbar' && (
|
||||
{!useTemplateMode && processType === 'slat_jointbar' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 가공상태</span>
|
||||
@@ -603,7 +801,7 @@ export function InspectionInputModal({
|
||||
)}
|
||||
|
||||
{/* ===== 절곡 검사 항목 ===== */}
|
||||
{processType === 'bending' && (
|
||||
{!useTemplateMode && processType === 'bending' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-sm font-bold">검모양 절곡상태</span>
|
||||
|
||||
@@ -513,4 +513,104 @@ export async function getWorkOrderInspectionData(
|
||||
errorMessage: '검사 데이터 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 검사 문서 템플릿 조회 (document_template 기반) =====
|
||||
export interface InspectionTemplateData {
|
||||
work_order_id: number;
|
||||
has_template: boolean;
|
||||
template?: {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string | null;
|
||||
sections: {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
items: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
is_required: boolean;
|
||||
sort_order: number;
|
||||
options: Record<string, unknown> | null;
|
||||
tolerance: {
|
||||
type: 'symmetric' | 'asymmetric' | 'range';
|
||||
value?: number;
|
||||
plus?: number;
|
||||
minus?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
} | null;
|
||||
standard_criteria: string | null;
|
||||
measurement_type: string | null;
|
||||
}[];
|
||||
}[];
|
||||
columns: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
sort_order: number;
|
||||
is_required: boolean;
|
||||
options: Record<string, unknown> | null;
|
||||
}[];
|
||||
approval_lines: {
|
||||
id: number;
|
||||
role_name: string;
|
||||
sort_order: number;
|
||||
is_required: boolean;
|
||||
}[];
|
||||
basic_fields: {
|
||||
id: number;
|
||||
name: string;
|
||||
field_key: string;
|
||||
field_type: string;
|
||||
is_required: boolean;
|
||||
sort_order: number;
|
||||
options: Record<string, unknown> | null;
|
||||
}[];
|
||||
};
|
||||
work_order_info?: {
|
||||
work_order_no: string;
|
||||
project_name: string | null;
|
||||
process_name: string | null;
|
||||
scheduled_date: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInspectionTemplate(
|
||||
workOrderId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: InspectionTemplateData;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<InspectionTemplateData>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-template`,
|
||||
errorMessage: '검사 템플릿 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 검사 문서 저장 (Document + DocumentData) =====
|
||||
export async function saveInspectionDocument(
|
||||
workOrderId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
data: Record<string, unknown>[];
|
||||
approvers?: { role_name: string; user_id?: number }[];
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: { document_id: number; document_no: string; status: string };
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<{ document_id: number; document_no: string; status: string }>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-document`,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '검사 문서 저장에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
@@ -33,7 +33,8 @@ 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 } from './actions';
|
||||
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate } from './actions';
|
||||
import type { InspectionTemplateData } from './actions';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
@@ -363,6 +364,8 @@ export default function WorkerScreen() {
|
||||
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
|
||||
// 공정의 중간검사 설정
|
||||
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
|
||||
// 문서 템플릿 데이터 (document_template 기반 동적 검사용)
|
||||
const [inspectionTemplateData, setInspectionTemplateData] = useState<InspectionTemplateData | undefined>();
|
||||
|
||||
// 중간검사 체크 상태 관리: { [itemId]: boolean }
|
||||
const [inspectionCheckedMap, setInspectionCheckedMap] = useState<Record<string, boolean>>({});
|
||||
@@ -842,8 +845,8 @@ export default function WorkerScreen() {
|
||||
};
|
||||
}, [filteredWorkOrders, workItems]);
|
||||
|
||||
// 중간검사 버튼 클릭 핸들러 - 바로 모달 열기
|
||||
const handleInspectionClick = useCallback((itemId: string) => {
|
||||
// 중간검사 버튼 클릭 핸들러 - 템플릿 로드 후 모달 열기
|
||||
const handleInspectionClick = useCallback(async (itemId: string) => {
|
||||
// 해당 아이템 찾기
|
||||
const item = workItems.find((w) => w.id === itemId);
|
||||
if (item) {
|
||||
@@ -867,6 +870,23 @@ export default function WorkerScreen() {
|
||||
createdAt: '',
|
||||
};
|
||||
setSelectedOrder(syntheticOrder);
|
||||
|
||||
// 실제 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]);
|
||||
@@ -922,6 +942,7 @@ export default function WorkerScreen() {
|
||||
const targetItem = workItems.find((w) => w.id === selectedOrder.id);
|
||||
if (targetItem?.apiItemId && targetItem?.workOrderId) {
|
||||
try {
|
||||
// 1. 기존: work_order_items.options에 저장
|
||||
const result = await saveItemInspection(
|
||||
targetItem.workOrderId,
|
||||
targetItem.apiItemId,
|
||||
@@ -933,6 +954,15 @@ export default function WorkerScreen() {
|
||||
} else {
|
||||
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 2. 추가: Document + DocumentData로 저장 (document_template 연결된 경우)
|
||||
try {
|
||||
await saveInspectionDocument(targetItem.workOrderId, {
|
||||
data: [data as unknown as Record<string, unknown>],
|
||||
});
|
||||
} catch {
|
||||
// Document 저장 실패는 무시 (template 미연결 시 404 가능)
|
||||
}
|
||||
} catch {
|
||||
toast.error('검사 데이터 저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -1275,6 +1305,7 @@ export default function WorkerScreen() {
|
||||
workItems={workItems}
|
||||
inspectionDataMap={inspectionDataMap}
|
||||
inspectionSetting={currentInspectionSetting}
|
||||
templateData={inspectionTemplateData}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
@@ -1298,6 +1329,7 @@ export default function WorkerScreen() {
|
||||
specification={workItems[0]?.slatJointBarInfo?.specification || workItems[0]?.wipInfo?.specification || ''}
|
||||
initialData={selectedOrder ? inspectionDataMap.get(selectedOrder.id) : undefined}
|
||||
onComplete={handleInspectionComplete}
|
||||
templateData={inspectionTemplateData}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface ProcessStep {
|
||||
isRequired: boolean; // 필수여부
|
||||
needsApproval: boolean; // 승인여부
|
||||
needsInspection: boolean; // 검사여부
|
||||
documentTemplateId?: number; // 문서양식 ID (검사 시 사용할 템플릿)
|
||||
documentTemplateName?: string; // 문서양식명 (표시용)
|
||||
isActive: boolean; // 사용여부
|
||||
order: number; // 순서 (드래그&드롭)
|
||||
// 연결 정보
|
||||
|
||||
Reference in New Issue
Block a user