feat: [품질검사] 검사 모달 개선 + 수주 선택 필터링

검사 모달:
- 기본값 null(미선택)으로 변경, 일괄합격/초기화 토글 버튼
- 시공 가로/세로, 변경사유 입력 필드 추가
- 검사 항목별 기준값 텍스트 표시
- 사진 첨부 기능 (최대 2장, base64)
- 이전/다음 개소 네비게이션 + 자동저장

뱃지/상태:
- legacy 검사 데이터 반영 (합격/불합격/진행중/미검사)
- 사진 없으면 진행중 처리, 뱃지 크기 통일
- Eye 아이콘 → "보기" 텍스트 뱃지
- 진행바 legacy+FQC 통합 inspectionStats

수주 선택:
- 같은 거래처(발주처) + 같은 모델만 선택 가능 필터링
- 수주 선택 시 개소별 자동 펼침 (floor, symbol, 규격 포함)
- 모달에 모델명 컬럼 추가, 필터 적용 시 제목에 안내 표시
- 변경사유 서버 저장 연동 수정
This commit is contained in:
2026-03-07 01:19:17 +09:00
parent e75d8f9b25
commit 563b240fbf
8 changed files with 774 additions and 212 deletions

View File

@@ -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}
/>
{/* 제품검사 입력 모달 */}

View File

@@ -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)}
/>
</>
);

View File

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

View File

@@ -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>
);
}

View File

@@ -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,
})),
})),
};
}

View File

@@ -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(() => {

View File

@@ -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 수주 설정 항목 =====

View File

@@ -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;
}
// ===== 메인 데이터 =====