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:
2026-02-10 08:36:12 +09:00
parent 14b84cc08d
commit 12a423051a
7 changed files with 574 additions and 13 deletions

View File

@@ -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>