feat(WEB): 제품검사 모달 양식(template) 기반 전환 (5.2.4)
FQC 문서 시스템 연동으로 하드코딩된 검사 모달을 양식 기반으로 전환. FQC 문서가 있으면 template 기반 렌더링, 없으면 기존 legacy 모드 유지. - fqcActions.ts: FQC 문서 Server Actions (조회/생성/저장) + 타입/변환 - FqcDocumentContent.tsx: 양식 기반 문서 렌더링 (readonly/edit, forwardRef) - InspectionReportModal: fqcDocumentMap prop으로 FQC/legacy 모드 자동 전환 - ProductInspectionInputModal: fqcDocumentId prop으로 FQC/legacy 모드 자동 전환 - InspectionDetail: FQC 매핑 로드 로직 + 모달 prop 전달 - OrderSettingItem에 orderId 추가 (FQC 활성화 트리거)
This commit is contained in:
@@ -62,6 +62,7 @@ import {
|
||||
updateInspection,
|
||||
completeInspection,
|
||||
} from './actions';
|
||||
import { getFqcStatus } from './fqcActions';
|
||||
import {
|
||||
statusColorMap,
|
||||
isOrderSpecSame,
|
||||
@@ -138,6 +139,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
|
||||
const [selectedOrderItem, setSelectedOrderItem] = useState<OrderSettingItem | null>(null);
|
||||
|
||||
// FQC 문서 매핑 (orderItemId → documentId)
|
||||
const [fqcDocumentMap, setFqcDocumentMap] = useState<Record<string, number>>({});
|
||||
|
||||
// ===== API 데이터 로드 =====
|
||||
const loadInspection = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -175,6 +179,39 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
loadInspection();
|
||||
}, [loadInspection]);
|
||||
|
||||
// ===== FQC 문서 매핑 로드 =====
|
||||
useEffect(() => {
|
||||
if (!inspection) return;
|
||||
|
||||
// orderItems에서 고유 orderId 추출 (API에서 제공되는 경우만)
|
||||
const orderIds = new Set<number>();
|
||||
inspection.orderItems.forEach((item) => {
|
||||
if (item.orderId) orderIds.add(item.orderId);
|
||||
});
|
||||
|
||||
if (orderIds.size === 0) return; // orderId 없으면 legacy 모드 유지
|
||||
|
||||
// 각 orderId별 FQC 상태 조회 후 매핑 구축
|
||||
const loadFqcMap = async () => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const orderId of orderIds) {
|
||||
const result = await getFqcStatus(orderId);
|
||||
if (result.success && result.data) {
|
||||
result.data.items.forEach((item) => {
|
||||
if (item.documentId) {
|
||||
map[String(item.orderItemId)] = item.documentId;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Object.keys(map).length > 0) {
|
||||
setFqcDocumentMap(map);
|
||||
}
|
||||
};
|
||||
|
||||
loadFqcMap();
|
||||
}, [inspection]);
|
||||
|
||||
// ===== 네비게이션 =====
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/quality/inspections/${id}?mode=edit`);
|
||||
@@ -1108,6 +1145,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null}
|
||||
inspection={inspection}
|
||||
orderItems={isEditMode ? formData.orderItems : inspection?.orderItems}
|
||||
fqcDocumentMap={Object.keys(fqcDocumentMap).length > 0 ? fqcDocumentMap : undefined}
|
||||
/>
|
||||
|
||||
{/* 제품검사 입력 모달 */}
|
||||
@@ -1119,6 +1157,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
|
||||
initialData={selectedOrderItem?.inspectionData}
|
||||
onComplete={handleInspectionComplete}
|
||||
fqcDocumentId={selectedOrderItem ? fqcDocumentMap[selectedOrderItem.id] ?? null : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
/**
|
||||
* 제품검사 입력 모달
|
||||
*
|
||||
* 수주 설정 정보 아코디언의 "검사하기" 버튼에서 열림
|
||||
* 검사 결과를 입력하면 해당 수주 항목의 inspectionData에 저장
|
||||
*
|
||||
* 검사 항목:
|
||||
* - 제품 사진 (2장)
|
||||
* - 겉모양 검사: 가공상태, 재봉상태, 조립상태, 연기차단재, 하단마감재, 모터
|
||||
* - 재질/치수 검사: 재질, 길이, 높이, 가이드레일 출간격, 하단마감재 간격
|
||||
* - 시험 검사: 내화시험, 차연시험, 개폐시험, 내충격시험
|
||||
* - 특이사항
|
||||
* 양식 기반 전환 (5.2.4):
|
||||
* - FQC 모드: documents API template 기반으로 검사항목 렌더링
|
||||
* → 11개 설치 후 최종검사 항목 (template_id: 65)
|
||||
* → 모두 visual/checkbox (적합/부적합)
|
||||
* → saveFqcDocument로 서버에 직접 저장
|
||||
* - Legacy 모드: 기존 하드코딩 필드 (fallback)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,13 +19,15 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Camera, X, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions';
|
||||
import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions';
|
||||
import type { ProductInspectionData } from './types';
|
||||
|
||||
type JudgmentValue = '적합' | '부적합' | null;
|
||||
|
||||
interface ProductInspectionInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -37,228 +36,10 @@ interface ProductInspectionInputModalProps {
|
||||
specification?: string;
|
||||
initialData?: ProductInspectionData;
|
||||
onComplete: (data: ProductInspectionData) => void;
|
||||
/** FQC 문서 ID (있으면 양식 기반 모드) */
|
||||
fqcDocumentId?: number | null;
|
||||
}
|
||||
|
||||
// 적합/부적합 버튼 컴포넌트
|
||||
function PassFailToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: 'pass' | 'fail' | null;
|
||||
onChange: (v: 'pass' | 'fail') => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange('pass')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'pass'
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && onChange('fail')}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'fail'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 사진 업로드 컴포넌트
|
||||
function ImageUploader({
|
||||
images,
|
||||
onImagesChange,
|
||||
maxImages = 2,
|
||||
}: {
|
||||
images: string[];
|
||||
onImagesChange: (images: string[]) => void;
|
||||
maxImages?: number;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
if (images.length >= maxImages) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64 = reader.result as string;
|
||||
onImagesChange([...images, base64]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onImagesChange(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">제품 사진 ({images.length}/{maxImages})</Label>
|
||||
<div className="flex gap-3">
|
||||
{images.map((img, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative w-24 h-24 rounded-lg overflow-hidden border"
|
||||
>
|
||||
<img src={img} alt={`제품 사진 ${index + 1}`} className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(index)}
|
||||
className="absolute top-1 right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<X className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < maxImages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-24 h-24 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center text-muted-foreground hover:border-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<Camera className="w-6 h-6 mb-1" />
|
||||
<span className="text-xs">사진 추가</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 검사 항목 그룹 컴포넌트
|
||||
function InspectionGroup({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-orange-500 border-b pb-2">
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-4 pl-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 검사 항목 행 컴포넌트
|
||||
function InspectionRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground text-sm">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 측정값 입력 + 판정 컴포넌트
|
||||
function MeasurementInput({
|
||||
value,
|
||||
judgment,
|
||||
onValueChange,
|
||||
onJudgmentChange,
|
||||
placeholder = '측정값',
|
||||
unit = 'mm',
|
||||
}: {
|
||||
value: number | null;
|
||||
judgment: 'pass' | 'fail' | null;
|
||||
onValueChange: (v: number | null) => void;
|
||||
onJudgmentChange: (v: 'pass' | 'fail') => void;
|
||||
placeholder?: string;
|
||||
unit?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onValueChange(e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||
placeholder={placeholder}
|
||||
className="w-24 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">{unit}</span>
|
||||
</div>
|
||||
<PassFailToggle value={judgment} onChange={onJudgmentChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INITIAL_DATA: ProductInspectionData = {
|
||||
productName: '',
|
||||
specification: '',
|
||||
productImages: [],
|
||||
// 겉모양 검사 (기본값: 적합)
|
||||
appearanceProcessing: 'pass',
|
||||
appearanceSewing: 'pass',
|
||||
appearanceAssembly: 'pass',
|
||||
appearanceSmokeBarrier: 'pass',
|
||||
appearanceBottomFinish: 'pass',
|
||||
motor: 'pass',
|
||||
// 재질/치수 검사 (기본값: 적합)
|
||||
material: 'pass',
|
||||
lengthValue: null,
|
||||
lengthJudgment: 'pass',
|
||||
heightValue: null,
|
||||
heightJudgment: 'pass',
|
||||
guideRailGapValue: null,
|
||||
guideRailGap: 'pass',
|
||||
bottomFinishGapValue: null,
|
||||
bottomFinishGap: 'pass',
|
||||
// 시험 검사 (기본값: 적합)
|
||||
fireResistanceTest: 'pass',
|
||||
smokeLeakageTest: 'pass',
|
||||
openCloseTest: 'pass',
|
||||
impactTest: 'pass',
|
||||
// 특이사항
|
||||
hasSpecialNotes: false,
|
||||
specialNotes: '',
|
||||
};
|
||||
|
||||
export function ProductInspectionInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -267,42 +48,217 @@ export function ProductInspectionInputModal({
|
||||
specification = '',
|
||||
initialData,
|
||||
onComplete,
|
||||
fqcDocumentId,
|
||||
}: ProductInspectionInputModalProps) {
|
||||
const [formData, setFormData] = useState<ProductInspectionData>({
|
||||
...INITIAL_DATA,
|
||||
productName,
|
||||
specification,
|
||||
});
|
||||
// FQC 모드 상태
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
const [fqcDocData, setFqcDocData] = useState<FqcDocumentData[]>([]);
|
||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 판정 상태 (FQC 모드)
|
||||
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>({});
|
||||
|
||||
const useFqcMode = !!fqcDocumentId;
|
||||
|
||||
// FQC 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialData) {
|
||||
setFormData(initialData);
|
||||
} else {
|
||||
setFormData({
|
||||
...INITIAL_DATA,
|
||||
if (!open || !useFqcMode) return;
|
||||
|
||||
setIsLoadingFqc(true);
|
||||
|
||||
Promise.all([
|
||||
getFqcTemplate(),
|
||||
getFqcDocument(fqcDocumentId!),
|
||||
])
|
||||
.then(([templateResult, docResult]) => {
|
||||
if (templateResult.success && templateResult.data) {
|
||||
setFqcTemplate(templateResult.data);
|
||||
}
|
||||
if (docResult.success && docResult.data) {
|
||||
setFqcDocData(docResult.data.data);
|
||||
// 기존 판정 데이터 복원
|
||||
const dataSection = docResult.data.template.sections.find(s => s.items.length > 0);
|
||||
const judgmentCol = docResult.data.template.columns.find(c => c.label === '판정');
|
||||
if (dataSection && judgmentCol) {
|
||||
const map: Record<number, JudgmentValue> = {};
|
||||
for (const d of docResult.data.data) {
|
||||
if (d.sectionId === dataSection.id && d.columnId === judgmentCol.id && d.fieldKey === 'result') {
|
||||
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
|
||||
}
|
||||
}
|
||||
setJudgments(map);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoadingFqc(false));
|
||||
}, [open, useFqcMode, fqcDocumentId]);
|
||||
|
||||
// 모달 닫힐 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setJudgments({});
|
||||
setFqcDocData([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 판정 토글
|
||||
const toggleJudgment = useCallback((rowIndex: number, value: JudgmentValue) => {
|
||||
setJudgments(prev => ({
|
||||
...prev,
|
||||
[rowIndex]: prev[rowIndex] === value ? null : value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// FQC 종합판정 계산
|
||||
const overallJudgment = useCallback((): '합격' | '불합격' | null => {
|
||||
if (!fqcTemplate) return null;
|
||||
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
|
||||
if (!dataSection || dataSection.items.length === 0) return null;
|
||||
|
||||
const values = dataSection.items.map((_, idx) => judgments[idx]);
|
||||
const hasValue = values.some(v => v !== undefined && v !== null);
|
||||
if (!hasValue) return null;
|
||||
if (values.some(v => v === '부적합')) return '불합격';
|
||||
if (values.every(v => v === '적합')) return '합격';
|
||||
return null;
|
||||
}, [fqcTemplate, judgments]);
|
||||
|
||||
// FQC 검사 완료 (서버 저장)
|
||||
const handleFqcComplete = useCallback(async () => {
|
||||
if (!fqcTemplate || !fqcDocumentId) return;
|
||||
|
||||
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
|
||||
const judgmentCol = fqcTemplate.columns.find(c => c.label === '판정');
|
||||
if (!dataSection || !judgmentCol) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// document_data 형식으로 변환
|
||||
const records: Array<{
|
||||
section_id: number | null;
|
||||
column_id: number | null;
|
||||
row_index: number;
|
||||
field_key: string;
|
||||
field_value: string | null;
|
||||
}> = [];
|
||||
|
||||
dataSection.items.forEach((_, idx) => {
|
||||
const value = judgments[idx];
|
||||
if (value) {
|
||||
records.push({
|
||||
section_id: dataSection.id,
|
||||
column_id: judgmentCol.id,
|
||||
row_index: idx,
|
||||
field_key: 'result',
|
||||
field_value: value,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 종합판정
|
||||
records.push({
|
||||
section_id: null,
|
||||
column_id: null,
|
||||
row_index: 0,
|
||||
field_key: 'footer_judgement',
|
||||
field_value: overallJudgment(),
|
||||
});
|
||||
|
||||
const result = await saveFqcDocument({
|
||||
documentId: fqcDocumentId,
|
||||
data: records,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('검사 데이터가 저장되었습니다.');
|
||||
|
||||
// onComplete callback으로 로컬 상태도 업데이트
|
||||
// Legacy 타입 호환: FQC 판정 데이터를 ProductInspectionData 형태로 변환
|
||||
const legacyData: ProductInspectionData = {
|
||||
productName,
|
||||
specification,
|
||||
});
|
||||
productImages: [],
|
||||
// FQC 모드에서는 모든 항목을 적합/부적합으로만 판정
|
||||
// 11개 항목을 legacy 필드에 매핑 (가능한 만큼)
|
||||
appearanceProcessing: judgments[0] === '적합' ? 'pass' : judgments[0] === '부적합' ? 'fail' : null,
|
||||
appearanceSewing: judgments[1] === '적합' ? 'pass' : judgments[1] === '부적합' ? 'fail' : null,
|
||||
appearanceAssembly: judgments[2] === '적합' ? 'pass' : judgments[2] === '부적합' ? 'fail' : null,
|
||||
appearanceSmokeBarrier: judgments[3] === '적합' ? 'pass' : judgments[3] === '부적합' ? 'fail' : null,
|
||||
appearanceBottomFinish: judgments[4] === '적합' ? 'pass' : judgments[4] === '부적합' ? 'fail' : null,
|
||||
motor: judgments[5] === '적합' ? 'pass' : judgments[5] === '부적합' ? 'fail' : null,
|
||||
material: judgments[6] === '적합' ? 'pass' : judgments[6] === '부적합' ? 'fail' : null,
|
||||
lengthValue: null,
|
||||
lengthJudgment: judgments[7] === '적합' ? 'pass' : judgments[7] === '부적합' ? 'fail' : null,
|
||||
heightValue: null,
|
||||
heightJudgment: judgments[8] === '적합' ? 'pass' : judgments[8] === '부적합' ? 'fail' : null,
|
||||
guideRailGapValue: null,
|
||||
guideRailGap: judgments[9] === '적합' ? 'pass' : judgments[9] === '부적합' ? 'fail' : null,
|
||||
bottomFinishGapValue: null,
|
||||
bottomFinishGap: judgments[10] === '적합' ? 'pass' : judgments[10] === '부적합' ? 'fail' : null,
|
||||
fireResistanceTest: null,
|
||||
smokeLeakageTest: null,
|
||||
openCloseTest: null,
|
||||
impactTest: null,
|
||||
hasSpecialNotes: false,
|
||||
specialNotes: '',
|
||||
};
|
||||
|
||||
onComplete(legacyData);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [open, productName, specification, initialData]);
|
||||
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, onComplete, onOpenChange]);
|
||||
|
||||
const updateField = <K extends keyof ProductInspectionData>(
|
||||
key: K,
|
||||
value: ProductInspectionData[K]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete(formData);
|
||||
// Legacy 완료 핸들러
|
||||
const handleLegacyComplete = useCallback(() => {
|
||||
if (!legacyFormData) return;
|
||||
onComplete(legacyFormData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
}, [onComplete, onOpenChange]);
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
// ===== Legacy 모드 상태 =====
|
||||
const [legacyFormData, setLegacyFormData] = useState<ProductInspectionData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !useFqcMode) {
|
||||
setLegacyFormData(initialData || {
|
||||
productName,
|
||||
specification,
|
||||
productImages: [],
|
||||
appearanceProcessing: 'pass',
|
||||
appearanceSewing: 'pass',
|
||||
appearanceAssembly: 'pass',
|
||||
appearanceSmokeBarrier: 'pass',
|
||||
appearanceBottomFinish: 'pass',
|
||||
motor: 'pass',
|
||||
material: 'pass',
|
||||
lengthValue: null,
|
||||
lengthJudgment: 'pass',
|
||||
heightValue: null,
|
||||
heightJudgment: 'pass',
|
||||
guideRailGapValue: null,
|
||||
guideRailGap: 'pass',
|
||||
bottomFinishGapValue: null,
|
||||
bottomFinishGap: 'pass',
|
||||
fireResistanceTest: 'pass',
|
||||
smokeLeakageTest: 'pass',
|
||||
openCloseTest: 'pass',
|
||||
impactTest: 'pass',
|
||||
hasSpecialNotes: false,
|
||||
specialNotes: '',
|
||||
});
|
||||
}
|
||||
}, [open, useFqcMode, initialData, productName, specification]);
|
||||
|
||||
// FQC 데이터 섹션
|
||||
const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0);
|
||||
const sortedItems = dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) || [];
|
||||
const currentOverallJudgment = overallJudgment();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -310,176 +266,247 @@ export function ProductInspectionInputModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
# 제품검사
|
||||
{useFqcMode && (
|
||||
<span className="ml-2 text-xs font-normal text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
|
||||
양식 기반
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
|
||||
{/* 제품명 / 규격 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">제품명</Label>
|
||||
<Input
|
||||
value={formData.productName}
|
||||
readOnly
|
||||
className=""
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">제품명</span>
|
||||
<div className="font-medium">{productName || '-'}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">규격</Label>
|
||||
<Input
|
||||
value={formData.specification}
|
||||
readOnly
|
||||
className=""
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">규격</span>
|
||||
<div className="font-medium">{specification || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제품 사진 */}
|
||||
<ImageUploader
|
||||
images={formData.productImages}
|
||||
onImagesChange={(images) => updateField('productImages', images)}
|
||||
maxImages={2}
|
||||
/>
|
||||
{useFqcMode ? (
|
||||
// ===== FQC 양식 기반 모드 =====
|
||||
isLoadingFqc ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground text-sm">양식 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 검사항목 목록 (template 기반) */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-blue-600 border-b pb-2">
|
||||
{dataSection?.title || dataSection?.name || '검사항목'}
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
({sortedItems.length}항목)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 겉모양 검사 */}
|
||||
<InspectionGroup title="겉모양 검사">
|
||||
<InspectionRow label="가공상태">
|
||||
<PassFailToggle
|
||||
value={formData.appearanceProcessing}
|
||||
onChange={(v) => updateField('appearanceProcessing', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="재봉상태">
|
||||
<PassFailToggle
|
||||
value={formData.appearanceSewing}
|
||||
onChange={(v) => updateField('appearanceSewing', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="조립상태">
|
||||
<PassFailToggle
|
||||
value={formData.appearanceAssembly}
|
||||
onChange={(v) => updateField('appearanceAssembly', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="연기차단재">
|
||||
<PassFailToggle
|
||||
value={formData.appearanceSmokeBarrier}
|
||||
onChange={(v) => updateField('appearanceSmokeBarrier', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="하단마감재">
|
||||
<PassFailToggle
|
||||
value={formData.appearanceBottomFinish}
|
||||
onChange={(v) => updateField('appearanceBottomFinish', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="모터">
|
||||
<PassFailToggle
|
||||
value={formData.motor}
|
||||
onChange={(v) => updateField('motor', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
</InspectionGroup>
|
||||
<div className="space-y-2">
|
||||
{sortedItems.map((item, idx) => (
|
||||
<FqcInspectionRow
|
||||
key={item.id}
|
||||
index={idx}
|
||||
item={item}
|
||||
judgment={judgments[idx] ?? null}
|
||||
onToggle={toggleJudgment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 재질/치수 검사 */}
|
||||
<InspectionGroup title="재질/치수 검사">
|
||||
<InspectionRow label="재질">
|
||||
<PassFailToggle
|
||||
value={formData.material}
|
||||
onChange={(v) => updateField('material', v)}
|
||||
{/* 종합판정 */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
|
||||
<span className="font-medium">종합판정</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-bold text-sm',
|
||||
currentOverallJudgment === '합격'
|
||||
? 'text-blue-600'
|
||||
: currentOverallJudgment === '불합격'
|
||||
? 'text-red-600'
|
||||
: 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{currentOverallJudgment || '미판정'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
// ===== Legacy 하드코딩 모드 (fallback) =====
|
||||
legacyFormData && (
|
||||
<LegacyInspectionForm
|
||||
data={legacyFormData}
|
||||
onChange={setLegacyFormData}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="길이">
|
||||
<MeasurementInput
|
||||
value={formData.lengthValue}
|
||||
judgment={formData.lengthJudgment}
|
||||
onValueChange={(v) => updateField('lengthValue', v)}
|
||||
onJudgmentChange={(v) => updateField('lengthJudgment', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="높이">
|
||||
<MeasurementInput
|
||||
value={formData.heightValue}
|
||||
judgment={formData.heightJudgment}
|
||||
onValueChange={(v) => updateField('heightValue', v)}
|
||||
onJudgmentChange={(v) => updateField('heightJudgment', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="가이드레일 홈간격">
|
||||
<MeasurementInput
|
||||
value={formData.guideRailGapValue}
|
||||
judgment={formData.guideRailGap}
|
||||
onValueChange={(v) => updateField('guideRailGapValue', v)}
|
||||
onJudgmentChange={(v) => updateField('guideRailGap', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="하단마감재 간격">
|
||||
<MeasurementInput
|
||||
value={formData.bottomFinishGapValue}
|
||||
judgment={formData.bottomFinishGap}
|
||||
onValueChange={(v) => updateField('bottomFinishGapValue', v)}
|
||||
onJudgmentChange={(v) => updateField('bottomFinishGap', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
</InspectionGroup>
|
||||
|
||||
{/* 시험 검사 */}
|
||||
<InspectionGroup title="시험 검사">
|
||||
<InspectionRow label="내화시험">
|
||||
<PassFailToggle
|
||||
value={formData.fireResistanceTest}
|
||||
onChange={(v) => updateField('fireResistanceTest', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="차연시험">
|
||||
<PassFailToggle
|
||||
value={formData.smokeLeakageTest}
|
||||
onChange={(v) => updateField('smokeLeakageTest', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="개폐시험">
|
||||
<PassFailToggle
|
||||
value={formData.openCloseTest}
|
||||
onChange={(v) => updateField('openCloseTest', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
<InspectionRow label="내충격시험">
|
||||
<PassFailToggle
|
||||
value={formData.impactTest}
|
||||
onChange={(v) => updateField('impactTest', v)}
|
||||
/>
|
||||
</InspectionRow>
|
||||
</InspectionGroup>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">특이사항</Label>
|
||||
<Textarea
|
||||
value={formData.specialNotes}
|
||||
onChange={(e) => updateField('specialNotes', e.target.value)}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex gap-3 mt-6 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1">
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
|
||||
onClick={useFqcMode ? handleFqcComplete : handleLegacyComplete}
|
||||
disabled={isSaving || isLoadingFqc}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
검사 완료
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'검사 완료'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== FQC 검사항목 행 =====
|
||||
|
||||
function FqcInspectionRow({
|
||||
index,
|
||||
item,
|
||||
judgment,
|
||||
onToggle,
|
||||
}: {
|
||||
index: number;
|
||||
item: FqcTemplateItem;
|
||||
judgment: JudgmentValue;
|
||||
onToggle: (rowIndex: number, value: JudgmentValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-5 text-right">{index + 1}</span>
|
||||
<span className="text-sm">{item.itemName}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(index, '적합')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded text-xs font-medium transition-colors',
|
||||
judgment === '적합'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-blue-50 hover:text-blue-600'
|
||||
)}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(index, '부적합')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded text-xs font-medium transition-colors',
|
||||
judgment === '부적합'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-600'
|
||||
)}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Legacy 폼 (기존 하드코딩 검사항목) =====
|
||||
|
||||
function LegacyInspectionForm({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ProductInspectionData;
|
||||
onChange: (data: ProductInspectionData) => void;
|
||||
}) {
|
||||
const update = <K extends keyof ProductInspectionData>(key: K, value: ProductInspectionData[K]) => {
|
||||
onChange({ ...data, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 겉모양 검사 */}
|
||||
<LegacyGroup title="겉모양 검사">
|
||||
<LegacyRow label="가공상태" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
|
||||
<LegacyRow label="재봉상태" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
|
||||
<LegacyRow label="조립상태" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
|
||||
<LegacyRow label="연기차단재" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
|
||||
<LegacyRow label="하단마감재" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', v)} />
|
||||
<LegacyRow label="모터" value={data.motor} onChange={v => update('motor', v)} />
|
||||
</LegacyGroup>
|
||||
{/* 재질/치수 검사 */}
|
||||
<LegacyGroup title="재질/치수 검사">
|
||||
<LegacyRow label="재질" value={data.material} onChange={v => update('material', v)} />
|
||||
<LegacyRow label="길이" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
|
||||
<LegacyRow label="높이" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
|
||||
<LegacyRow label="가이드레일 홈간격" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
|
||||
<LegacyRow label="하단마감재 간격" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
|
||||
</LegacyGroup>
|
||||
{/* 시험 검사 */}
|
||||
<LegacyGroup title="시험 검사">
|
||||
<LegacyRow label="내화시험" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
|
||||
<LegacyRow label="차연시험" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
|
||||
<LegacyRow label="개폐시험" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
|
||||
<LegacyRow label="내충격시험" value={data.impactTest} onChange={v => update('impactTest', v)} />
|
||||
</LegacyGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-orange-500 border-b pb-2">{title}</div>
|
||||
<div className="space-y-4 pl-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: 'pass' | 'fail' | null;
|
||||
onChange: (v: 'pass' | 'fail') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('pass')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'pass' ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
)}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('fail')}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
value === 'fail' ? 'bg-gray-700 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
)}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FQC 제품검사 성적서 - 양식 기반 렌더링
|
||||
*
|
||||
* documents 시스템의 template 구조를 기반으로 렌더링:
|
||||
* - 결재라인 (3인: 작성/검토/승인)
|
||||
* - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자)
|
||||
* - 검사항목 테이블 (4컬럼: NO, 검사항목, 검사기준, 판정)
|
||||
* - 11개 설치 후 최종검사 항목 (모두 visual/checkbox → 적합/부적합)
|
||||
* - 종합판정 (자동 계산)
|
||||
*
|
||||
* readonly=true → 조회 모드 (InspectionReportModal에서 사용)
|
||||
* readonly=false → 편집 모드 (ProductInspectionInputModal 대체)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
|
||||
import { ConstructionApprovalTable } from '@/components/document-system';
|
||||
import type {
|
||||
FqcTemplate,
|
||||
FqcDocumentData,
|
||||
FqcTemplateItem,
|
||||
} from '../fqcActions';
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
export interface FqcDocumentContentRef {
|
||||
/** 현재 입력 데이터를 document_data 형식으로 반환 */
|
||||
getInspectionData: () => {
|
||||
records: Array<{
|
||||
section_id: number | null;
|
||||
column_id: number | null;
|
||||
row_index: number;
|
||||
field_key: string;
|
||||
field_value: string | null;
|
||||
}>;
|
||||
overallJudgment: '합격' | '불합격' | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface FqcDocumentContentProps {
|
||||
/** 양식 정의 (template) */
|
||||
template: FqcTemplate;
|
||||
/** 기존 문서 데이터 (저장된 검사 결과) */
|
||||
documentData?: FqcDocumentData[];
|
||||
/** 문서번호 */
|
||||
documentNo?: string;
|
||||
/** 작성일자 */
|
||||
createdDate?: string;
|
||||
/** 기본필드 오버라이드 (API에서 auto-fill된 값) */
|
||||
basicFieldValues?: Record<string, string>;
|
||||
/** 읽기전용 모드 */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
type JudgmentValue = '적합' | '부적합' | null;
|
||||
|
||||
// ===== Component =====
|
||||
|
||||
export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentContentProps>(
|
||||
function FqcDocumentContent(
|
||||
{
|
||||
template,
|
||||
documentData = [],
|
||||
documentNo = '',
|
||||
createdDate = '',
|
||||
basicFieldValues = {},
|
||||
readonly = true,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
// 데이터 섹션 찾기 (items가 있는 섹션)
|
||||
const dataSection = useMemo(
|
||||
() => template.sections.find(s => s.items.length > 0),
|
||||
[template.sections]
|
||||
);
|
||||
|
||||
// 판정 컬럼 ID 찾기
|
||||
const judgmentColumnId = useMemo(
|
||||
() => template.columns.find(c => c.label === '판정')?.id ?? null,
|
||||
[template.columns]
|
||||
);
|
||||
|
||||
// 기존 문서 데이터에서 판정값 추출
|
||||
const initialJudgments = useMemo(() => {
|
||||
const map: Record<number, JudgmentValue> = {};
|
||||
if (!dataSection || !judgmentColumnId) return map;
|
||||
|
||||
for (const d of documentData) {
|
||||
if (
|
||||
d.sectionId === dataSection.id &&
|
||||
d.columnId === judgmentColumnId &&
|
||||
d.fieldKey === 'result'
|
||||
) {
|
||||
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [documentData, dataSection, judgmentColumnId]);
|
||||
|
||||
// 판정 상태 (편집 모드용)
|
||||
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>(initialJudgments);
|
||||
|
||||
// 판정 토글
|
||||
const toggleJudgment = useCallback(
|
||||
(rowIndex: number, value: JudgmentValue) => {
|
||||
if (readonly) return;
|
||||
setJudgments(prev => ({
|
||||
...prev,
|
||||
[rowIndex]: prev[rowIndex] === value ? null : value,
|
||||
}));
|
||||
},
|
||||
[readonly]
|
||||
);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
const overallJudgment = useMemo(() => {
|
||||
if (!dataSection) return null;
|
||||
const items = dataSection.items;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const values = items.map((_, idx) => judgments[idx]);
|
||||
const hasValue = values.some(v => v !== undefined && v !== null);
|
||||
if (!hasValue) return null;
|
||||
if (values.some(v => v === '부적합')) return '불합격' as const;
|
||||
if (values.every(v => v === '적합')) return '합격' as const;
|
||||
return null;
|
||||
}, [dataSection, judgments]);
|
||||
|
||||
// 기본필드 값 조회
|
||||
const getBasicFieldValue = useCallback(
|
||||
(fieldKey: string): string => {
|
||||
// 먼저 API에서 auto-fill된 값, 없으면 documentData에서
|
||||
const bfKey = `bf_${template.basicFields.find(f => f.fieldKey === fieldKey)?.label ?? fieldKey}`;
|
||||
if (basicFieldValues[bfKey]) return basicFieldValues[bfKey];
|
||||
|
||||
// documentData에서 기본필드 값 찾기
|
||||
const found = documentData.find(d => d.fieldKey === bfKey && !d.sectionId);
|
||||
return found?.fieldValue ?? '';
|
||||
},
|
||||
[basicFieldValues, documentData, template.basicFields]
|
||||
);
|
||||
|
||||
// ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용)
|
||||
useImperativeHandle(ref, () => ({
|
||||
getInspectionData: () => {
|
||||
const records: Array<{
|
||||
section_id: number | null;
|
||||
column_id: number | null;
|
||||
row_index: number;
|
||||
field_key: string;
|
||||
field_value: string | null;
|
||||
}> = [];
|
||||
|
||||
if (dataSection && judgmentColumnId) {
|
||||
dataSection.items.forEach((_, idx) => {
|
||||
const value = judgments[idx];
|
||||
if (value) {
|
||||
records.push({
|
||||
section_id: dataSection.id,
|
||||
column_id: judgmentColumnId,
|
||||
row_index: idx,
|
||||
field_key: 'result',
|
||||
field_value: value,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 종합판정
|
||||
records.push({
|
||||
section_id: null,
|
||||
column_id: null,
|
||||
row_index: 0,
|
||||
field_key: 'footer_judgement',
|
||||
field_value: overallJudgment,
|
||||
});
|
||||
|
||||
return { records, overallJudgment };
|
||||
},
|
||||
}));
|
||||
|
||||
// 결재라인 데이터 매핑
|
||||
const approvers = useMemo(() => {
|
||||
const lines = template.approvalLines.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
return {
|
||||
writer: lines[0] ? { name: lines[0].name, department: lines[0].department } : undefined,
|
||||
approver1: lines[1] ? { name: lines[1].name, department: lines[1].department } : undefined,
|
||||
approver2: lines[2] ? { name: lines[2].name, department: lines[2].department } : undefined,
|
||||
};
|
||||
}, [template.approvalLines]);
|
||||
|
||||
// 기본필드 라벨-값 쌍
|
||||
const basicFieldPairs = useMemo(() => {
|
||||
const sorted = [...template.basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
const pairs: Array<{ label: string; value: string }> = [];
|
||||
for (const field of sorted) {
|
||||
const bfKey = `bf_${field.label}`;
|
||||
const value = basicFieldValues[bfKey]
|
||||
|| documentData.find(d => d.fieldKey === bfKey && !d.sectionId)?.fieldValue
|
||||
|| field.defaultValue
|
||||
|| '';
|
||||
pairs.push({ label: field.label, value });
|
||||
}
|
||||
return pairs;
|
||||
}, [template.basicFields, basicFieldValues, documentData]);
|
||||
|
||||
// 이미지 섹션 (items가 없는 섹션)
|
||||
const imageSections = useMemo(
|
||||
() => template.sections.filter(s => s.items.length === 0 && s.imagePath),
|
||||
[template.sections]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full text-[11px]">
|
||||
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">
|
||||
{template.title || template.name || '제 품 검 사 성 적 서'}
|
||||
</h1>
|
||||
<div className="text-[10px] space-y-1">
|
||||
<div className="flex gap-4">
|
||||
<span>문서번호: <strong>{documentNo}</strong></span>
|
||||
<span>작성일자: <strong>{createdDate}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructionApprovalTable approvers={approvers} />
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">
|
||||
기본 정보
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{/* 2열씩 표시 */}
|
||||
{Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => {
|
||||
const left = basicFieldPairs[rowIdx * 2];
|
||||
const right = basicFieldPairs[rowIdx * 2 + 1];
|
||||
const isLast = rowIdx === Math.ceil(basicFieldPairs.length / 2) - 1;
|
||||
return (
|
||||
<tr key={rowIdx} className={isLast ? '' : 'border-b border-gray-300'}>
|
||||
{left && (
|
||||
<>
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">
|
||||
{left.label}
|
||||
</td>
|
||||
<td className="px-2 py-1 border-r border-gray-300">
|
||||
{left.value || '-'}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{right ? (
|
||||
<>
|
||||
<td className="bg-gray-100 px-2 py-1 w-28 font-medium border-r border-gray-300">
|
||||
{right.label}
|
||||
</td>
|
||||
<td className="px-2 py-1">{right.value || '-'}</td>
|
||||
</>
|
||||
) : (
|
||||
<td colSpan={2} />
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 이미지 섹션 (검사 기준서 등) */}
|
||||
{imageSections.map(section => (
|
||||
<div key={section.id} className="border border-gray-400 mb-4">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">
|
||||
{section.title || section.name}
|
||||
</div>
|
||||
<div className="p-4 flex items-center justify-center min-h-[100px]">
|
||||
{section.imagePath ? (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/storage/${section.imagePath}`}
|
||||
alt={section.name}
|
||||
className="max-h-[300px] max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400 text-center">
|
||||
<div className="border-2 border-dashed border-gray-300 p-8 rounded">
|
||||
이미지 없음
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 검사항목 테이블 */}
|
||||
{dataSection && (
|
||||
<table className="w-full border-collapse border border-gray-400 mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
{template.columns
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(col => (
|
||||
<th
|
||||
key={col.id}
|
||||
className="border border-gray-400 px-2 py-1 text-center"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataSection.items
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((item, idx) => (
|
||||
<InspectionRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
rowIndex={idx}
|
||||
judgment={judgments[idx] ?? null}
|
||||
onToggleJudgment={toggleJudgment}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
|
||||
{dataSection.items.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={template.columns.length}
|
||||
className="border border-gray-400 px-2 py-4 text-center text-gray-400"
|
||||
>
|
||||
검사항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* 종합판정 */}
|
||||
<tr>
|
||||
<td
|
||||
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
|
||||
colSpan={template.columns.length - 1}
|
||||
>
|
||||
종합판정
|
||||
</td>
|
||||
<td
|
||||
className={`border border-gray-400 px-2 py-2 text-center font-bold text-sm ${
|
||||
overallJudgment === '합격'
|
||||
? 'text-blue-600'
|
||||
: overallJudgment === '불합격'
|
||||
? 'text-red-600'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{overallJudgment || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="mt-8 text-center text-[10px]">
|
||||
<p>위 내용과 같이 제품검사 결과를 보고합니다.</p>
|
||||
<div className="mt-6">
|
||||
<p>{createdDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// ===== 검사항목 행 =====
|
||||
|
||||
interface InspectionRowProps {
|
||||
item: FqcTemplateItem;
|
||||
rowIndex: number;
|
||||
judgment: JudgmentValue;
|
||||
onToggleJudgment: (rowIndex: number, value: JudgmentValue) => void;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
function InspectionRow({ item, rowIndex, judgment, onToggleJudgment, readonly }: InspectionRowProps) {
|
||||
return (
|
||||
<tr className="border-b border-gray-300">
|
||||
{/* NO */}
|
||||
<td className="border border-gray-400 px-2 py-1 text-center align-middle font-medium w-10">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
{/* 검사항목 */}
|
||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
||||
{item.itemName}
|
||||
</td>
|
||||
{/* 검사기준 */}
|
||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
||||
{item.standard || item.frequency || '-'}
|
||||
</td>
|
||||
{/* 판정 */}
|
||||
<td className="border border-gray-400 px-1 py-1 text-center align-middle w-28">
|
||||
{readonly ? (
|
||||
<div className="flex items-center justify-center gap-1 text-[10px]">
|
||||
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
|
||||
{judgment === '적합' ? '■' : '□'} 적합
|
||||
</span>
|
||||
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
|
||||
{judgment === '부적합' ? '■' : '□'} 부적합
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleJudgment(rowIndex, '적합')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
||||
judgment === '적합'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleJudgment(rowIndex, '부적합')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
||||
judgment === '부적합'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||
}`}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 제품검사성적서 모달 (읽기전용)
|
||||
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
|
||||
* 검사 입력은 별도 ProductInspectionInputModal에서 진행
|
||||
* 제품검사성적서 모달
|
||||
*
|
||||
* 페이지네이션: 층(orderItem)별로 검사성적서 표시
|
||||
* 양식 기반 전환 (5.2.4):
|
||||
* - documents API에서 FQC 문서를 조회하여 양식 기반 렌더링
|
||||
* - FqcDocumentContent로 template 구조 기반 표시
|
||||
* - 개소(orderItem)별 페이지네이션 유지
|
||||
*
|
||||
* Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { FqcDocumentContent } from './FqcDocumentContent';
|
||||
import { InspectionReportDocument } from './InspectionReportDocument';
|
||||
import { getFqcDocument, getFqcTemplate } from '../fqcActions';
|
||||
import type {
|
||||
InspectionReportDocument as InspectionReportDocumentType,
|
||||
OrderSettingItem,
|
||||
ProductInspection
|
||||
ProductInspection,
|
||||
} from '../types';
|
||||
import type { FqcDocument, FqcTemplate } from '../fqcActions';
|
||||
import { buildReportDocumentDataForItem } from '../mockData';
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
@@ -29,6 +35,8 @@ interface InspectionReportModalProps {
|
||||
inspection?: ProductInspection | null;
|
||||
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
|
||||
orderItems?: OrderSettingItem[];
|
||||
/** FQC 문서 ID 매핑 (orderItemId → documentId) */
|
||||
fqcDocumentMap?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
@@ -37,40 +45,87 @@ export function InspectionReportModal({
|
||||
data,
|
||||
inspection,
|
||||
orderItems,
|
||||
fqcDocumentMap,
|
||||
}: InspectionReportModalProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [inputPage, setInputPage] = useState('1');
|
||||
|
||||
// 총 페이지 수 (orderItems가 있으면 그 길이, 아니면 1)
|
||||
// FQC 문서/양식 상태
|
||||
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||
const [fqcError, setFqcError] = useState<string | null>(null);
|
||||
|
||||
// 양식 기반 모드 사용 여부
|
||||
const useFqcMode = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
|
||||
|
||||
// 총 페이지 수
|
||||
const totalPages = useMemo(() => {
|
||||
if (orderItems && orderItems.length > 0) {
|
||||
return orderItems.length;
|
||||
}
|
||||
if (orderItems && orderItems.length > 0) return orderItems.length;
|
||||
return 1;
|
||||
}, [orderItems]);
|
||||
|
||||
// 모달 열릴 때 페이지 초기화
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentPage(1);
|
||||
setInputPage('1');
|
||||
setFqcDocument(null);
|
||||
setFqcError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 현재 페이지에 해당하는 문서 데이터
|
||||
const currentData = useMemo(() => {
|
||||
// 페이지네이션이 가능한 경우 (inspection과 orderItems 모두 있음)
|
||||
// FQC 양식 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (!open || !useFqcMode || fqcTemplate) return;
|
||||
getFqcTemplate().then(result => {
|
||||
if (result.success && result.data) {
|
||||
setFqcTemplate(result.data);
|
||||
}
|
||||
});
|
||||
}, [open, useFqcMode, fqcTemplate]);
|
||||
|
||||
// 페이지 변경 시 FQC 문서 로드
|
||||
useEffect(() => {
|
||||
if (!open || !useFqcMode || !orderItems || !fqcDocumentMap) return;
|
||||
|
||||
const currentItem = orderItems[currentPage - 1];
|
||||
if (!currentItem) return;
|
||||
|
||||
const documentId = fqcDocumentMap[currentItem.id];
|
||||
if (!documentId) {
|
||||
setFqcDocument(null);
|
||||
setFqcError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingFqc(true);
|
||||
setFqcError(null);
|
||||
|
||||
getFqcDocument(documentId)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setFqcDocument(result.data);
|
||||
} else {
|
||||
setFqcError(result.error || '문서 조회 실패');
|
||||
setFqcDocument(null);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoadingFqc(false));
|
||||
}, [open, useFqcMode, currentPage, orderItems, fqcDocumentMap]);
|
||||
|
||||
// 기존 모드: 현재 페이지 문서 데이터 (fallback)
|
||||
const legacyCurrentData = useMemo(() => {
|
||||
if (inspection && orderItems && orderItems.length > 0) {
|
||||
const currentItem = orderItems[currentPage - 1];
|
||||
if (currentItem) {
|
||||
return buildReportDocumentDataForItem(inspection, currentItem);
|
||||
}
|
||||
}
|
||||
// 기본: data prop 사용
|
||||
return data;
|
||||
}, [inspection, orderItems, currentPage, data]);
|
||||
|
||||
// 이전 페이지
|
||||
// 페이지네이션 핸들러
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (currentPage > 1) {
|
||||
const newPage = currentPage - 1;
|
||||
@@ -79,7 +134,6 @@ export function InspectionReportModal({
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
// 다음 페이지
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (currentPage < totalPages) {
|
||||
const newPage = currentPage + 1;
|
||||
@@ -88,27 +142,29 @@ export function InspectionReportModal({
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
// 페이지 입력 후 이동
|
||||
const handleGoToPage = useCallback(() => {
|
||||
const pageNum = parseInt(inputPage, 10);
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
|
||||
setCurrentPage(pageNum);
|
||||
} else {
|
||||
// 잘못된 입력 → 현재 페이지로 복원
|
||||
setInputPage(String(currentPage));
|
||||
}
|
||||
}, [inputPage, totalPages, currentPage]);
|
||||
|
||||
// 엔터키로 이동
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleGoToPage();
|
||||
}
|
||||
}, [handleGoToPage]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleGoToPage();
|
||||
},
|
||||
[handleGoToPage]
|
||||
);
|
||||
|
||||
if (!currentData) return null;
|
||||
// 문서 표시할 데이터 없음 체크
|
||||
if (!useFqcMode && !legacyCurrentData) return null;
|
||||
|
||||
// 페이지네이션 UI 컴포넌트
|
||||
// 현재 페이지 아이템 정보
|
||||
const currentItem = orderItems?.[currentPage - 1];
|
||||
|
||||
// 페이지네이션 UI
|
||||
const paginationUI = totalPages > 1 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-3 border-t bg-gray-100 print:hidden">
|
||||
<Button
|
||||
@@ -130,12 +186,7 @@ export function InspectionReportModal({
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">/ {totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGoToPage}
|
||||
className="h-8"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={handleGoToPage} className="h-8">
|
||||
이동
|
||||
</Button>
|
||||
<Button
|
||||
@@ -148,22 +199,66 @@ export function InspectionReportModal({
|
||||
다음
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
{/* 현재 개소 정보 */}
|
||||
{currentItem && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{currentItem.floor || ''}{currentItem.symbol ? `-${currentItem.symbol}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 문서 제목
|
||||
const documentTitle = useFqcMode && fqcDocument
|
||||
? fqcDocument.title
|
||||
: '제품검사성적서';
|
||||
|
||||
// PDF 메타 정보
|
||||
const pdfMeta = useFqcMode && fqcDocument
|
||||
? { documentNumber: fqcDocument.documentNo, createdDate: fqcDocument.createdAt.split('T')[0] }
|
||||
: legacyCurrentData
|
||||
? { documentNumber: legacyCurrentData.documentNumber, createdDate: legacyCurrentData.createdDate }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="제품검사성적서"
|
||||
title={documentTitle}
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
pdfMeta={{
|
||||
documentNumber: currentData.documentNumber,
|
||||
createdDate: currentData.createdDate,
|
||||
}}
|
||||
pdfMeta={pdfMeta}
|
||||
toolbarExtra={paginationUI}
|
||||
>
|
||||
<InspectionReportDocument data={currentData} />
|
||||
{useFqcMode ? (
|
||||
// 양식 기반 모드
|
||||
isLoadingFqc ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">문서 로딩 중...</span>
|
||||
</div>
|
||||
) : fqcError ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p>{fqcError}</p>
|
||||
</div>
|
||||
) : fqcDocument && fqcTemplate ? (
|
||||
<FqcDocumentContent
|
||||
template={fqcTemplate}
|
||||
documentData={fqcDocument.data}
|
||||
documentNo={fqcDocument.documentNo}
|
||||
createdDate={fqcDocument.createdAt.split('T')[0]}
|
||||
readonly={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p>이 개소의 FQC 문서가 아직 생성되지 않았습니다.</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 기존 하드코딩 모드 (fallback)
|
||||
legacyCurrentData && <InspectionReportDocument data={legacyCurrentData} />
|
||||
)}
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { InspectionRequestDocument } from './InspectionRequestDocument';
|
||||
export { InspectionRequestModal } from './InspectionRequestModal';
|
||||
export { InspectionReportDocument } from './InspectionReportDocument';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FqcDocumentContent } from './FqcDocumentContent';
|
||||
|
||||
461
src/components/quality/InspectionManagement/fqcActions.ts
Normal file
461
src/components/quality/InspectionManagement/fqcActions.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* FQC (제품검사) 문서 시스템 Server Actions
|
||||
*
|
||||
* documents API를 활용한 FQC 문서 관리:
|
||||
* - POST /v1/documents/bulk-create-fqc → 개소별 문서 일괄생성
|
||||
* - GET /v1/documents/fqc-status → 수주 FQC 진행현황
|
||||
* - GET /v1/documents/{id} → 문서 상세 조회
|
||||
* - POST /v1/documents/upsert → 문서 데이터 저장
|
||||
* - GET /v1/document-templates/{id} → 양식 상세 조회
|
||||
*/
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
/** 양식 항목 */
|
||||
interface TemplateItemApi {
|
||||
id: number;
|
||||
section_id: number;
|
||||
item_name: string;
|
||||
standard: string | null;
|
||||
tolerance: string | null;
|
||||
measurement_type: string;
|
||||
frequency: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** 양식 섹션 */
|
||||
interface TemplateSectionApi {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image_path: string | null;
|
||||
sort_order: number;
|
||||
items: TemplateItemApi[];
|
||||
}
|
||||
|
||||
/** 양식 컬럼 */
|
||||
interface TemplateColumnApi {
|
||||
id: number;
|
||||
label: string;
|
||||
column_type: string;
|
||||
width: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** 양식 결재라인 */
|
||||
interface TemplateApprovalLineApi {
|
||||
id: number;
|
||||
name: string;
|
||||
department: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** 양식 기본필드 */
|
||||
interface TemplateBasicFieldApi {
|
||||
id: number;
|
||||
label: string;
|
||||
field_key: string;
|
||||
field_type: string;
|
||||
default_value: string | null;
|
||||
is_required: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** 양식 상세 API 응답 */
|
||||
interface DocumentTemplateApi {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
title: string | null;
|
||||
approval_lines: TemplateApprovalLineApi[];
|
||||
basic_fields: TemplateBasicFieldApi[];
|
||||
sections: TemplateSectionApi[];
|
||||
columns: TemplateColumnApi[];
|
||||
}
|
||||
|
||||
/** 문서 데이터 (EAV) */
|
||||
interface DocumentDataApi {
|
||||
id: number;
|
||||
document_id: number;
|
||||
section_id: number | null;
|
||||
column_id: number | null;
|
||||
row_index: number;
|
||||
field_key: string;
|
||||
field_value: string | null;
|
||||
}
|
||||
|
||||
/** 문서 상세 API 응답 */
|
||||
interface DocumentApi {
|
||||
id: number;
|
||||
template_id: number;
|
||||
document_no: string;
|
||||
title: string;
|
||||
status: string;
|
||||
linkable_type: string | null;
|
||||
linkable_id: number | null;
|
||||
created_by: number | null;
|
||||
updated_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
template: DocumentTemplateApi;
|
||||
data: DocumentDataApi[];
|
||||
}
|
||||
|
||||
/** FQC 일괄생성 응답 */
|
||||
interface BulkCreateFqcResponse {
|
||||
created_count: number;
|
||||
skipped_count: number;
|
||||
total_items: number;
|
||||
documents: Array<{
|
||||
id: number;
|
||||
document_no: string;
|
||||
title: string;
|
||||
status: string;
|
||||
linkable_id: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** FQC 진행현황 항목 */
|
||||
interface FqcStatusItemApi {
|
||||
order_item_id: number;
|
||||
floor_code: string;
|
||||
symbol_code: string;
|
||||
specification: string;
|
||||
item_name: string;
|
||||
document_id: number | null;
|
||||
document_no: string | null;
|
||||
status: string;
|
||||
judgement: string | null;
|
||||
}
|
||||
|
||||
/** FQC 진행현황 응답 */
|
||||
interface FqcStatusResponse {
|
||||
order_id: number;
|
||||
order_no: string;
|
||||
total: number;
|
||||
created: number;
|
||||
approved: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
items: FqcStatusItemApi[];
|
||||
}
|
||||
|
||||
// ===== Frontend 타입 =====
|
||||
|
||||
export interface FqcTemplateItem {
|
||||
id: number;
|
||||
sectionId: number;
|
||||
itemName: string;
|
||||
standard: string | null;
|
||||
tolerance: string | null;
|
||||
measurementType: string;
|
||||
frequency: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface FqcTemplateSection {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string | null;
|
||||
imagePath: string | null;
|
||||
sortOrder: number;
|
||||
items: FqcTemplateItem[];
|
||||
}
|
||||
|
||||
export interface FqcTemplateColumn {
|
||||
id: number;
|
||||
label: string;
|
||||
columnType: string;
|
||||
width: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface FqcApprovalLine {
|
||||
id: number;
|
||||
name: string;
|
||||
department: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface FqcBasicField {
|
||||
id: number;
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
fieldType: string;
|
||||
defaultValue: string | null;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface FqcTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
title: string | null;
|
||||
approvalLines: FqcApprovalLine[];
|
||||
basicFields: FqcBasicField[];
|
||||
sections: FqcTemplateSection[];
|
||||
columns: FqcTemplateColumn[];
|
||||
}
|
||||
|
||||
export interface FqcDocumentData {
|
||||
sectionId: number | null;
|
||||
columnId: number | null;
|
||||
rowIndex: number;
|
||||
fieldKey: string;
|
||||
fieldValue: string | null;
|
||||
}
|
||||
|
||||
export interface FqcDocument {
|
||||
id: number;
|
||||
templateId: number;
|
||||
documentNo: string;
|
||||
title: string;
|
||||
status: string;
|
||||
linkableId: number | null;
|
||||
createdAt: string;
|
||||
template: FqcTemplate;
|
||||
data: FqcDocumentData[];
|
||||
}
|
||||
|
||||
export interface FqcStatusItem {
|
||||
orderItemId: number;
|
||||
floorCode: string;
|
||||
symbolCode: string;
|
||||
specification: string;
|
||||
itemName: string;
|
||||
documentId: number | null;
|
||||
documentNo: string | null;
|
||||
status: string;
|
||||
judgement: string | null;
|
||||
}
|
||||
|
||||
export interface FqcStatus {
|
||||
orderId: number;
|
||||
orderNo: string;
|
||||
total: number;
|
||||
created: number;
|
||||
approved: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
items: FqcStatusItem[];
|
||||
}
|
||||
|
||||
// ===== 변환 함수 =====
|
||||
|
||||
function transformTemplate(api: DocumentTemplateApi): FqcTemplate {
|
||||
return {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
category: api.category,
|
||||
title: api.title,
|
||||
approvalLines: (api.approval_lines || []).map(al => ({
|
||||
id: al.id,
|
||||
name: al.name,
|
||||
department: al.department,
|
||||
sortOrder: al.sort_order,
|
||||
})),
|
||||
basicFields: (api.basic_fields || []).map(bf => ({
|
||||
id: bf.id,
|
||||
label: bf.label,
|
||||
fieldKey: bf.field_key,
|
||||
fieldType: bf.field_type,
|
||||
defaultValue: bf.default_value,
|
||||
isRequired: bf.is_required,
|
||||
sortOrder: bf.sort_order,
|
||||
})),
|
||||
sections: (api.sections || []).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
title: s.title,
|
||||
imagePath: s.image_path,
|
||||
sortOrder: s.sort_order,
|
||||
items: (s.items || []).map(item => ({
|
||||
id: item.id,
|
||||
sectionId: item.section_id,
|
||||
itemName: item.item_name,
|
||||
standard: item.standard,
|
||||
tolerance: item.tolerance,
|
||||
measurementType: item.measurement_type,
|
||||
frequency: item.frequency,
|
||||
sortOrder: item.sort_order,
|
||||
})),
|
||||
})),
|
||||
columns: (api.columns || []).map(c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
columnType: c.column_type,
|
||||
width: c.width,
|
||||
sortOrder: c.sort_order,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformDocument(api: DocumentApi): FqcDocument {
|
||||
return {
|
||||
id: api.id,
|
||||
templateId: api.template_id,
|
||||
documentNo: api.document_no,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
linkableId: api.linkable_id,
|
||||
createdAt: api.created_at,
|
||||
template: transformTemplate(api.template),
|
||||
data: (api.data || []).map(d => ({
|
||||
sectionId: d.section_id,
|
||||
columnId: d.column_id,
|
||||
rowIndex: d.row_index,
|
||||
fieldKey: d.field_key,
|
||||
fieldValue: d.field_value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformFqcStatus(api: FqcStatusResponse): FqcStatus {
|
||||
return {
|
||||
orderId: api.order_id,
|
||||
orderNo: api.order_no,
|
||||
total: api.total,
|
||||
created: api.created,
|
||||
approved: api.approved,
|
||||
passed: api.passed,
|
||||
failed: api.failed,
|
||||
pending: api.pending,
|
||||
items: api.items.map(item => ({
|
||||
orderItemId: item.order_item_id,
|
||||
floorCode: item.floor_code,
|
||||
symbolCode: item.symbol_code,
|
||||
specification: item.specification,
|
||||
itemName: item.item_name,
|
||||
documentId: item.document_id,
|
||||
documentNo: item.document_no,
|
||||
status: item.status,
|
||||
judgement: item.judgement,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Server Actions =====
|
||||
|
||||
const FQC_TEMPLATE_ID = 65;
|
||||
|
||||
/**
|
||||
* FQC 양식 상세 조회
|
||||
* GET /v1/document-templates/{id}
|
||||
*/
|
||||
export async function getFqcTemplate(): Promise<{
|
||||
success: boolean;
|
||||
data?: FqcTemplate;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
return executeServerAction<DocumentTemplateApi, FqcTemplate>({
|
||||
url: buildApiUrl(`/api/v1/document-templates/${FQC_TEMPLATE_ID}`),
|
||||
transform: transformTemplate,
|
||||
errorMessage: 'FQC 양식 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FQC 문서 일괄생성
|
||||
* POST /v1/documents/bulk-create-fqc
|
||||
*/
|
||||
export async function bulkCreateFqcDocuments(orderId: number): Promise<{
|
||||
success: boolean;
|
||||
data?: BulkCreateFqcResponse;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
return executeServerAction<BulkCreateFqcResponse>({
|
||||
url: buildApiUrl('/api/v1/documents/bulk-create-fqc'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
template_id: FQC_TEMPLATE_ID,
|
||||
order_id: orderId,
|
||||
},
|
||||
errorMessage: 'FQC 문서 일괄생성에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FQC 진행현황 조회
|
||||
* GET /v1/documents/fqc-status?order_id=N&template_id=65
|
||||
*/
|
||||
export async function getFqcStatus(orderId: number): Promise<{
|
||||
success: boolean;
|
||||
data?: FqcStatus;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
return executeServerAction<FqcStatusResponse, FqcStatus>({
|
||||
url: buildApiUrl('/api/v1/documents/fqc-status', {
|
||||
order_id: orderId,
|
||||
template_id: FQC_TEMPLATE_ID,
|
||||
}),
|
||||
transform: transformFqcStatus,
|
||||
errorMessage: 'FQC 진행현황 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FQC 문서 상세 조회
|
||||
* GET /v1/documents/{id}
|
||||
*/
|
||||
export async function getFqcDocument(documentId: number): Promise<{
|
||||
success: boolean;
|
||||
data?: FqcDocument;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
return executeServerAction<DocumentApi, FqcDocument>({
|
||||
url: buildApiUrl(`/api/v1/documents/${documentId}`),
|
||||
transform: transformDocument,
|
||||
errorMessage: 'FQC 문서 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* FQC 문서 데이터 저장 (upsert)
|
||||
* POST /v1/documents/upsert
|
||||
*/
|
||||
export async function saveFqcDocument(params: {
|
||||
documentId?: number;
|
||||
templateId?: number;
|
||||
itemId?: number;
|
||||
title?: string;
|
||||
data: Array<{
|
||||
section_id?: number | null;
|
||||
column_id?: number | null;
|
||||
row_index: number;
|
||||
field_key: string;
|
||||
field_value: string | null;
|
||||
}>;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const body: Record<string, unknown> = {
|
||||
template_id: params.templateId ?? FQC_TEMPLATE_ID,
|
||||
data: params.data,
|
||||
};
|
||||
|
||||
if (params.documentId) body.document_id = params.documentId;
|
||||
if (params.itemId) body.item_id = params.itemId;
|
||||
if (params.title) body.title = params.title;
|
||||
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/documents/upsert'),
|
||||
method: 'POST',
|
||||
body,
|
||||
errorMessage: 'FQC 검사 데이터 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export interface InspectionScheduleInfo {
|
||||
// 수주 설정 항목 (상세 페이지 아코디언 내부 테이블)
|
||||
export interface OrderSettingItem {
|
||||
id: string;
|
||||
orderId?: number; // 수주 DB ID (FQC 문서 연동용)
|
||||
orderNumber: string; // 수주번호
|
||||
siteName: string; // 현장명
|
||||
deliveryDate: string; // 납품일
|
||||
|
||||
Reference in New Issue
Block a user