feat: [품질검사] 검사 모달 개선 + 수주 선택 필터링
검사 모달: - 기본값 null(미선택)으로 변경, 일괄합격/초기화 토글 버튼 - 시공 가로/세로, 변경사유 입력 필드 추가 - 검사 항목별 기준값 텍스트 표시 - 사진 첨부 기능 (최대 2장, base64) - 이전/다음 개소 네비게이션 + 자동저장 뱃지/상태: - legacy 검사 데이터 반영 (합격/불합격/진행중/미검사) - 사진 없으면 진행중 처리, 뱃지 크기 통일 - Eye 아이콘 → "보기" 텍스트 뱃지 - 진행바 legacy+FQC 통합 inspectionStats 수주 선택: - 같은 거래처(발주처) + 같은 모델만 선택 가능 필터링 - 수주 선택 시 개소별 자동 펼침 (floor, symbol, 규격 포함) - 모달에 모델명 컬럼 추가, 필터 적용 시 제목에 안내 표시 - 변경사유 서버 저장 연동 수정
This commit is contained in:
@@ -84,19 +84,45 @@ export function InspectionCreate() {
|
||||
|
||||
// ===== 수주 선택 처리 =====
|
||||
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
||||
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: '',
|
||||
symbol: '',
|
||||
orderWidth: 0,
|
||||
orderHeight: 0,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}));
|
||||
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
|
||||
item.locations.length > 0
|
||||
? item.locations.map((loc) => ({
|
||||
id: `${item.id}-${loc.nodeId}`,
|
||||
orderId: Number(item.id),
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
clientId: item.clientId,
|
||||
clientName: item.clientName,
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: loc.floor,
|
||||
symbol: loc.symbol,
|
||||
orderWidth: loc.orderWidth,
|
||||
orderHeight: loc.orderHeight,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}))
|
||||
: [{
|
||||
id: item.id,
|
||||
orderId: Number(item.id),
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
clientId: item.clientId,
|
||||
clientName: item.clientName,
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: '',
|
||||
symbol: '',
|
||||
orderWidth: 0,
|
||||
orderHeight: 0,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}]
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: [...prev.orderItems, ...newOrderItems],
|
||||
@@ -659,12 +685,23 @@ export function InspectionCreate() {
|
||||
</div>
|
||||
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
|
||||
|
||||
// 이미 선택된 수주 ID 목록
|
||||
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
|
||||
const excludeOrderIds = useMemo(
|
||||
() => formData.orderItems.map((item) => item.id),
|
||||
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
|
||||
[formData.orderItems]
|
||||
);
|
||||
|
||||
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
|
||||
const orderFilter = useMemo(() => {
|
||||
if (formData.orderItems.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
|
||||
const first = formData.orderItems[0];
|
||||
return {
|
||||
clientId: first.clientId ?? undefined,
|
||||
itemId: first.itemId ?? undefined,
|
||||
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
|
||||
};
|
||||
}, [formData.orderItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
@@ -683,6 +720,9 @@ export function InspectionCreate() {
|
||||
onOpenChange={setOrderModalOpen}
|
||||
onSelect={handleOrderSelect}
|
||||
excludeIds={excludeOrderIds}
|
||||
filterClientId={orderFilter.clientId}
|
||||
filterItemId={orderFilter.itemId}
|
||||
filterLabel={orderFilter.label}
|
||||
/>
|
||||
|
||||
{/* 제품검사 입력 모달 */}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -50,10 +49,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { inspectionConfig } from './inspectionConfig';
|
||||
@@ -63,6 +58,7 @@ import {
|
||||
getInspectionById,
|
||||
updateInspection,
|
||||
completeInspection,
|
||||
saveLocationInspection,
|
||||
} from './actions';
|
||||
import { getFqcStatus, type FqcStatusItem } from './fqcActions';
|
||||
import {
|
||||
@@ -153,31 +149,54 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
// FQC 상태 데이터 (개소별 진행현황)
|
||||
const [fqcStatusItems, setFqcStatusItems] = useState<FqcStatusItem[]>([]);
|
||||
|
||||
// 파생: 문서 매핑 (orderItemId → documentId)
|
||||
const fqcDocumentMap = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
fqcStatusItems.forEach((item) => {
|
||||
if (item.documentId) map[String(item.orderItemId)] = item.documentId;
|
||||
});
|
||||
return map;
|
||||
}, [fqcStatusItems]);
|
||||
|
||||
// 파생: 진행현황 통계
|
||||
const fqcStats = useMemo(() => {
|
||||
if (fqcStatusItems.length === 0) return null;
|
||||
return {
|
||||
total: fqcStatusItems.length,
|
||||
passed: fqcStatusItems.filter((i) => i.judgement === '합격').length,
|
||||
failed: fqcStatusItems.filter((i) => i.judgement === '불합격').length,
|
||||
inProgress: fqcStatusItems.filter((i) => i.documentId != null && !i.judgement).length,
|
||||
notCreated: fqcStatusItems.filter((i) => i.documentId == null).length,
|
||||
};
|
||||
}, [fqcStatusItems]);
|
||||
// 개소별 검사 상태 집계 (legacy inspectionData + FQC 통합)
|
||||
const inspectionStats = useMemo(() => {
|
||||
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// 개소별 FQC 상태 조회 헬퍼
|
||||
const getStatus = (item: OrderSettingItem) => {
|
||||
// FQC 문서 기반 상태 확인
|
||||
const fqcItem = fqcStatusItems.find(
|
||||
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
|
||||
);
|
||||
if (fqcItem?.judgement === '합격') return 'passed';
|
||||
if (fqcItem?.judgement === '불합격') return 'failed';
|
||||
if (fqcItem?.documentId) return 'inProgress';
|
||||
|
||||
// legacy inspectionData 확인
|
||||
if (!item.inspectionData) return 'none';
|
||||
const d = item.inspectionData;
|
||||
const judgmentFields = [
|
||||
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
|
||||
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
|
||||
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
|
||||
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
|
||||
];
|
||||
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
|
||||
const hasPhotos = d.productImages && d.productImages.length > 0;
|
||||
if (inspected.length === 0 && !hasPhotos) return 'none';
|
||||
if (inspected.length < judgmentFields.length || !hasPhotos) return 'inProgress';
|
||||
if (inspected.some(v => v === 'fail')) return 'failed';
|
||||
return 'passed';
|
||||
};
|
||||
|
||||
const statuses = items.map(getStatus);
|
||||
return {
|
||||
total: items.length,
|
||||
passed: statuses.filter(s => s === 'passed').length,
|
||||
failed: statuses.filter(s => s === 'failed').length,
|
||||
inProgress: statuses.filter(s => s === 'inProgress').length,
|
||||
none: statuses.filter(s => s === 'none').length,
|
||||
};
|
||||
}, [isEditMode, formData.orderItems, inspection?.orderItems, fqcStatusItems]);
|
||||
|
||||
// 개소별 FQC 상태 조회 헬퍼 (floor+symbol 기반 매칭)
|
||||
const getFqcItemStatus = useCallback(
|
||||
(orderItemId: string): FqcStatusItem | null => {
|
||||
return fqcStatusItems.find((i) => String(i.orderItemId) === orderItemId) ?? null;
|
||||
(item: OrderSettingItem): FqcStatusItem | null => {
|
||||
return fqcStatusItems.find(
|
||||
(i) => i.floorCode === item.floor && i.symbolCode === item.symbol
|
||||
) ?? null;
|
||||
},
|
||||
[fqcStatusItems]
|
||||
);
|
||||
@@ -316,19 +335,45 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
|
||||
// ===== 수주 선택/삭제 처리 =====
|
||||
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
|
||||
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: '',
|
||||
symbol: '',
|
||||
orderWidth: 0,
|
||||
orderHeight: 0,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}));
|
||||
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
|
||||
item.locations.length > 0
|
||||
? item.locations.map((loc) => ({
|
||||
id: `${item.id}-${loc.nodeId}`,
|
||||
orderId: Number(item.id),
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
clientId: item.clientId,
|
||||
clientName: item.clientName,
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: loc.floor,
|
||||
symbol: loc.symbol,
|
||||
orderWidth: loc.orderWidth,
|
||||
orderHeight: loc.orderHeight,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}))
|
||||
: [{
|
||||
id: item.id,
|
||||
orderId: Number(item.id),
|
||||
orderNumber: item.orderNumber,
|
||||
siteName: item.siteName,
|
||||
clientId: item.clientId,
|
||||
clientName: item.clientName,
|
||||
itemId: item.itemId,
|
||||
itemName: item.itemName,
|
||||
deliveryDate: item.deliveryDate,
|
||||
floor: '',
|
||||
symbol: '',
|
||||
orderWidth: 0,
|
||||
orderHeight: 0,
|
||||
constructionWidth: 0,
|
||||
constructionHeight: 0,
|
||||
changeReason: '',
|
||||
}]
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: [...prev.orderItems, ...newOrderItems],
|
||||
@@ -342,11 +387,24 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
|
||||
const excludeOrderIds = useMemo(
|
||||
() => formData.orderItems.map((item) => item.id),
|
||||
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
|
||||
[formData.orderItems]
|
||||
);
|
||||
|
||||
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
|
||||
const orderFilter = useMemo(() => {
|
||||
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||
if (items.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
|
||||
const first = items[0];
|
||||
return {
|
||||
clientId: first.clientId ?? undefined,
|
||||
itemId: first.itemId ?? undefined,
|
||||
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
|
||||
};
|
||||
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
|
||||
|
||||
// ===== 수주 설정 요약 =====
|
||||
const orderSummary = useMemo(() => {
|
||||
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
|
||||
@@ -384,22 +442,46 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
setInspectionInputOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInspectionComplete = useCallback((data: ProductInspectionData) => {
|
||||
const handleInspectionComplete = useCallback(async (
|
||||
data: ProductInspectionData,
|
||||
constructionInfo?: { width: number | null; height: number | null; changeReason: string }
|
||||
) => {
|
||||
if (!selectedOrderItem) return;
|
||||
|
||||
// formData의 해당 orderItem에 inspectionData 저장
|
||||
// 서버에 개소별 검사 데이터 저장
|
||||
const result = await saveLocationInspection(id, selectedOrderItem.id, data, constructionInfo);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateItem = (item: OrderSettingItem) => {
|
||||
if (item.id !== selectedOrderItem.id) return item;
|
||||
const updated = { ...item, inspectionData: data };
|
||||
if (constructionInfo) {
|
||||
if (constructionInfo.width !== null) updated.constructionWidth = constructionInfo.width;
|
||||
if (constructionInfo.height !== null) updated.constructionHeight = constructionInfo.height;
|
||||
updated.changeReason = constructionInfo.changeReason;
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
// 로컬 state도 반영
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: prev.orderItems.map((item) =>
|
||||
item.id === selectedOrderItem.id
|
||||
? { ...item, inspectionData: data }
|
||||
: item
|
||||
),
|
||||
orderItems: prev.orderItems.map(updateItem),
|
||||
}));
|
||||
|
||||
// inspection 데이터도 갱신 (새로고침 없이 반영)
|
||||
if (inspection) {
|
||||
setInspection({
|
||||
...inspection,
|
||||
orderItems: inspection.orderItems.map(updateItem),
|
||||
});
|
||||
}
|
||||
|
||||
toast.success('검사 데이터가 저장되었습니다.');
|
||||
setSelectedOrderItem(null);
|
||||
}, [selectedOrderItem]);
|
||||
}, [id, selectedOrderItem, inspection]);
|
||||
|
||||
// ===== 시공규격/변경사유 수정 핸들러 (수정 모드) =====
|
||||
const handleUpdateOrderItemField = useCallback((
|
||||
@@ -418,25 +500,47 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
// ===== FQC 상태 뱃지 렌더링 =====
|
||||
const renderFqcBadge = useCallback(
|
||||
(item: OrderSettingItem) => {
|
||||
const fqcItem = getFqcItemStatus(item.id);
|
||||
const fqcItem = getFqcItemStatus(item);
|
||||
if (!fqcItem) {
|
||||
// FQC 데이터 없음 → legacy 상태
|
||||
return item.inspectionData ? (
|
||||
<Badge className="bg-green-100 text-green-800 border-0">검사완료</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">미검사</Badge>
|
||||
<Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>
|
||||
);
|
||||
}
|
||||
if (fqcItem.judgement === '합격') {
|
||||
return <Badge className="bg-green-100 text-green-800 border-0">합격</Badge>;
|
||||
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center">합격</Badge>;
|
||||
}
|
||||
if (fqcItem.judgement === '불합격') {
|
||||
return <Badge className="bg-red-100 text-red-800 border-0">불합격</Badge>;
|
||||
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center">불합격</Badge>;
|
||||
}
|
||||
if (fqcItem.documentId) {
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-0">진행중</Badge>;
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center">진행중</Badge>;
|
||||
}
|
||||
return <Badge variant="outline" className="text-muted-foreground">미생성</Badge>;
|
||||
// FQC 문서 없음 → legacy 검사 데이터 확인
|
||||
if (!item.inspectionData) {
|
||||
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>;
|
||||
}
|
||||
const d = item.inspectionData;
|
||||
const judgmentFields = [
|
||||
d.appearanceProcessing, d.appearanceSewing, d.appearanceAssembly,
|
||||
d.appearanceSmokeBarrier, d.appearanceBottomFinish, d.motor, d.material,
|
||||
d.lengthJudgment, d.heightJudgment, d.guideRailGap, d.bottomFinishGap,
|
||||
d.fireResistanceTest, d.smokeLeakageTest, d.openCloseTest, d.impactTest,
|
||||
];
|
||||
const inspected = judgmentFields.filter(v => v !== null && v !== undefined);
|
||||
const hasPhotos = d.productImages && d.productImages.length > 0;
|
||||
if (inspected.length === 0 && !hasPhotos) {
|
||||
return <Badge variant="outline" className="text-muted-foreground min-w-[3.5rem] justify-center">미검사</Badge>;
|
||||
}
|
||||
if (inspected.length < judgmentFields.length || !hasPhotos) {
|
||||
return <Badge className="bg-blue-100 text-blue-800 border-0 min-w-[3.5rem] justify-center">진행중</Badge>;
|
||||
}
|
||||
if (inspected.some(v => v === 'fail')) {
|
||||
return <Badge className="bg-red-100 text-red-800 border-0 min-w-[3.5rem] justify-center">불합격</Badge>;
|
||||
}
|
||||
return <Badge className="bg-green-100 text-green-800 border-0 min-w-[3.5rem] justify-center">합격</Badge>;
|
||||
},
|
||||
[getFqcItemStatus]
|
||||
);
|
||||
@@ -451,15 +555,15 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
|
||||
// ===== FQC 진행현황 통계 바 =====
|
||||
const renderFqcProgressBar = useMemo(() => {
|
||||
if (!fqcStats) return null;
|
||||
const { total, passed, failed, inProgress, notCreated } = fqcStats;
|
||||
if (!inspectionStats) return null;
|
||||
const { total, passed, failed, inProgress, none } = inspectionStats;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-green-600 font-medium">합격 {passed}</span>
|
||||
<span className="text-red-600 font-medium">불합격 {failed}</span>
|
||||
<span className="text-blue-600 font-medium">진행중 {inProgress}</span>
|
||||
<span className="text-muted-foreground">미생성 {notCreated}</span>
|
||||
<span className="text-muted-foreground">미검사 {none}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden flex">
|
||||
{passed > 0 && (
|
||||
@@ -483,7 +587,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [fqcStats]);
|
||||
}, [inspectionStats]);
|
||||
|
||||
// ===== 수주 설정 아코디언 (조회 모드) =====
|
||||
const renderOrderAccordion = (groups: OrderGroup[]) => {
|
||||
@@ -496,20 +600,16 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
|
||||
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소 */}
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-6 text-sm w-full">
|
||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
{/* 하위 레벨: 테이블 */}
|
||||
<div key={group.orderNumber} className="border rounded-lg">
|
||||
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
|
||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -538,15 +638,14 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{renderFqcBadge(item)}
|
||||
{(getFqcItemStatus(item.id) || item.inspectionData) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
{(getFqcItemStatus(item) || item.inspectionData) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-muted"
|
||||
onClick={() => handleOpenInspectionInput(item)}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
보기
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -554,10 +653,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -572,33 +671,27 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<AccordionItem key={group.orderNumber} value={group.orderNumber} className="border rounded-lg mb-2">
|
||||
{/* 상위 레벨: 수주번호, 현장명, 납품일, 개소, 삭제 */}
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline flex-1">
|
||||
<div className="flex items-center gap-6 text-sm w-full">
|
||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<div key={group.orderNumber} className="border rounded-lg">
|
||||
<div className="flex items-center gap-6 text-sm px-4 py-3 bg-muted/30 rounded-t-lg">
|
||||
<span className="font-medium w-32">{group.orderNumber}</span>
|
||||
<span className="text-muted-foreground flex-1">{group.siteName}</span>
|
||||
<span className="text-muted-foreground w-28">{group.deliveryDate}</span>
|
||||
<span className="text-muted-foreground w-16">{group.locationCount}개소</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 mr-4 text-muted-foreground hover:text-red-600"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-600"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// 해당 그룹의 모든 아이템 삭제
|
||||
group.items.forEach((item) => handleRemoveOrderItem(item.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<div className="px-4 pb-4">
|
||||
{/* 하위 레벨: 테이블 (시공규격, 변경사유 편집 가능) */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -670,10 +763,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -845,8 +938,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
<CardTitle className="text-base">수주 설정 정보</CardTitle>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
||||
<span className="text-green-600">일치: <strong>{orderSummary.same}</strong>건</span>
|
||||
<span className="text-red-600">불일치: <strong>{orderSummary.changed}</strong>건</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderFqcProgressBar}
|
||||
@@ -1151,8 +1242,6 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>전체: <strong>{orderSummary.total}</strong>건</span>
|
||||
<span className="text-green-600">일치: <strong>{orderSummary.same}</strong>건</span>
|
||||
<span className="text-red-600">불일치: <strong>{orderSummary.changed}</strong>건</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderFqcProgressBar}
|
||||
@@ -1240,6 +1329,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
onOpenChange={setOrderModalOpen}
|
||||
onSelect={handleOrderSelect}
|
||||
excludeIds={excludeOrderIds}
|
||||
filterClientId={orderFilter.clientId}
|
||||
filterItemId={orderFilter.itemId}
|
||||
filterLabel={orderFilter.label}
|
||||
/>
|
||||
|
||||
{/* 제품검사요청서 모달 */}
|
||||
@@ -1257,19 +1349,23 @@ 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}
|
||||
/>
|
||||
|
||||
{/* 제품검사 입력 모달 */}
|
||||
<ProductInspectionInputModal
|
||||
open={inspectionInputOpen}
|
||||
onOpenChange={setInspectionInputOpen}
|
||||
onOpenChange={(open) => { setInspectionInputOpen(open); if (!open) setSelectedOrderItem(null); }}
|
||||
orderItemId={selectedOrderItem?.id || ''}
|
||||
productName="방화셔터"
|
||||
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
|
||||
initialData={selectedOrderItem?.inspectionData}
|
||||
onComplete={handleInspectionComplete}
|
||||
fqcDocumentId={selectedOrderItem ? fqcDocumentMap[selectedOrderItem.id] ?? null : null}
|
||||
fqcDocumentId={selectedOrderItem?.documentId ?? null}
|
||||
constructionWidth={selectedOrderItem?.constructionWidth}
|
||||
constructionHeight={selectedOrderItem?.constructionHeight}
|
||||
changeReason={selectedOrderItem?.changeReason}
|
||||
orderItems={isEditMode ? formData.orderItems : (inspection?.orderItems || [])}
|
||||
onNavigate={(item) => setSelectedOrderItem(item)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,12 @@ interface OrderSelectModalProps {
|
||||
onSelect: (items: OrderSelectItem[]) => void;
|
||||
/** 이미 선택된 항목 ID 목록 (중복 선택 방지) */
|
||||
excludeIds?: string[];
|
||||
/** 같은 거래처만 필터 (이미 선택된 수주의 client_id) */
|
||||
filterClientId?: number | null;
|
||||
/** 같은 모델만 필터 (이미 선택된 수주의 item_id) */
|
||||
filterItemId?: number | null;
|
||||
/** 필터 안내 텍스트 (예: "발주처A / 방화셔터") */
|
||||
filterLabel?: string;
|
||||
}
|
||||
|
||||
export function OrderSelectModal({
|
||||
@@ -37,10 +43,17 @@ export function OrderSelectModal({
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
excludeIds = [],
|
||||
filterClientId,
|
||||
filterItemId,
|
||||
filterLabel,
|
||||
}: OrderSelectModalProps) {
|
||||
const handleFetchData = useCallback(async (query: string) => {
|
||||
try {
|
||||
const result = await getOrderSelectList({ q: query || undefined });
|
||||
const result = await getOrderSelectList({
|
||||
q: query || undefined,
|
||||
clientId: filterClientId,
|
||||
itemId: filterItemId,
|
||||
});
|
||||
if (result.success) {
|
||||
return result.data.filter((item) => !excludeIds.includes(item.id));
|
||||
}
|
||||
@@ -52,13 +65,13 @@ export function OrderSelectModal({
|
||||
toast.error('수주 목록 로드 중 오류가 발생했습니다.');
|
||||
return [];
|
||||
}
|
||||
}, [excludeIds]);
|
||||
}, [excludeIds, filterClientId, filterItemId]);
|
||||
|
||||
return (
|
||||
<SearchableSelectionModal<OrderSelectItem>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="수주 선택"
|
||||
title={filterLabel ? `수주 선택 — ${filterLabel}` : '수주 선택'}
|
||||
searchPlaceholder="수주번호, 현장명 검색..."
|
||||
fetchData={handleFetchData}
|
||||
keyExtractor={(item) => item.id}
|
||||
@@ -71,9 +84,12 @@ export function OrderSelectModal({
|
||||
confirmLabel="선택"
|
||||
allowSelectAll
|
||||
isItemDisabled={(item, selectedItems) => {
|
||||
// 서버 필터가 이미 적용된 경우 모달 내 추가 제한 불필요
|
||||
if (filterClientId || filterItemId) return false;
|
||||
// 서버 필터 없이 첫 선택 시 모달 내에서 같은 거래처+모델만 선택 가능
|
||||
if (selectedItems.length === 0) return false;
|
||||
const selectedClient = selectedItems[0].clientName;
|
||||
return item.clientName !== selectedClient;
|
||||
const first = selectedItems[0];
|
||||
return item.clientId !== first.clientId || item.itemId !== first.itemId;
|
||||
}}
|
||||
listWrapper={(children, selectState) => (
|
||||
<Table>
|
||||
@@ -90,24 +106,25 @@ export function OrderSelectModal({
|
||||
<TableHead>수주번호</TableHead>
|
||||
<TableHead>현장명</TableHead>
|
||||
<TableHead>발주처</TableHead>
|
||||
<TableHead>모델</TableHead>
|
||||
<TableHead className="text-center">납품일</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{children}
|
||||
{/* 빈 상태는 공통 컴포넌트에서 처리 */}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
renderItem={(item, isSelected, isDisabled) => (
|
||||
<TableRow className={isDisabled ? '' : 'hover:bg-muted/50'}>
|
||||
<TableRow className={isDisabled ? 'opacity-40' : 'hover:bg-muted/50'}>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} disabled={isDisabled} />
|
||||
</TableCell>
|
||||
<TableCell>{item.orderNumber}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.clientName}</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryDate}</TableCell>
|
||||
<TableCell className="text-center">{item.locationCount}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -19,15 +19,21 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions';
|
||||
import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions';
|
||||
import type { ProductInspectionData } from './types';
|
||||
import type { ProductInspectionData, OrderSettingItem } from './types';
|
||||
|
||||
type JudgmentValue = '적합' | '부적합' | null;
|
||||
|
||||
interface ConstructionInfo {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
changeReason: string;
|
||||
}
|
||||
|
||||
interface ProductInspectionInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -35,9 +41,18 @@ interface ProductInspectionInputModalProps {
|
||||
productName?: string;
|
||||
specification?: string;
|
||||
initialData?: ProductInspectionData;
|
||||
onComplete: (data: ProductInspectionData) => void;
|
||||
onComplete: (data: ProductInspectionData, constructionInfo?: ConstructionInfo) => void;
|
||||
/** FQC 문서 ID (있으면 양식 기반 모드) */
|
||||
fqcDocumentId?: number | null;
|
||||
/** 시공 가로/세로 초기값 */
|
||||
constructionWidth?: number | null;
|
||||
constructionHeight?: number | null;
|
||||
/** 변경사유 초기값 */
|
||||
changeReason?: string;
|
||||
/** 전체 주문 아이템 목록 (이전/다음 네비게이션용) */
|
||||
orderItems?: OrderSettingItem[];
|
||||
/** 이전/다음 이동 시 호출 (저장 후 해당 아이템으로 전환) */
|
||||
onNavigate?: (item: OrderSettingItem) => void;
|
||||
}
|
||||
|
||||
export function ProductInspectionInputModal({
|
||||
@@ -49,6 +64,11 @@ export function ProductInspectionInputModal({
|
||||
initialData,
|
||||
onComplete,
|
||||
fqcDocumentId,
|
||||
constructionWidth: initialConstructionWidth,
|
||||
constructionHeight: initialConstructionHeight,
|
||||
changeReason: initialChangeReason = '',
|
||||
orderItems = [],
|
||||
onNavigate,
|
||||
}: ProductInspectionInputModalProps) {
|
||||
// FQC 모드 상태
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
@@ -56,6 +76,11 @@ export function ProductInspectionInputModal({
|
||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 시공 가로/세로/변경사유
|
||||
const [conWidth, setConWidth] = useState<number | null>(null);
|
||||
const [conHeight, setConHeight] = useState<number | null>(null);
|
||||
const [changeReason, setChangeReason] = useState('');
|
||||
|
||||
// 판정 상태 (FQC 모드)
|
||||
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>({});
|
||||
|
||||
@@ -94,6 +119,15 @@ export function ProductInspectionInputModal({
|
||||
.finally(() => setIsLoadingFqc(false));
|
||||
}, [open, useFqcMode, fqcDocumentId]);
|
||||
|
||||
// 모달 열릴 때 또는 아이템 전환 시 시공 사이즈/변경사유 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConWidth(initialConstructionWidth ?? null);
|
||||
setConHeight(initialConstructionHeight ?? null);
|
||||
setChangeReason(initialChangeReason);
|
||||
}
|
||||
}, [open, orderItemId, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
|
||||
|
||||
// 모달 닫힐 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -125,7 +159,7 @@ export function ProductInspectionInputModal({
|
||||
}, [fqcTemplate, judgments]);
|
||||
|
||||
// FQC 검사 완료 (서버 저장)
|
||||
const handleFqcComplete = useCallback(async () => {
|
||||
const handleFqcComplete = useCallback(async (closeModal = true) => {
|
||||
if (!fqcTemplate || !fqcDocumentId) return;
|
||||
|
||||
const dataSection = fqcTemplate.sections.find(s => s.items.length > 0);
|
||||
@@ -134,7 +168,6 @@ export function ProductInspectionInputModal({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// document_data 형식으로 변환
|
||||
const records: Array<{
|
||||
section_id: number | null;
|
||||
column_id: number | null;
|
||||
@@ -156,7 +189,6 @@ export function ProductInspectionInputModal({
|
||||
}
|
||||
});
|
||||
|
||||
// 종합판정
|
||||
records.push({
|
||||
section_id: null,
|
||||
column_id: null,
|
||||
@@ -173,14 +205,10 @@ export function ProductInspectionInputModal({
|
||||
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,
|
||||
@@ -204,22 +232,15 @@ export function ProductInspectionInputModal({
|
||||
specialNotes: '',
|
||||
};
|
||||
|
||||
onComplete(legacyData);
|
||||
onOpenChange(false);
|
||||
onComplete(legacyData, { width: conWidth, height: conHeight, changeReason });
|
||||
if (closeModal) onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error || '검사 데이터 저장에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, onComplete, onOpenChange]);
|
||||
|
||||
// Legacy 완료 핸들러
|
||||
const handleLegacyComplete = useCallback(() => {
|
||||
if (!legacyFormData) return;
|
||||
onComplete(legacyFormData);
|
||||
onOpenChange(false);
|
||||
}, [onComplete, onOpenChange]);
|
||||
}, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
|
||||
|
||||
// ===== Legacy 모드 상태 =====
|
||||
const [legacyFormData, setLegacyFormData] = useState<ProductInspectionData | null>(null);
|
||||
@@ -230,30 +251,76 @@ export function ProductInspectionInputModal({
|
||||
productName,
|
||||
specification,
|
||||
productImages: [],
|
||||
appearanceProcessing: 'pass',
|
||||
appearanceSewing: 'pass',
|
||||
appearanceAssembly: 'pass',
|
||||
appearanceSmokeBarrier: 'pass',
|
||||
appearanceBottomFinish: 'pass',
|
||||
motor: 'pass',
|
||||
material: 'pass',
|
||||
appearanceProcessing: null,
|
||||
appearanceSewing: null,
|
||||
appearanceAssembly: null,
|
||||
appearanceSmokeBarrier: null,
|
||||
appearanceBottomFinish: null,
|
||||
motor: null,
|
||||
material: null,
|
||||
lengthValue: null,
|
||||
lengthJudgment: 'pass',
|
||||
lengthJudgment: null,
|
||||
heightValue: null,
|
||||
heightJudgment: 'pass',
|
||||
heightJudgment: null,
|
||||
guideRailGapValue: null,
|
||||
guideRailGap: 'pass',
|
||||
guideRailGap: null,
|
||||
bottomFinishGapValue: null,
|
||||
bottomFinishGap: 'pass',
|
||||
fireResistanceTest: 'pass',
|
||||
smokeLeakageTest: 'pass',
|
||||
openCloseTest: 'pass',
|
||||
impactTest: 'pass',
|
||||
bottomFinishGap: null,
|
||||
fireResistanceTest: null,
|
||||
smokeLeakageTest: null,
|
||||
openCloseTest: null,
|
||||
impactTest: null,
|
||||
hasSpecialNotes: false,
|
||||
specialNotes: '',
|
||||
});
|
||||
}
|
||||
}, [open, useFqcMode, initialData, productName, specification]);
|
||||
}, [open, orderItemId, useFqcMode, initialData, productName, specification]);
|
||||
|
||||
// Legacy 완료 핸들러
|
||||
const handleLegacyComplete = useCallback(() => {
|
||||
if (!legacyFormData) return;
|
||||
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
|
||||
onOpenChange(false);
|
||||
}, [legacyFormData, conWidth, conHeight, changeReason, onComplete, onOpenChange]);
|
||||
|
||||
// ===== 이전/다음 네비게이션 =====
|
||||
const currentIndex = orderItems.findIndex(item => item.id === orderItemId);
|
||||
const totalItems = orderItems.length;
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < totalItems - 1;
|
||||
|
||||
const hasLegacyChanges = useCallback(() => {
|
||||
if (!legacyFormData) return false;
|
||||
// 검사 데이터 변경 확인
|
||||
if (JSON.stringify(legacyFormData) !== JSON.stringify(initialData ?? null)) return true;
|
||||
// 시공 사이즈/변경사유 변경 확인
|
||||
if (conWidth !== (initialConstructionWidth ?? null)) return true;
|
||||
if (conHeight !== (initialConstructionHeight ?? null)) return true;
|
||||
if (changeReason !== initialChangeReason) return true;
|
||||
return false;
|
||||
}, [legacyFormData, initialData, conWidth, conHeight, changeReason, initialConstructionWidth, initialConstructionHeight, initialChangeReason]);
|
||||
|
||||
const saveAndNavigate = useCallback(async (targetItem: OrderSettingItem) => {
|
||||
if (!onNavigate) return;
|
||||
// 변경된 내용이 있을 때만 저장
|
||||
if (useFqcMode) {
|
||||
// FQC: judgments 변경 확인
|
||||
const hasJudgmentChanges = Object.keys(judgments).length > 0;
|
||||
if (hasJudgmentChanges) await handleFqcComplete(false);
|
||||
} else if (legacyFormData && hasLegacyChanges()) {
|
||||
onComplete(legacyFormData, { width: conWidth, height: conHeight, changeReason });
|
||||
}
|
||||
// 다음 아이템으로 이동
|
||||
onNavigate(targetItem);
|
||||
}, [useFqcMode, handleFqcComplete, legacyFormData, judgments, conWidth, conHeight, changeReason, hasLegacyChanges, onComplete, onNavigate]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (hasPrev) saveAndNavigate(orderItems[currentIndex - 1]);
|
||||
}, [hasPrev, currentIndex, orderItems, saveAndNavigate]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (hasNext) saveAndNavigate(orderItems[currentIndex + 1]);
|
||||
}, [hasNext, currentIndex, orderItems, saveAndNavigate]);
|
||||
|
||||
// FQC 데이터 섹션
|
||||
const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0);
|
||||
@@ -274,6 +341,41 @@ export function ProductInspectionInputModal({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 이전/다음 네비게이션 */}
|
||||
{totalItems > 1 && (
|
||||
<div className="flex items-center justify-between py-2 px-1 border-b">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrev}
|
||||
disabled={!hasPrev || isSaving}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{currentIndex + 1}</span>
|
||||
<span className="text-muted-foreground">/ {totalItems}</span>
|
||||
{orderItems[currentIndex] && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
({orderItems[currentIndex].floor}-{orderItems[currentIndex].symbol})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={!hasNext || isSaving}
|
||||
className="h-8"
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
|
||||
{/* 제품명 / 규격 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -287,6 +389,40 @@ export function ProductInspectionInputModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시공 가로/세로 + 변경사유 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">시공 가로</span>
|
||||
<input
|
||||
type="number"
|
||||
value={conWidth ?? ''}
|
||||
onChange={(e) => setConWidth(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
placeholder="가로"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">시공 세로</span>
|
||||
<input
|
||||
type="number"
|
||||
value={conHeight ?? ''}
|
||||
onChange={(e) => setConHeight(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
placeholder="세로"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 col-span-3">
|
||||
<span className="text-sm text-muted-foreground">변경사유</span>
|
||||
<input
|
||||
type="text"
|
||||
value={changeReason}
|
||||
onChange={(e) => setChangeReason(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
placeholder="변경사유 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{useFqcMode ? (
|
||||
// ===== FQC 양식 기반 모드 =====
|
||||
isLoadingFqc ? (
|
||||
@@ -298,11 +434,37 @@ export function ProductInspectionInputModal({
|
||||
<>
|
||||
{/* 검사항목 목록 (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 className="flex items-center justify-between border-b pb-2">
|
||||
<div className="text-sm font-medium text-blue-600">
|
||||
{dataSection?.title || dataSection?.name || '검사항목'}
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
({sortedItems.length}항목)
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const allPassed = sortedItems.length > 0 && sortedItems.every((_, idx) => judgments[idx] === '적합');
|
||||
return allPassed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJudgments({})}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const allPass: Record<number, JudgmentValue> = {};
|
||||
sortedItems.forEach((_, idx) => { allPass[idx] = '적합'; });
|
||||
setJudgments(allPass);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
일괄합격
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -353,7 +515,7 @@ export function ProductInspectionInputModal({
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={useFqcMode ? handleFqcComplete : handleLegacyComplete}
|
||||
onClick={useFqcMode ? () => handleFqcComplete(true) : handleLegacyComplete}
|
||||
disabled={isSaving || isLoadingFqc}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
@@ -434,31 +596,94 @@ function LegacyInspectionForm({
|
||||
onChange({ ...data, [key]: value });
|
||||
};
|
||||
|
||||
const judgmentKeys: (keyof ProductInspectionData)[] = [
|
||||
'appearanceProcessing', 'appearanceSewing', 'appearanceAssembly',
|
||||
'appearanceSmokeBarrier', 'appearanceBottomFinish', 'motor', 'material',
|
||||
'lengthJudgment', 'heightJudgment', 'guideRailGap', 'bottomFinishGap',
|
||||
'fireResistanceTest', 'smokeLeakageTest', 'openCloseTest', 'impactTest',
|
||||
];
|
||||
|
||||
const allPassed = judgmentKeys.every(k => data[k] === 'pass');
|
||||
|
||||
const setAllPass = () => {
|
||||
const updated = { ...data };
|
||||
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = 'pass'; });
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
const updated = { ...data };
|
||||
judgmentKeys.forEach(k => { (updated as Record<string, unknown>)[k] = null; });
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
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)} />
|
||||
<div className="flex justify-end">
|
||||
{allPassed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={setAllPass}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
일괄합격
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 1. 겉모양 검사 */}
|
||||
<LegacyGroup title="1. 겉모양">
|
||||
<LegacyRow label="가공상태" criteria="사용상 해로운 결함이 없을 것" value={data.appearanceProcessing} onChange={v => update('appearanceProcessing', v)} />
|
||||
<LegacyRow label="재봉상태" criteria="내화실에 의해 견고하게 접합되어야 함" value={data.appearanceSewing} onChange={v => update('appearanceSewing', v)} />
|
||||
<LegacyRow label="조립상태" criteria="핸드링이 견고하게 조립되어야 함" value={data.appearanceAssembly} onChange={v => update('appearanceAssembly', v)} />
|
||||
<LegacyRow label="연기차단재" criteria="케이스 W80, 가이드레일 W50(양쪽 설치)" value={data.appearanceSmokeBarrier} onChange={v => update('appearanceSmokeBarrier', v)} />
|
||||
<LegacyRow label="하단마감재" criteria="내부 무겁방절 설치 유무" value={data.appearanceBottomFinish} onChange={v => update('appearanceBottomFinish', 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)} />
|
||||
{/* 2. 모터 */}
|
||||
<LegacyGroup title="2. 모터">
|
||||
<LegacyRow label="모터" criteria="인정제품과 동일사양" value={data.motor} onChange={v => update('motor', 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)} />
|
||||
{/* 3. 재질 */}
|
||||
<LegacyGroup title="3. 재질">
|
||||
<LegacyRow label="재질" criteria="WY-SC780 인쇄상태 확인" value={data.material} onChange={v => update('material', v)} />
|
||||
</LegacyGroup>
|
||||
{/* 4. 치수(오픈사이즈) */}
|
||||
<LegacyGroup title="4. 치수(오픈사이즈)">
|
||||
<LegacyRow label="길이" criteria="수주 치수 ± 30mm" value={data.lengthJudgment} onChange={v => update('lengthJudgment', v)} />
|
||||
<LegacyRow label="높이" criteria="수주 치수 ± 30mm" value={data.heightJudgment} onChange={v => update('heightJudgment', v)} />
|
||||
<LegacyRow label="가이드레일 간격" criteria="10 ± 5mm (측정부위 높이 100 이하)" value={data.guideRailGap} onChange={v => update('guideRailGap', v)} />
|
||||
<LegacyRow label="하단막대 간격" criteria="가이드레일과 하단마감재 측 사이 25mm 이내" value={data.bottomFinishGap} onChange={v => update('bottomFinishGap', v)} />
|
||||
</LegacyGroup>
|
||||
{/* 5~9. 시험 검사 */}
|
||||
<LegacyGroup title="5~9. 시험 검사">
|
||||
<LegacyRow label="내화시험" criteria="비차열/차열성 - 공인시험기관 시험성적서" value={data.fireResistanceTest} onChange={v => update('fireResistanceTest', v)} />
|
||||
<LegacyRow label="차연시험" criteria="25Pa 시 공기누설량 0.9m³/min·m² 이하" value={data.smokeLeakageTest} onChange={v => update('smokeLeakageTest', v)} />
|
||||
<LegacyRow label="개폐시험" criteria="전동개폐 2.5~6.5m/min, 자중강하 3~7m/min" value={data.openCloseTest} onChange={v => update('openCloseTest', v)} />
|
||||
<LegacyRow label="내충격시험" criteria="방화상 유해한 파괴, 박리 탈락 유무" value={data.impactTest} onChange={v => update('impactTest', v)} />
|
||||
</LegacyGroup>
|
||||
{/* 사진 첨부 */}
|
||||
<LegacyGroup title="제품 사진">
|
||||
<LegacyPhotoUpload
|
||||
images={data.productImages}
|
||||
onChange={(images) => update('productImages', images)}
|
||||
maxCount={2}
|
||||
/>
|
||||
</LegacyGroup>
|
||||
{/* 특이사항 */}
|
||||
<LegacyGroup title="특이사항">
|
||||
<textarea
|
||||
value={data.specialNotes ?? ''}
|
||||
onChange={(e) => update('specialNotes', e.target.value)}
|
||||
className="w-full min-h-[60px] px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
||||
placeholder="특이사항 입력"
|
||||
/>
|
||||
</LegacyGroup>
|
||||
</>
|
||||
);
|
||||
@@ -475,17 +700,22 @@ function LegacyGroup({ title, children }: { title: string; children: React.React
|
||||
|
||||
function LegacyRow({
|
||||
label,
|
||||
criteria,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
criteria?: 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">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{criteria && <p className="text-xs text-muted-foreground mt-0.5">{criteria}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('pass')}
|
||||
@@ -510,3 +740,69 @@ function LegacyRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyPhotoUpload({
|
||||
images,
|
||||
onChange,
|
||||
maxCount,
|
||||
}: {
|
||||
images: string[];
|
||||
onChange: (images: string[]) => void;
|
||||
maxCount: number;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
if (images.length >= maxCount) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const dataUrl = ev.target?.result as string;
|
||||
onChange([...images, dataUrl].slice(0, maxCount));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
onChange(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{images.map((src, idx) => (
|
||||
<div key={idx} className="relative w-24 h-24 rounded-lg border overflow-hidden group">
|
||||
<img src={src} alt={`사진 ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(idx)}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < maxCount && (
|
||||
<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-gray-400 hover:border-blue-400 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<span className="text-2xl leading-none">+</span>
|
||||
<span className="text-xs mt-1">{images.length}/{maxCount}</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,8 +85,13 @@ interface ProductInspectionApi {
|
||||
};
|
||||
order_items: Array<{
|
||||
id: string;
|
||||
order_id?: number;
|
||||
order_number: string;
|
||||
site_name: string;
|
||||
client_id?: number | null;
|
||||
client_name?: string;
|
||||
item_id?: number | null;
|
||||
item_name?: string;
|
||||
delivery_date: string;
|
||||
floor: string;
|
||||
symbol: string;
|
||||
@@ -128,9 +133,19 @@ interface OrderSelectItemApi {
|
||||
id: number;
|
||||
order_number: string;
|
||||
site_name: string;
|
||||
client_id: number | null;
|
||||
client_name: string;
|
||||
item_id: number | null;
|
||||
item_name: string;
|
||||
delivery_date: string;
|
||||
location_count: number;
|
||||
locations: Array<{
|
||||
node_id: number;
|
||||
floor: string;
|
||||
symbol: string;
|
||||
order_width: number;
|
||||
order_height: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===== 페이지네이션 =====
|
||||
@@ -224,6 +239,10 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
|
||||
orderId: item.order_id,
|
||||
orderNumber: item.order_number,
|
||||
siteName: item.site_name || '',
|
||||
clientId: item.client_id ?? null,
|
||||
clientName: item.client_name ?? '',
|
||||
itemId: item.item_id ?? null,
|
||||
itemName: item.item_name ?? '',
|
||||
deliveryDate: item.delivery_date || '',
|
||||
floor: item.floor,
|
||||
symbol: item.symbol,
|
||||
@@ -232,6 +251,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
|
||||
constructionWidth: item.construction_width,
|
||||
constructionHeight: item.construction_height,
|
||||
changeReason: item.change_reason,
|
||||
documentId: item.document_id ?? null,
|
||||
inspectionData: item.inspection_data || undefined,
|
||||
})),
|
||||
requestDocumentId: api.request_document_id ?? null,
|
||||
@@ -591,6 +611,42 @@ export async function updateInspection(
|
||||
: { success: true };
|
||||
}
|
||||
|
||||
// ===== 개소별 검사 저장 =====
|
||||
|
||||
export async function saveLocationInspection(
|
||||
docId: string,
|
||||
locationId: string,
|
||||
inspectionData: Record<string, unknown>,
|
||||
constructionInfo?: {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
changeReason: string;
|
||||
},
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const body: Record<string, unknown> = {
|
||||
inspection_data: inspectionData,
|
||||
inspection_status: 'completed',
|
||||
};
|
||||
if (constructionInfo) {
|
||||
body.construction_width = constructionInfo.width;
|
||||
body.construction_height = constructionInfo.height;
|
||||
body.change_reason = constructionInfo.changeReason;
|
||||
}
|
||||
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/quality/documents/${docId}/locations/${locationId}/inspect`),
|
||||
method: 'POST',
|
||||
body,
|
||||
errorMessage: '검사 데이터 저장에 실패했습니다.',
|
||||
});
|
||||
|
||||
return { success: result.success, error: result.error, __authError: result.__authError };
|
||||
}
|
||||
|
||||
// ===== 삭제 =====
|
||||
|
||||
export async function deleteInspection(id: string): Promise<{
|
||||
@@ -640,6 +696,8 @@ export async function completeInspection(
|
||||
|
||||
export async function getOrderSelectList(params?: {
|
||||
q?: string;
|
||||
clientId?: number | null;
|
||||
itemId?: number | null;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: OrderSelectItem[];
|
||||
@@ -647,7 +705,11 @@ export async function getOrderSelectList(params?: {
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
const result = await executeServerAction<OrderSelectItemApi[]>({
|
||||
url: buildApiUrl('/api/v1/quality/documents/available-orders', { q: params?.q }),
|
||||
url: buildApiUrl('/api/v1/quality/documents/available-orders', {
|
||||
q: params?.q,
|
||||
client_id: params?.clientId ?? undefined,
|
||||
item_id: params?.itemId ?? undefined,
|
||||
}),
|
||||
errorMessage: '수주 선택 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -660,6 +722,12 @@ export async function getOrderSelectList(params?: {
|
||||
i.orderNumber.toLowerCase().includes(q) || i.siteName.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (params?.clientId) {
|
||||
filtered = filtered.filter(i => i.clientId === params.clientId);
|
||||
}
|
||||
if (params?.itemId) {
|
||||
filtered = filtered.filter(i => i.itemId === params.itemId);
|
||||
}
|
||||
return { success: true, data: filtered };
|
||||
}
|
||||
return { success: false, data: [], error: result.error, __authError: result.__authError };
|
||||
@@ -672,9 +740,19 @@ export async function getOrderSelectList(params?: {
|
||||
id: String(item.id),
|
||||
orderNumber: item.order_number,
|
||||
siteName: item.site_name,
|
||||
clientId: item.client_id ?? null,
|
||||
clientName: item.client_name ?? '',
|
||||
itemId: item.item_id ?? null,
|
||||
itemName: item.item_name ?? '',
|
||||
deliveryDate: item.delivery_date,
|
||||
locationCount: item.location_count,
|
||||
locations: (item.locations || []).map((loc) => ({
|
||||
nodeId: loc.node_id,
|
||||
floor: loc.floor,
|
||||
symbol: loc.symbol,
|
||||
orderWidth: loc.order_width,
|
||||
orderHeight: loc.order_height,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ interface InspectionReportModalProps {
|
||||
inspection?: ProductInspection | null;
|
||||
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
|
||||
orderItems?: OrderSettingItem[];
|
||||
/** FQC 문서 ID 매핑 (orderItemId → documentId) */
|
||||
fqcDocumentMap?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function InspectionReportModal({
|
||||
@@ -46,7 +44,6 @@ export function InspectionReportModal({
|
||||
data,
|
||||
inspection,
|
||||
orderItems,
|
||||
fqcDocumentMap,
|
||||
}: InspectionReportModalProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [inputPage, setInputPage] = useState('1');
|
||||
@@ -61,8 +58,8 @@ export function InspectionReportModal({
|
||||
const [fqcError, setFqcError] = useState<string | null>(null);
|
||||
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
|
||||
|
||||
// FQC 모드 우선 (fqcDocumentMap 없어도 시도, template 로드 실패 시 fallback)
|
||||
const hasFqcDocuments = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
|
||||
// FQC 모드 우선 (orderItems에 documentId가 있으면 FQC 문서 존재)
|
||||
const hasFqcDocuments = !!orderItems && orderItems.some((i) => i.documentId);
|
||||
const useFqcMode = !templateLoadFailed && (hasFqcDocuments || !!fqcTemplate);
|
||||
|
||||
// 총 페이지 수
|
||||
@@ -96,12 +93,12 @@ export function InspectionReportModal({
|
||||
|
||||
// 페이지 변경 시 FQC 문서 로드
|
||||
useEffect(() => {
|
||||
if (!open || !hasFqcDocuments || !orderItems || !fqcDocumentMap) return;
|
||||
if (!open || !hasFqcDocuments || !orderItems) return;
|
||||
|
||||
const currentItem = orderItems[currentPage - 1];
|
||||
if (!currentItem) return;
|
||||
|
||||
const documentId = fqcDocumentMap[currentItem.id];
|
||||
const documentId = currentItem.documentId;
|
||||
if (!documentId) {
|
||||
setFqcDocument(null);
|
||||
setFqcError(null);
|
||||
@@ -121,7 +118,7 @@ export function InspectionReportModal({
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoadingFqc(false));
|
||||
}, [open, useFqcMode, currentPage, orderItems, fqcDocumentMap]);
|
||||
}, [open, useFqcMode, currentPage, orderItems]);
|
||||
|
||||
// 기존 모드: 현재 페이지 문서 데이터 (fallback)
|
||||
const legacyCurrentData = useMemo(() => {
|
||||
|
||||
@@ -62,13 +62,32 @@ const defaultSupervisor = {
|
||||
// ===== Mock 수주 선택 목록 (모달용) =====
|
||||
|
||||
export const mockOrderSelectItems: OrderSelectItem[] = [
|
||||
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-2', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-3', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-4', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-5', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-6', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-7', orderNumber: '123123', siteName: '현장명', deliveryDate: '2026-01-01', locationCount: 3 },
|
||||
{ id: 'os-1', orderNumber: '123123', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 3, locations: [
|
||||
{ nodeId: 101, floor: '1층', symbol: 'A', orderWidth: 4100, orderHeight: 2700 },
|
||||
{ nodeId: 102, floor: '2층', symbol: 'B', orderWidth: 3800, orderHeight: 2500 },
|
||||
{ nodeId: 103, floor: '3층', symbol: 'C', orderWidth: 4200, orderHeight: 2800 },
|
||||
] },
|
||||
{ id: 'os-2', orderNumber: '123124', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||
{ nodeId: 201, floor: '1층', symbol: 'D', orderWidth: 3500, orderHeight: 2400 },
|
||||
{ nodeId: 202, floor: '2층', symbol: 'E', orderWidth: 3600, orderHeight: 2500 },
|
||||
] },
|
||||
{ id: 'os-3', orderNumber: '123125', siteName: '현장명', clientId: 1, clientName: '발주처A', itemId: 20, itemName: '스크린', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||
{ nodeId: 301, floor: '1층', symbol: 'F', orderWidth: 5000, orderHeight: 3000 },
|
||||
] },
|
||||
{ id: 'os-4', orderNumber: '123126', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||
{ nodeId: 401, floor: '1층', symbol: 'G', orderWidth: 4000, orderHeight: 2600 },
|
||||
{ nodeId: 402, floor: '2층', symbol: 'H', orderWidth: 4100, orderHeight: 2700 },
|
||||
] },
|
||||
{ id: 'os-5', orderNumber: '123127', siteName: '현장명', clientId: 2, clientName: '발주처B', itemId: 10, itemName: '방화셔터', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||
{ nodeId: 501, floor: '1층', symbol: 'I', orderWidth: 3900, orderHeight: 2500 },
|
||||
] },
|
||||
{ id: 'os-6', orderNumber: '123128', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 2, locations: [
|
||||
{ nodeId: 601, floor: '1층', symbol: 'J', orderWidth: 2000, orderHeight: 1500 },
|
||||
{ nodeId: 602, floor: '2층', symbol: 'K', orderWidth: 2100, orderHeight: 1600 },
|
||||
] },
|
||||
{ id: 'os-7', orderNumber: '123129', siteName: '현장명', clientId: 3, clientName: '발주처C', itemId: 30, itemName: '절곡물', deliveryDate: '2026-01-01', locationCount: 1, locations: [
|
||||
{ nodeId: 701, floor: '1층', symbol: 'L', orderWidth: 2200, orderHeight: 1700 },
|
||||
] },
|
||||
];
|
||||
|
||||
// ===== Mock 수주 설정 항목 =====
|
||||
|
||||
@@ -61,6 +61,10 @@ export interface OrderSettingItem {
|
||||
orderId?: number; // 수주 DB ID (FQC 문서 연동용)
|
||||
orderNumber: string; // 수주번호
|
||||
siteName: string; // 현장명
|
||||
clientId?: number | null; // 발주처 ID
|
||||
clientName?: string; // 발주처명
|
||||
itemId?: number | null; // 품목(모델) ID
|
||||
itemName?: string; // 품목(모델)명
|
||||
deliveryDate: string; // 납품일
|
||||
floor: string; // 층수
|
||||
symbol: string; // 부호
|
||||
@@ -69,6 +73,8 @@ export interface OrderSettingItem {
|
||||
constructionWidth: number; // 시공 규격 - 가로
|
||||
constructionHeight: number; // 시공 규격 - 세로
|
||||
changeReason: string; // 변경사유
|
||||
// FQC 성적서 EAV 문서 ID (quality_document_locations.document_id)
|
||||
documentId?: number | null;
|
||||
// 검사 결과 데이터
|
||||
inspectionData?: ProductInspectionData;
|
||||
}
|
||||
@@ -120,9 +126,22 @@ export interface OrderSelectItem {
|
||||
id: string;
|
||||
orderNumber: string; // 수주번호
|
||||
siteName: string; // 현장명
|
||||
clientId: number | null; // 발주처 ID
|
||||
clientName: string; // 발주처
|
||||
itemId: number | null; // 품목(모델) ID
|
||||
itemName: string; // 품목(모델)명
|
||||
deliveryDate: string; // 납품일
|
||||
locationCount: number; // 개소
|
||||
locations: OrderSelectLocation[]; // 개소 상세
|
||||
}
|
||||
|
||||
// 수주의 개소(root node) 정보
|
||||
export interface OrderSelectLocation {
|
||||
nodeId: number;
|
||||
floor: string;
|
||||
symbol: string;
|
||||
orderWidth: number;
|
||||
orderHeight: number;
|
||||
}
|
||||
|
||||
// ===== 메인 데이터 =====
|
||||
|
||||
Reference in New Issue
Block a user