Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-06 09:17:12 +09:00
11 changed files with 1797 additions and 330 deletions

View File

@@ -0,0 +1,366 @@
'use client';
/**
* 수입검사 입력 모달
*
* 작업자 화면 중간검사 모달 양식 참고
* 기획서: 스크린샷 2026-02-05 오후 9.58.16
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
// 시료 탭 타입
type SampleTab = 'N1' | 'N2' | 'N3';
// 검사 결과 데이터 타입
export interface ImportInspectionData {
sampleTab: SampleTab;
productName: string;
specification: string;
// 겉모양
appearanceStatus: 'ok' | 'ng' | null;
// 치수
thickness: number | null;
width: number | null;
length: number | null;
// 판정
judgment: 'pass' | 'fail' | null;
// 물성치
tensileStrength: number | null; // 인장강도 (270 이상)
elongation: number | null; // 연신율 (36 이상)
zincCoating: number | null; // 아연의 최소 부착량 (17 이상)
// 내용
content: string;
}
interface ImportInspectionInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
productName?: string;
specification?: string;
onComplete: (data: ImportInspectionData) => void;
}
// OK/NG 버튼 컴포넌트
function OkNgToggle({
value,
onChange,
}: {
value: 'ok' | 'ng' | null;
onChange: (v: 'ok' | 'ng') => void;
}) {
return (
<div className="flex gap-2">
<button
type="button"
onClick={() => onChange('ok')}
className={cn(
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'ok'
? 'bg-gray-900 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600'
)}
>
OK
</button>
<button
type="button"
onClick={() => onChange('ng')}
className={cn(
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'ng'
? 'bg-gray-900 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600'
)}
>
NG
</button>
</div>
);
}
// 적합/부적합 버튼 컴포넌트
function JudgmentToggle({
value,
onChange,
}: {
value: 'pass' | 'fail' | null;
onChange: (v: 'pass' | 'fail') => void;
}) {
return (
<div className="flex gap-2">
<button
type="button"
onClick={() => onChange('pass')}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'pass'
? 'bg-orange-500 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600'
)}
>
</button>
<button
type="button"
onClick={() => onChange('fail')}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'fail'
? 'bg-gray-900 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600'
)}
>
</button>
</div>
);
}
export function ImportInspectionInputModal({
open,
onOpenChange,
productName = '',
specification = '',
onComplete,
}: ImportInspectionInputModalProps) {
const [formData, setFormData] = useState<ImportInspectionData>({
sampleTab: 'N1',
productName,
specification,
appearanceStatus: 'ok',
thickness: 1.55,
width: 1219,
length: 480,
judgment: 'pass',
tensileStrength: null,
elongation: null,
zincCoating: null,
content: '',
});
useEffect(() => {
if (open) {
// 모달 열릴 때 초기화 - 기본값 적합 상태
setFormData({
sampleTab: 'N1',
productName,
specification,
appearanceStatus: 'ok',
thickness: 1.55,
width: 1219,
length: 480,
judgment: 'pass',
tensileStrength: null,
elongation: null,
zincCoating: null,
content: '',
});
}
}, [open, productName, specification]);
const handleComplete = () => {
onComplete(formData);
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
// 숫자 입력 핸들러
const handleNumberChange = (
key: keyof ImportInspectionData,
value: string
) => {
const num = value === '' ? null : parseFloat(value);
setFormData((prev) => ({ ...prev, [key]: num }));
};
const sampleTabs: SampleTab[] = ['N1', 'N2', 'N3'];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px] bg-gray-900 text-white border-gray-700">
<DialogHeader>
<DialogTitle className="text-white text-lg font-bold">
</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4 max-h-[70vh] overflow-y-auto pr-2">
{/* 제품명 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
value={formData.productName}
readOnly
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
{/* 규격 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
value={formData.specification}
readOnly
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
{/* 시료 탭: N1, N2, N3 */}
<div className="flex gap-2">
{sampleTabs.map((tab) => (
<button
key={tab}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, sampleTab: tab }))}
className={cn(
'px-6 py-2 rounded-lg text-sm font-medium transition-colors',
formData.sampleTab === tab
? 'bg-orange-500 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600'
)}
>
{tab}
</button>
))}
</div>
{/* 겉모양: OK/NG */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<OkNgToggle
value={formData.appearanceStatus}
onChange={(v) => setFormData((prev) => ({ ...prev, appearanceStatus: v }))}
/>
</div>
{/* 두께 / 너비 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> (1.55)</Label>
<Input
type="number"
step="0.01"
placeholder="1.55"
value={formData.thickness ?? ''}
onChange={(e) => handleNumberChange('thickness', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (1219)</Label>
<Input
type="number"
placeholder="1219"
value={formData.width ?? ''}
onChange={(e) => handleNumberChange('width', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
</div>
{/* 길이 */}
<div className="space-y-2">
<Label className="text-gray-300"> (480)</Label>
<Input
type="number"
placeholder="480"
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
{/* 판정: 적합/부적합 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<JudgmentToggle
value={formData.judgment}
onChange={(v) => setFormData((prev) => ({ ...prev, judgment: v }))}
/>
</div>
{/* 인장강도 / 연신율 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> (270 )</Label>
<Input
type="number"
placeholder=""
value={formData.tensileStrength ?? ''}
onChange={(e) => handleNumberChange('tensileStrength', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (36 )</Label>
<Input
type="number"
placeholder=""
value={formData.elongation ?? ''}
onChange={(e) => handleNumberChange('elongation', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
</div>
{/* 아연의 최소 부착량 */}
<div className="space-y-2">
<Label className="text-gray-300"> (17 )</Label>
<Input
type="number"
placeholder=""
value={formData.zincCoating ?? ''}
onChange={(e) => handleNumberChange('zincCoating', e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
{/* 내용 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
value={formData.content}
onChange={(e) =>
setFormData((prev) => ({ ...prev, content: e.target.value }))
}
placeholder=""
className="bg-gray-800 border-gray-700 text-white min-h-[80px]"
/>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex gap-3 mt-6">
<Button
variant="outline"
onClick={handleCancel}
className="flex-1 bg-gray-800 border-gray-700 text-white hover:bg-gray-700"
>
</Button>
<Button
onClick={handleComplete}
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -20,6 +20,7 @@ import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react'
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModalV2 } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModalV2';
import { ImportInspectionInputModal, type ImportInspectionData } from './ImportInspectionInputModal';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -129,6 +130,8 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
// 수입검사 성적서 모달 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
// 수입검사 입력 모달 상태
const [isImportInspectionModalOpen, setIsImportInspectionModalOpen] = useState(false);
const [isItemSearchOpen, setIsItemSearchOpen] = useState(false);
const [isSupplierSearchOpen, setIsSupplierSearchOpen] = useState(false);
@@ -253,11 +256,23 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
}
};
// 수입검사하기 버튼 핸들러 - 모달 표시
// 수입검사하기 버튼 핸들러 - 수입검사 입력 모달 표시
const handleInspection = () => {
setIsImportInspectionModalOpen(true);
};
// 수입검사성적서 보기 버튼 핸들러 - 성적서 모달 표시
const handleViewInspectionReport = () => {
setIsInspectionModalOpen(true);
};
// 수입검사 완료 핸들러
const handleImportInspectionComplete = (data: ImportInspectionData) => {
console.log('수입검사 완료:', data);
toast.success('수입검사가 완료되었습니다.');
// TODO: API 호출하여 검사 결과 저장
};
// 재고 조정 행 추가
const handleAddAdjustment = () => {
const newRecord: InventoryAdjustmentRecord = {
@@ -689,10 +704,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
// ===== 커스텀 헤더 액션 (view/edit 모드) =====
// 수정 버튼은 IntegratedDetailTemplate의 DetailActions에서 아이콘으로 제공하므로 중복 제거
const customHeaderActions = (isViewMode || isEditMode) && detail ? (
<Button variant="outline" size="sm" onClick={handleInspection}>
<ClipboardCheck className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleInspection}>
<ClipboardCheck className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button variant="outline" size="sm" onClick={handleViewInspectionReport}>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
</div>
) : undefined;
// 에러 상태 표시 (view/edit 모드에서만)
@@ -769,7 +790,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
/>
*/}
{/* 수입검사 성적서 모달 */}
{/* 수입검사 성적서 모달 (읽기 전용) */}
<InspectionModalV2
isOpen={isInspectionModalOpen}
onClose={() => setIsInspectionModalOpen(false)}
@@ -789,6 +810,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
itemName={detail?.itemName}
specification={detail?.specification}
supplier={detail?.supplier}
readOnly={true}
/>
{/* 수입검사 입력 모달 */}
<ImportInspectionInputModal
open={isImportInspectionModalOpen}
onOpenChange={setIsImportInspectionModalOpen}
productName={detail?.itemName}
specification={detail?.specification}
onComplete={handleImportInspectionComplete}
/>
</>
);

View File

@@ -12,7 +12,7 @@
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { Plus, Trash2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
@@ -27,6 +27,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { qualityInspectionCreateConfig } from './inspectionConfig';
import { toast } from 'sonner';
@@ -34,7 +40,8 @@ import { createInspection } from './actions';
import { isOrderSpecSame, calculateOrderSummary } from './mockData';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { OrderSelectModal } from './OrderSelectModal';
import type { InspectionFormData, OrderSettingItem, OrderSelectItem } from './types';
import { ProductInspectionInputModal } from './ProductInspectionInputModal';
import type { InspectionFormData, OrderSettingItem, OrderSelectItem, OrderGroup, ProductInspectionData } from './types';
import {
emptyConstructionSite,
emptyMaterialDistributor,
@@ -64,11 +71,17 @@ export function InspectionCreate() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [orderModalOpen, setOrderModalOpen] = useState(false);
// 제품검사 입력 모달
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
const [selectedOrderItem, setSelectedOrderItem] = useState<OrderSettingItem | null>(null);
// ===== 수주 선택 처리 =====
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,
@@ -119,6 +132,67 @@ export function InspectionCreate() {
[formData.orderItems]
);
// ===== 수주 항목을 그룹별로 묶기 (아코디언용) =====
const groupOrderItems = useCallback((items: OrderSettingItem[]): OrderGroup[] => {
const groups: Record<string, OrderGroup> = {};
items.forEach((item) => {
const key = item.orderNumber;
if (!groups[key]) {
groups[key] = {
orderNumber: item.orderNumber,
siteName: item.siteName,
deliveryDate: item.deliveryDate,
locationCount: 0,
items: [],
};
}
groups[key].items.push(item);
groups[key].locationCount = groups[key].items.length;
});
return Object.values(groups);
}, []);
const orderGroups = useMemo(
() => groupOrderItems(formData.orderItems),
[formData.orderItems, groupOrderItems]
);
// ===== 제품검사 입력 핸들러 =====
const handleOpenInspectionInput = useCallback((item: OrderSettingItem) => {
setSelectedOrderItem(item);
setInspectionInputOpen(true);
}, []);
const handleInspectionComplete = useCallback((data: ProductInspectionData) => {
if (!selectedOrderItem) return;
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === selectedOrderItem.id
? { ...item, inspectionData: data }
: item
),
}));
toast.success('검사 데이터가 저장되었습니다.');
setSelectedOrderItem(null);
}, [selectedOrderItem]);
// ===== 시공규격/변경사유 수정 핸들러 =====
const handleUpdateOrderItemField = useCallback((
itemId: string,
field: 'constructionWidth' | 'constructionHeight' | 'changeReason',
value: string | number
) => {
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
}, []);
// ===== 취소 =====
const handleCancel = useCallback(() => {
router.push('/quality/inspections');
@@ -153,69 +227,117 @@ export function InspectionCreate() {
}
}, [formData, router]);
// ===== 수주 설정 테이블 =====
const renderOrderTable = (items: OrderSettingItem[]) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const isSame = isOrderSpecSame(item);
return (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.floor}</TableCell>
<TableCell>{item.symbol}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">{item.constructionWidth}</TableCell>
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-600"
type="button"
onClick={() => handleRemoveOrderItem(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={11} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
// ===== 수주 설정 아코디언 =====
const renderOrderAccordion = (groups: OrderGroup[]) => {
if (groups.length === 0) {
return (
<div className="text-center text-muted-foreground py-8">
.
</div>
);
}
return (
<Accordion type="multiple" className="w-full">
{groups.map((group) => (
<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>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 mr-4 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">
{/* 하위 레벨: 테이블 */}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="w-40"></TableHead>
<TableHead className="w-24 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.floor || '-'}</TableCell>
<TableCell>{item.symbol || '-'}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={item.constructionWidth || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'constructionWidth', Number(e.target.value))
}
className="h-8 w-20 text-center"
/>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={item.constructionHeight || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'constructionHeight', Number(e.target.value))
}
className="h-8 w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
value={item.changeReason || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'changeReason', e.target.value)
}
className="h-8"
placeholder="변경사유 입력"
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInspectionInput(item)}
className="h-7"
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
// ===== 폼 렌더링 =====
const renderFormContent = useCallback(() => (
@@ -526,12 +648,12 @@ export function InspectionCreate() {
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</CardHeader>
<CardContent className="p-0">
{renderOrderTable(formData.orderItems)}
<CardContent className="p-4">
{renderOrderAccordion(orderGroups)}
</CardContent>
</Card>
</div>
), [formData, orderSummary, updateField, updateNested, handleRemoveOrderItem, orderModalOpen]);
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
// 이미 선택된 수주 ID 목록
const excludeOrderIds = useMemo(
@@ -558,6 +680,17 @@ export function InspectionCreate() {
onSelect={handleOrderSelect}
excludeIds={excludeOrderIds}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}
onOpenChange={setInspectionInputOpen}
orderItemId={selectedOrderItem?.id || ''}
productName="방화셔터"
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
initialData={selectedOrderItem?.inspectionData}
onComplete={handleInspectionComplete}
/>
</>
);
}

View File

@@ -19,6 +19,8 @@ import {
Loader2,
Plus,
Trash2,
ChevronDown,
ClipboardCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -44,6 +46,12 @@ import {
AlertDialogHeader,
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';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
@@ -62,6 +70,7 @@ import {
} from './mockData';
import { InspectionRequestModal } from './documents/InspectionRequestModal';
import { InspectionReportModal } from './documents/InspectionReportModal';
import { ProductInspectionInputModal } from './ProductInspectionInputModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { OrderSelectModal } from './OrderSelectModal';
import type {
@@ -69,6 +78,8 @@ import type {
InspectionFormData,
OrderSettingItem,
OrderSelectItem,
OrderGroup,
ProductInspectionData,
} from './types';
interface InspectionDetailProps {
@@ -122,6 +133,10 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
const [requestDocOpen, setRequestDocOpen] = useState(false);
const [reportDocOpen, setReportDocOpen] = useState(false);
// 제품검사 입력 모달
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
const [selectedOrderItem, setSelectedOrderItem] = useState<OrderSettingItem | null>(null);
// ===== API 데이터 로드 =====
const loadInspection = useCallback(async () => {
setIsLoading(true);
@@ -230,6 +245,8 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
const newOrderItems: OrderSettingItem[] = items.map((item) => ({
id: item.id,
orderNumber: item.orderNumber,
siteName: item.siteName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
@@ -262,6 +279,68 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
return calculateOrderSummary(items);
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
// ===== 수주 항목을 그룹별로 묶기 (아코디언용) =====
const groupOrderItems = useCallback((items: OrderSettingItem[]): OrderGroup[] => {
const groups: Record<string, OrderGroup> = {};
items.forEach((item) => {
const key = item.orderNumber;
if (!groups[key]) {
groups[key] = {
orderNumber: item.orderNumber,
siteName: item.siteName,
deliveryDate: item.deliveryDate,
locationCount: 0,
items: [],
};
}
groups[key].items.push(item);
groups[key].locationCount = groups[key].items.length;
});
return Object.values(groups);
}, []);
const orderGroups = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
return groupOrderItems(items);
}, [isEditMode, formData.orderItems, inspection?.orderItems, groupOrderItems]);
// ===== 제품검사 입력 핸들러 =====
const handleOpenInspectionInput = useCallback((item: OrderSettingItem) => {
setSelectedOrderItem(item);
setInspectionInputOpen(true);
}, []);
const handleInspectionComplete = useCallback((data: ProductInspectionData) => {
if (!selectedOrderItem) return;
// formData의 해당 orderItem에 inspectionData 저장
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === selectedOrderItem.id
? { ...item, inspectionData: data }
: item
),
}));
toast.success('검사 데이터가 저장되었습니다.');
setSelectedOrderItem(null);
}, [selectedOrderItem]);
// ===== 시공규격/변경사유 수정 핸들러 (수정 모드) =====
const handleUpdateOrderItemField = useCallback((
itemId: string,
field: 'constructionWidth' | 'constructionHeight' | 'changeReason',
value: string | number
) => {
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
}, []);
// ===== 정보 필드 렌더링 헬퍼 =====
const renderInfoField = (label: string, value: React.ReactNode) => (
<div>
@@ -270,126 +349,212 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
);
// ===== 수주 설정 테이블 =====
const renderOrderTable = (items: OrderSettingItem[]) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const isSame = isOrderSpecSame(item);
return (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.floor}</TableCell>
<TableCell>{item.symbol}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">{item.constructionWidth}</TableCell>
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
</TableRow>
);
})}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
// ===== 수주 설정 아코디언 (조회 모드) =====
const renderOrderAccordion = (groups: OrderGroup[]) => {
if (groups.length === 0) {
return (
<div className="text-center text-muted-foreground py-8">
.
</div>
);
}
// ===== 수주 설정 테이블 (편집용 - 삭제 버튼 포함) =====
const renderEditOrderTable = (items: OrderSettingItem[]) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-12 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const isSame = isOrderSpecSame(item);
return (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.floor}</TableCell>
<TableCell>{item.symbol}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">{item.constructionWidth}</TableCell>
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-600"
type="button"
onClick={() => handleRemoveOrderItem(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={11} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
return (
<Accordion type="multiple" className="w-full">
{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">
{/* 하위 레벨: 테이블 */}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead></TableHead>
<TableHead className="w-24 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.floor || '-'}</TableCell>
<TableCell>{item.symbol || '-'}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">{item.constructionWidth}</TableCell>
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
<TableCell className="text-center">
{item.inspectionData ? (
<Badge className="bg-green-100 text-green-800 border-0">
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
// ===== 헤더 액션 버튼 (view 모드) =====
// ===== 수주 설정 아코디언 (편집 모드 - 삭제, 시공규격/변경사유 수정 가능) =====
const renderEditOrderAccordion = (groups: OrderGroup[]) => {
if (groups.length === 0) {
return (
<div className="text-center text-muted-foreground py-8">
.
</div>
);
}
return (
<Accordion type="multiple" className="w-full">
{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>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 mr-4 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">
{/* 하위 레벨: 테이블 (시공규격, 변경사유 편집 가능) */}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12 text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="text-center w-24"> </TableHead>
<TableHead className="w-40"></TableHead>
<TableHead className="w-24 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.floor || '-'}</TableCell>
<TableCell>{item.symbol || '-'}</TableCell>
<TableCell className="text-center">{item.orderWidth}</TableCell>
<TableCell className="text-center">{item.orderHeight}</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={item.constructionWidth || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'constructionWidth', Number(e.target.value))
}
className="h-8 w-20 text-center"
/>
</TableCell>
<TableCell className="text-center">
<Input
type="number"
value={item.constructionHeight || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'constructionHeight', Number(e.target.value))
}
className="h-8 w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
value={item.changeReason || ''}
onChange={(e) =>
handleUpdateOrderItemField(item.id, 'changeReason', e.target.value)
}
className="h-8"
placeholder="변경사유 입력"
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInspectionInput(item)}
className="h-7"
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
};
// ===== 헤더 액션 버튼 (view 및 edit 모드) =====
const headerActions = useMemo(() => {
if (isEditMode || !inspection) return null;
if (!inspection) return null;
// Edit 모드: 검사제품요청서 보기, 제품검사하기 버튼만 표시
if (isEditMode) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setRequestDocOpen(true)}>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
<Button variant="outline" size="sm" onClick={() => setReportDocOpen(true)}>
<PlayCircle className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</>
);
}
// View 모드: 검사제품요청서 보기, 제품검사하기, 검사 완료 버튼 표시
return (
<>
<Button variant="outline" size="sm" onClick={() => setRequestDocOpen(true)}>
@@ -540,13 +705,13 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</CardHeader>
<CardContent className="p-0">
{renderOrderTable(inspection.orderItems)}
<CardContent className="p-4">
{renderOrderAccordion(orderGroups)}
</CardContent>
</Card>
</div>
);
}, [inspection, orderSummary]);
}, [inspection, orderSummary, orderGroups, handleOpenInspectionInput]);
// ===== Edit 모드 폼 렌더링 =====
const renderFormContent = useCallback(() => {
@@ -846,13 +1011,13 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</CardHeader>
<CardContent className="p-0">
{renderEditOrderTable(formData.orderItems)}
<CardContent className="p-4">
{renderEditOrderAccordion(orderGroups)}
</CardContent>
</Card>
</div>
);
}, [inspection, formData, orderSummary, updateField, updateNested, handleRemoveOrderItem, orderModalOpen]);
}, [inspection, formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
// ===== 모드 & Config =====
const mode = isEditMode ? 'edit' : 'view';
@@ -942,7 +1107,20 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<InspectionReportModal
open={reportDocOpen}
onOpenChange={setReportDocOpen}
data={inspection ? buildReportDocumentData(inspection) : null}
data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null}
inspection={inspection}
orderItems={isEditMode ? formData.orderItems : inspection?.orderItems}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}
onOpenChange={setInspectionInputOpen}
orderItemId={selectedOrderItem?.id || ''}
productName="방화셔터"
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
initialData={selectedOrderItem?.inspectionData}
onComplete={handleInspectionComplete}
/>
</>
);

View File

@@ -0,0 +1,485 @@
'use client';
/**
* 제품검사 입력 모달
*
* 수주 설정 정보 아코디언의 "검사하기" 버튼에서 열림
* 검사 결과를 입력하면 해당 수주 항목의 inspectionData에 저장
*
* 검사 항목:
* - 제품 사진 (2장)
* - 겉모양 검사: 가공상태, 재봉상태, 조립상태, 연기차단재, 하단마감재, 모터
* - 재질/치수 검사: 재질, 길이, 높이, 가이드레일 출간격, 하단마감재 간격
* - 시험 검사: 내화시험, 차연시험, 개폐시험, 내충격시험
* - 특이사항
*/
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { Camera, X, Plus } from 'lucide-react';
import type { ProductInspectionData } from './types';
interface ProductInspectionInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
orderItemId: string;
productName?: string;
specification?: string;
initialData?: ProductInspectionData;
onComplete: (data: ProductInspectionData) => void;
}
// 적합/부적합 버튼 컴포넌트
function PassFailToggle({
value,
onChange,
disabled = false,
}: {
value: 'pass' | 'fail' | null;
onChange: (v: 'pass' | 'fail') => void;
disabled?: boolean;
}) {
return (
<div className="flex gap-2">
<button
type="button"
onClick={() => !disabled && onChange('pass')}
disabled={disabled}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'pass'
? 'bg-orange-500 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
</button>
<button
type="button"
onClick={() => !disabled && onChange('fail')}
disabled={disabled}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
value === 'fail'
? 'bg-gray-900 text-white'
: 'bg-gray-700 text-white hover:bg-gray-600',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
</button>
</div>
);
}
// 사진 업로드 컴포넌트
function ImageUploader({
images,
onImagesChange,
maxImages = 2,
}: {
images: string[];
onImagesChange: (images: string[]) => void;
maxImages?: number;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file) => {
if (images.length >= maxImages) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
onImagesChange([...images, base64]);
};
reader.readAsDataURL(file);
});
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleRemove = (index: number) => {
onImagesChange(images.filter((_, i) => i !== index));
};
return (
<div className="space-y-2">
<Label className="text-gray-300"> ({images.length}/{maxImages})</Label>
<div className="flex gap-3">
{images.map((img, index) => (
<div
key={index}
className="relative w-24 h-24 rounded-lg overflow-hidden border border-gray-700"
>
<img src={img} alt={`제품 사진 ${index + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => handleRemove(index)}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center"
>
<X className="w-3 h-3 text-white" />
</button>
</div>
))}
{images.length < maxImages && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-24 h-24 rounded-lg border-2 border-dashed border-gray-600 flex flex-col items-center justify-center text-gray-400 hover:border-gray-500 hover:text-gray-300 transition-colors"
>
<Camera className="w-6 h-6 mb-1" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}
// 검사 항목 그룹 컴포넌트
function InspectionGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-3">
<div className="text-sm font-medium text-orange-400 border-b border-gray-700 pb-2">
{title}
</div>
<div className="space-y-4 pl-2">{children}</div>
</div>
);
}
// 검사 항목 행 컴포넌트
function InspectionRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between">
<Label className="text-gray-300 text-sm">{label}</Label>
{children}
</div>
);
}
// 측정값 입력 + 판정 컴포넌트
function MeasurementInput({
value,
judgment,
onValueChange,
onJudgmentChange,
placeholder = '측정값',
unit = 'mm',
}: {
value: number | null;
judgment: 'pass' | 'fail' | null;
onValueChange: (v: number | null) => void;
onJudgmentChange: (v: 'pass' | 'fail') => void;
placeholder?: string;
unit?: string;
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Input
type="number"
value={value ?? ''}
onChange={(e) => onValueChange(e.target.value === '' ? null : parseFloat(e.target.value))}
placeholder={placeholder}
className="w-24 bg-gray-800 border-gray-700 text-white text-sm"
/>
<span className="text-gray-400 text-xs">{unit}</span>
</div>
<PassFailToggle value={judgment} onChange={onJudgmentChange} />
</div>
);
}
const INITIAL_DATA: ProductInspectionData = {
productName: '',
specification: '',
productImages: [],
// 겉모양 검사 (기본값: 적합)
appearanceProcessing: 'pass',
appearanceSewing: 'pass',
appearanceAssembly: 'pass',
appearanceSmokeBarrier: 'pass',
appearanceBottomFinish: 'pass',
motor: 'pass',
// 재질/치수 검사 (기본값: 적합)
material: 'pass',
lengthValue: null,
lengthJudgment: 'pass',
heightValue: null,
heightJudgment: 'pass',
guideRailGapValue: null,
guideRailGap: 'pass',
bottomFinishGapValue: null,
bottomFinishGap: 'pass',
// 시험 검사 (기본값: 적합)
fireResistanceTest: 'pass',
smokeLeakageTest: 'pass',
openCloseTest: 'pass',
impactTest: 'pass',
// 특이사항
hasSpecialNotes: false,
specialNotes: '',
};
export function ProductInspectionInputModal({
open,
onOpenChange,
orderItemId,
productName = '',
specification = '',
initialData,
onComplete,
}: ProductInspectionInputModalProps) {
const [formData, setFormData] = useState<ProductInspectionData>({
...INITIAL_DATA,
productName,
specification,
});
useEffect(() => {
if (open) {
if (initialData) {
setFormData(initialData);
} else {
setFormData({
...INITIAL_DATA,
productName,
specification,
});
}
}
}, [open, productName, specification, initialData]);
const updateField = <K extends keyof ProductInspectionData>(
key: K,
value: ProductInspectionData[K]
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleComplete = () => {
onComplete(formData);
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[600px] bg-gray-900 text-white border-gray-700 max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-white text-lg font-bold">
#
</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-4 overflow-y-auto flex-1 pr-2">
{/* 제품명 / 규격 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
value={formData.productName}
readOnly
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
value={formData.specification}
readOnly
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
</div>
{/* 제품 사진 */}
<ImageUploader
images={formData.productImages}
onImagesChange={(images) => updateField('productImages', images)}
maxImages={2}
/>
{/* 겉모양 검사 */}
<InspectionGroup title="겉모양 검사">
<InspectionRow label="가공상태">
<PassFailToggle
value={formData.appearanceProcessing}
onChange={(v) => updateField('appearanceProcessing', v)}
/>
</InspectionRow>
<InspectionRow label="재봉상태">
<PassFailToggle
value={formData.appearanceSewing}
onChange={(v) => updateField('appearanceSewing', v)}
/>
</InspectionRow>
<InspectionRow label="조립상태">
<PassFailToggle
value={formData.appearanceAssembly}
onChange={(v) => updateField('appearanceAssembly', v)}
/>
</InspectionRow>
<InspectionRow label="연기차단재">
<PassFailToggle
value={formData.appearanceSmokeBarrier}
onChange={(v) => updateField('appearanceSmokeBarrier', v)}
/>
</InspectionRow>
<InspectionRow label="하단마감재">
<PassFailToggle
value={formData.appearanceBottomFinish}
onChange={(v) => updateField('appearanceBottomFinish', v)}
/>
</InspectionRow>
<InspectionRow label="모터">
<PassFailToggle
value={formData.motor}
onChange={(v) => updateField('motor', v)}
/>
</InspectionRow>
</InspectionGroup>
{/* 재질/치수 검사 */}
<InspectionGroup title="재질/치수 검사">
<InspectionRow label="재질">
<PassFailToggle
value={formData.material}
onChange={(v) => updateField('material', v)}
/>
</InspectionRow>
<InspectionRow label="길이">
<MeasurementInput
value={formData.lengthValue}
judgment={formData.lengthJudgment}
onValueChange={(v) => updateField('lengthValue', v)}
onJudgmentChange={(v) => updateField('lengthJudgment', v)}
/>
</InspectionRow>
<InspectionRow label="높이">
<MeasurementInput
value={formData.heightValue}
judgment={formData.heightJudgment}
onValueChange={(v) => updateField('heightValue', v)}
onJudgmentChange={(v) => updateField('heightJudgment', v)}
/>
</InspectionRow>
<InspectionRow label="가이드레일 홈간격">
<MeasurementInput
value={formData.guideRailGapValue}
judgment={formData.guideRailGap}
onValueChange={(v) => updateField('guideRailGapValue', v)}
onJudgmentChange={(v) => updateField('guideRailGap', v)}
/>
</InspectionRow>
<InspectionRow label="하단마감재 간격">
<MeasurementInput
value={formData.bottomFinishGapValue}
judgment={formData.bottomFinishGap}
onValueChange={(v) => updateField('bottomFinishGapValue', v)}
onJudgmentChange={(v) => updateField('bottomFinishGap', v)}
/>
</InspectionRow>
</InspectionGroup>
{/* 시험 검사 */}
<InspectionGroup title="시험 검사">
<InspectionRow label="내화시험">
<PassFailToggle
value={formData.fireResistanceTest}
onChange={(v) => updateField('fireResistanceTest', v)}
/>
</InspectionRow>
<InspectionRow label="차연시험">
<PassFailToggle
value={formData.smokeLeakageTest}
onChange={(v) => updateField('smokeLeakageTest', v)}
/>
</InspectionRow>
<InspectionRow label="개폐시험">
<PassFailToggle
value={formData.openCloseTest}
onChange={(v) => updateField('openCloseTest', v)}
/>
</InspectionRow>
<InspectionRow label="내충격시험">
<PassFailToggle
value={formData.impactTest}
onChange={(v) => updateField('impactTest', v)}
/>
</InspectionRow>
</InspectionGroup>
{/* 특이사항 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
value={formData.specialNotes}
onChange={(e) => updateField('specialNotes', e.target.value)}
placeholder="특이사항을 입력하세요"
className="bg-gray-800 border-gray-700 text-white min-h-[80px]"
/>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex gap-3 mt-6 pt-4 border-t border-gray-700">
<Button
variant="outline"
onClick={handleCancel}
className="flex-1 bg-gray-800 border-gray-700 text-white hover:bg-gray-700"
>
</Button>
<Button
onClick={handleComplete}
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -86,6 +86,8 @@ interface ProductInspectionApi {
order_items: Array<{
id: string;
order_number: string;
site_name: string;
delivery_date: string;
floor: string;
symbol: string;
order_width: number;
@@ -218,6 +220,8 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
orderItems: (api.order_items || []).map((item) => ({
id: item.id,
orderNumber: item.order_number,
siteName: item.site_name || '',
deliveryDate: item.delivery_date || '',
floor: item.floor,
symbol: item.symbol,
orderWidth: item.order_width,

View File

@@ -14,7 +14,7 @@
* - 특이사항 + 종합판정(자동계산)을 테이블 마지막 행으로 포함
*/
import { useState, useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { ConstructionApprovalTable } from '@/components/document-system';
import type { InspectionReportDocument as InspectionReportDocumentType, ReportInspectionItem } from '../types';
@@ -57,8 +57,8 @@ function buildCoverageMap(items: ReportInspectionItem[], field: 'methodSpan' | '
}
export function InspectionReportDocument({ data }: InspectionReportDocumentProps) {
// 판정 인터랙션을 위한 stateful items
const [items, setItems] = useState<ReportInspectionItem[]>(() => data.inspectionItems);
// 읽기 전용 - data.inspectionItems 그대로 사용
const items = data.inspectionItems;
const groups = useMemo(() => groupItemsByNo(items), [items]);
@@ -104,27 +104,6 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
return '합격';
}, [items, judgmentCoverage.covered]);
// 측정값 변경 핸들러
const handleMeasuredValueChange = useCallback((flatIdx: number, value: string) => {
setItems(prev => {
const next = [...prev];
next[flatIdx] = { ...next[flatIdx], measuredValue: value };
return next;
});
}, []);
// 판정 클릭 핸들러
const handleJudgmentClick = useCallback((flatIdx: number, value: '적합' | '부적합') => {
setItems(prev => {
const next = [...prev];
next[flatIdx] = {
...next[flatIdx],
judgment: next[flatIdx].judgment === value ? undefined : value,
};
return next;
});
}, []);
return (
<div className="bg-white p-8 min-h-full text-[11px]">
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
@@ -193,13 +172,16 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
{/* 제품 사진 */}
<div className="border border-gray-400 mb-4">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"> </div>
<div className="p-4 flex items-center justify-center min-h-[200px]">
{data.productImage ? (
<img
src={data.productImage}
alt="제품 사진"
className="max-h-[300px] object-contain"
/>
<div className="p-4 flex items-center justify-center gap-4 min-h-[200px]">
{data.productImages && data.productImages.length > 0 ? (
data.productImages.map((img, index) => (
<img
key={index}
src={img}
alt={`제품 사진 ${index + 1}`}
className="max-h-[250px] max-w-[45%] object-contain border border-gray-300 rounded"
/>
))
) : (
<div className="text-gray-400 text-center">
<div className="border-2 border-dashed border-gray-300 p-8 rounded">
@@ -358,27 +340,14 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
if (measuredValueCoverage.covered.has(flatIdx)) {
return null;
}
// 편집 가능한 측정값 셀 → input 렌더
if (row.editable) {
return (
<td className="border border-gray-400 px-0.5 py-0.5 text-center">
<input
type="text"
value={row.measuredValue || ''}
onChange={(e) => handleMeasuredValueChange(flatIdx, e.target.value)}
className="w-full h-full px-1 py-0.5 text-center text-[11px] border border-gray-300 rounded-sm focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-200"
placeholder=""
/>
</td>
);
}
// 읽기 전용 - 측정값 표시만
return (
<td className="border border-gray-400 px-2 py-1 text-center">
{row.measuredValue || ''}
</td>
);
})()}
{/* 판정 */}
{/* 판정 - 읽기 전용 */}
{renderJudgment && (
row.hideJudgment ? (
<td
@@ -391,24 +360,12 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
rowSpan={judgmentRowSpan}
>
<div className="flex items-center justify-center gap-1 text-[10px]">
<button
type="button"
className={`cursor-pointer hover:opacity-80 ${
row.judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'
}`}
onClick={() => handleJudgmentClick(flatIdx, '적합')}
>
<span className={row.judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
{row.judgment === '적합' ? '■' : '□'}
</button>
<button
type="button"
className={`cursor-pointer hover:opacity-80 ${
row.judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'
}`}
onClick={() => handleJudgmentClick(flatIdx, '부적합')}
>
</span>
<span className={row.judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
{row.judgment === '부적합' ? '■' : '□'}
</button>
</span>
</div>
</td>
)

View File

@@ -1,30 +1,155 @@
'use client';
/**
* 제품검사성적서 모달
* 제품검사성적서 모달 (읽기전용)
* DocumentViewer를 사용하여 문서 표시 + 인쇄/PDF 기능 제공
* 검사 입력은 별도 ProductInspectionInputModal에서 진행
*
* 페이지네이션: 층(orderItem)별로 검사성적서 표시
*/
import { Save } from 'lucide-react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { InspectionReportDocument } from './InspectionReportDocument';
import type { InspectionReportDocument as InspectionReportDocumentType } from '../types';
import type {
InspectionReportDocument as InspectionReportDocumentType,
OrderSettingItem,
ProductInspection
} from '../types';
import { buildReportDocumentDataForItem } from '../mockData';
interface InspectionReportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: InspectionReportDocumentType | null;
onSave?: () => void;
/** 페이지네이션용: 원본 inspection 데이터 */
inspection?: ProductInspection | null;
/** 페이지네이션용: orderItems (수정 모드에서는 formData.orderItems) */
orderItems?: OrderSettingItem[];
}
export function InspectionReportModal({
open,
onOpenChange,
data,
onSave,
inspection,
orderItems,
}: InspectionReportModalProps) {
if (!data) return null;
const [currentPage, setCurrentPage] = useState(1);
const [inputPage, setInputPage] = useState('1');
// 총 페이지 수 (orderItems가 있으면 그 길이, 아니면 1)
const totalPages = useMemo(() => {
if (orderItems && orderItems.length > 0) {
return orderItems.length;
}
return 1;
}, [orderItems]);
// 모달 열릴 때 페이지 초기화
useEffect(() => {
if (open) {
setCurrentPage(1);
setInputPage('1');
}
}, [open]);
// 현재 페이지에 해당하는 문서 데이터
const currentData = useMemo(() => {
// 페이지네이션이 가능한 경우 (inspection과 orderItems 모두 있음)
if (inspection && orderItems && orderItems.length > 0) {
const currentItem = orderItems[currentPage - 1];
if (currentItem) {
return buildReportDocumentDataForItem(inspection, currentItem);
}
}
// 기본: data prop 사용
return data;
}, [inspection, orderItems, currentPage, data]);
// 이전 페이지
const handlePrevPage = useCallback(() => {
if (currentPage > 1) {
const newPage = currentPage - 1;
setCurrentPage(newPage);
setInputPage(String(newPage));
}
}, [currentPage]);
// 다음 페이지
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
const newPage = currentPage + 1;
setCurrentPage(newPage);
setInputPage(String(newPage));
}
}, [currentPage, totalPages]);
// 페이지 입력 후 이동
const handleGoToPage = useCallback(() => {
const pageNum = parseInt(inputPage, 10);
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
setCurrentPage(pageNum);
} else {
// 잘못된 입력 → 현재 페이지로 복원
setInputPage(String(currentPage));
}
}, [inputPage, totalPages, currentPage]);
// 엔터키로 이동
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleGoToPage();
}
}, [handleGoToPage]);
if (!currentData) return null;
// 페이지네이션 UI 컴포넌트
const paginationUI = totalPages > 1 ? (
<div className="flex items-center justify-center gap-2 py-3 border-t bg-gray-100 print:hidden">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={currentPage <= 1}
className="h-8"
>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2">
<Input
value={inputPage}
onChange={(e) => setInputPage(e.target.value)}
onKeyDown={handleKeyDown}
className="w-14 h-8 text-center"
/>
<span className="text-sm text-muted-foreground">/ {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleGoToPage}
className="h-8"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage >= totalPages}
className="h-8"
>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
) : null;
return (
<DocumentViewer
@@ -33,17 +158,12 @@ export function InspectionReportModal({
open={open}
onOpenChange={onOpenChange}
pdfMeta={{
documentNumber: data.documentNumber,
createdDate: data.createdDate,
documentNumber: currentData.documentNumber,
createdDate: currentData.createdDate,
}}
toolbarExtra={
<Button onClick={onSave} size="sm">
<Save className="w-4 h-4 mr-1.5" />
</Button>
}
toolbarExtra={paginationUI}
>
<InspectionReportDocument data={data} />
<InspectionReportDocument data={currentData} />
</DocumentViewer>
);
}

View File

@@ -11,7 +11,6 @@
*/
import { ConstructionApprovalTable } from '@/components/document-system';
import { isOrderSpecSame } from '../mockData';
import type { InspectionRequestDocument as InspectionRequestDocumentType } from '../types';
interface InspectionRequestDocumentProps {
@@ -179,49 +178,65 @@ export function InspectionRequestDocument({ data }: InspectionRequestDocumentPro
</div>
</div>
{/* 검사 요청 시 필독 */}
<div className="border border-gray-400 mb-4">
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400"> </div>
<div className="px-4 py-3 text-[11px] leading-relaxed text-center">
<p>
,
<br />
.
<br />
<span className="text-red-600 font-medium"> .</span>
</p>
<p className="mt-2 text-gray-600">
( .)
</p>
</div>
</div>
{/* 검사대상 사전 고지 정보 */}
<div className="border border-gray-400 mb-4">
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400"> </div>
<div className="bg-gray-800 text-white text-center py-1 font-bold border-b border-gray-400"> </div>
<table className="w-full">
<thead>
{/* 1단: 오픈사이즈 병합 */}
<tr className="bg-gray-100 border-b border-gray-300">
<th className="border-r border-gray-400 px-2 py-1 w-12 text-center" rowSpan={3}>No.</th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center" rowSpan={3}></th>
<th className="border-r border-gray-400 px-2 py-1 w-20 text-center" rowSpan={3}></th>
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={4}></th>
<th className="px-2 py-1 text-center" rowSpan={3}></th>
</tr>
{/* 2단: 발주 규격, 시공후 규격 */}
<tr className="bg-gray-100 border-b border-gray-300">
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}> </th>
<th className="border-r border-gray-400 px-2 py-1 text-center" colSpan={2}> </th>
</tr>
{/* 3단: 가로, 세로 */}
<tr className="bg-gray-100 border-b border-gray-400">
<th className="border-r border-gray-400 px-2 py-1 w-10 text-center">No.</th>
<th className="border-r border-gray-400 px-2 py-1"></th>
<th className="border-r border-gray-400 px-2 py-1"></th>
<th className="border-r border-gray-400 px-2 py-1"></th>
<th className="border-r border-gray-400 px-2 py-1 text-center"> </th>
<th className="border-r border-gray-400 px-2 py-1 text-center"> </th>
<th className="border-r border-gray-400 px-2 py-1 text-center"> </th>
<th className="border-r border-gray-400 px-2 py-1 text-center"> </th>
<th className="border-r border-gray-400 px-2 py-1 text-center"></th>
<th className="px-2 py-1"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border-r border-gray-400 px-2 py-1 w-16 text-center"></th>
</tr>
</thead>
<tbody>
{data.priorNoticeItems.map((item, index) => {
const isSame = isOrderSpecSame(item);
return (
<tr key={item.id} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-2 py-1">{item.orderNumber}</td>
<td className="border-r border-gray-300 px-2 py-1">{item.floor}</td>
<td className="border-r border-gray-300 px-2 py-1">{item.symbol}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderHeight}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionHeight}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center font-medium">
<span className={isSame ? 'text-green-700' : 'text-red-700'}>
{isSame ? '일치' : '불일치'}
</span>
</td>
<td className="px-2 py-1">{item.changeReason || '-'}</td>
</tr>
);
})}
{data.priorNoticeItems.map((item, index) => (
<tr key={item.id} className="border-b border-gray-300">
<td className="border-r border-gray-300 px-2 py-1 text-center">{index + 1}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.floor}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.symbol}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.orderHeight}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionWidth}</td>
<td className="border-r border-gray-300 px-2 py-1 text-center">{item.constructionHeight}</td>
<td className="px-2 py-1">{item.changeReason || '-'}</td>
</tr>
))}
{data.priorNoticeItems.length === 0 && (
<tr>
<td colSpan={10} className="px-2 py-4 text-center text-gray-400">
<td colSpan={8} className="px-2 py-4 text-center text-gray-400">
.
</td>
</tr>

View File

@@ -8,6 +8,7 @@ import type {
InspectionRequestDocument,
InspectionReportDocument,
ReportInspectionItem,
ProductInspectionData,
} from './types';
// ===== 상태/색상 매핑 =====
@@ -76,6 +77,8 @@ const defaultOrderItems: OrderSettingItem[] = [
{
id: 'oi-1',
orderNumber: '123123',
siteName: '현장명',
deliveryDate: '2026-01-01',
floor: '1층',
symbol: '부호명',
orderWidth: 4100,
@@ -87,6 +90,8 @@ const defaultOrderItems: OrderSettingItem[] = [
{
id: 'oi-2',
orderNumber: '123123',
siteName: '현장명',
deliveryDate: '2026-01-01',
floor: '2층',
symbol: '부호명',
orderWidth: 4100,
@@ -452,25 +457,152 @@ export const mockReportInspectionItems: ReportInspectionItem[] = [
{ no: 9, category: '내충격시험', criteria: '방화상 유해한 파괴, 박리 탈락 유무', method: '', frequency: '' },
];
/** pass/fail → 적합/부적합 변환 */
const convertJudgment = (value: 'pass' | 'fail' | null): '적합' | '부적합' | undefined => {
if (value === 'pass') return '적합';
if (value === 'fail') return '부적합';
return undefined;
};
/** 검사 데이터를 검사항목에 매핑 */
const mapInspectionDataToItems = (
items: ReportInspectionItem[],
inspectionData?: ProductInspectionData
): ReportInspectionItem[] => {
if (!inspectionData) return items;
return items.map((item) => {
const newItem = { ...item };
// 겉모양 검사 매핑
if (item.category === '겉모양') {
if (item.subCategory === '가공상태') {
newItem.judgment = convertJudgment(inspectionData.appearanceProcessing);
} else if (item.subCategory === '재봉상태') {
newItem.judgment = convertJudgment(inspectionData.appearanceSewing);
} else if (item.subCategory === '조립상태') {
newItem.judgment = convertJudgment(inspectionData.appearanceAssembly);
} else if (item.subCategory === '연기차단재') {
newItem.judgment = convertJudgment(inspectionData.appearanceSmokeBarrier);
} else if (item.subCategory === '하단마감재') {
newItem.judgment = convertJudgment(inspectionData.appearanceBottomFinish);
}
}
// 모터 매핑
if (item.category === '모터') {
newItem.judgment = convertJudgment(inspectionData.motor);
}
// 재질 매핑
if (item.category === '재질') {
newItem.judgment = convertJudgment(inspectionData.material);
}
// 치수 검사 매핑
if (item.category === '치수\n(오픈사이즈)') {
if (item.subCategory === '길이') {
newItem.measuredValue = inspectionData.lengthValue?.toString() || '';
newItem.judgment = convertJudgment(inspectionData.lengthJudgment);
} else if (item.subCategory === '높이') {
newItem.measuredValue = inspectionData.heightValue?.toString() || '';
newItem.judgment = convertJudgment(inspectionData.heightJudgment);
} else if (item.subCategory === '가이드레일 간격') {
newItem.measuredValue = inspectionData.guideRailGapValue?.toString() || '';
newItem.judgment = convertJudgment(inspectionData.guideRailGap);
} else if (item.subCategory === '하단막대 간격') {
newItem.measuredValue = inspectionData.bottomFinishGapValue?.toString() || '';
newItem.judgment = convertJudgment(inspectionData.bottomFinishGap);
}
}
// 내화시험 매핑
if (item.category === '내화시험' && item.judgmentSpan) {
newItem.judgment = convertJudgment(inspectionData.fireResistanceTest);
}
// 차연시험 매핑
if (item.category === '차연시험') {
newItem.judgment = convertJudgment(inspectionData.smokeLeakageTest);
}
// 개폐시험 매핑
if (item.category === '개폐시험') {
newItem.judgment = convertJudgment(inspectionData.openCloseTest);
}
// 내충격시험 매핑
if (item.category === '내충격시험') {
newItem.judgment = convertJudgment(inspectionData.impactTest);
}
return newItem;
});
};
/** ProductInspection → InspectionReportDocument 변환 */
export const buildReportDocumentData = (
inspection: ProductInspection
): InspectionReportDocument => ({
documentNumber: `RPT-${inspection.qualityDocNumber}`,
createdDate: inspection.receptionDate,
approvalLine: [
{ role: '작성', name: inspection.scheduleInfo.inspector || inspection.author, department: '' },
{ role: '승인', name: '', department: '' },
],
productName: '방화스크린',
productLotNo: inspection.qualityDocNumber,
productCode: '',
lotSize: String(inspection.locationCount),
client: inspection.client,
inspectionDate: inspection.scheduleInfo.startDate,
siteName: inspection.siteName,
inspector: inspection.scheduleInfo.inspector,
inspectionItems: mockReportInspectionItems,
specialNotes: '',
finalJudgment: inspection.status === '완료' ? '합격' : '합격',
});
inspection: ProductInspection,
orderItems?: OrderSettingItem[]
): InspectionReportDocument => {
// 검사 데이터가 있는 첫 번째 orderItem 찾기
const items = orderItems || inspection.orderItems;
const itemWithData = items.find((item) => item.inspectionData);
const inspectionData = itemWithData?.inspectionData;
// 검사 항목에 검사 데이터 매핑
const mappedInspectionItems = mapInspectionDataToItems(mockReportInspectionItems, inspectionData);
return {
documentNumber: `RPT-${inspection.qualityDocNumber}`,
createdDate: inspection.receptionDate,
approvalLine: [
{ role: '작성', name: inspection.scheduleInfo.inspector || inspection.author, department: '' },
{ role: '승인', name: '', department: '' },
],
productName: inspectionData?.productName || '방화스크린',
productLotNo: inspection.qualityDocNumber,
productCode: '',
lotSize: String(inspection.locationCount),
client: inspection.client,
inspectionDate: inspection.scheduleInfo.startDate,
siteName: inspection.siteName,
inspector: inspection.scheduleInfo.inspector,
productImages: inspectionData?.productImages || [],
inspectionItems: mappedInspectionItems,
specialNotes: inspectionData?.specialNotes || '',
finalJudgment: inspection.status === '완료' ? '합격' : '합격',
};
};
/** 특정 OrderItem에 대한 InspectionReportDocument 빌드 (페이지네이션용) */
export const buildReportDocumentDataForItem = (
inspection: ProductInspection,
orderItem: OrderSettingItem
): InspectionReportDocument => {
const inspectionData = orderItem.inspectionData;
// 검사 항목에 검사 데이터 매핑
const mappedInspectionItems = mapInspectionDataToItems(mockReportInspectionItems, inspectionData);
return {
documentNumber: `RPT-${inspection.qualityDocNumber}-${orderItem.floor}`,
createdDate: inspection.receptionDate,
approvalLine: [
{ role: '작성', name: inspection.scheduleInfo.inspector || inspection.author, department: '' },
{ role: '승인', name: '', department: '' },
],
productName: inspectionData?.productName || '방화스크린',
productLotNo: `${inspection.qualityDocNumber}-${orderItem.floor}`,
productCode: orderItem.symbol || '',
lotSize: '1',
client: inspection.client,
inspectionDate: inspection.scheduleInfo.startDate,
siteName: `${inspection.siteName} (${orderItem.floor})`,
inspector: inspection.scheduleInfo.inspector,
productImages: inspectionData?.productImages || [],
inspectionItems: mappedInspectionItems,
specialNotes: inspectionData?.specialNotes || '',
finalJudgment: inspection.status === '완료' ? '합격' : '합격',
};
};

View File

@@ -55,10 +55,12 @@ export interface InspectionScheduleInfo {
// ===== 수주 관련 =====
// 수주 설정 항목 (상세 페이지 테이블)
// 수주 설정 항목 (상세 페이지 아코디언 내부 테이블)
export interface OrderSettingItem {
id: string;
orderNumber: string; // 수주번호
siteName: string; // 현장명
deliveryDate: string; // 납품일
floor: string; // 층수
symbol: string; // 부호
orderWidth: number; // 수주 규격 - 가로
@@ -66,6 +68,50 @@ export interface OrderSettingItem {
constructionWidth: number; // 시공 규격 - 가로
constructionHeight: number; // 시공 규격 - 세로
changeReason: string; // 변경사유
// 검사 결과 데이터
inspectionData?: ProductInspectionData;
}
// 수주 그룹 (아코디언 상위 레벨)
export interface OrderGroup {
orderNumber: string; // 수주번호
siteName: string; // 현장명
deliveryDate: string; // 납품일
locationCount: number; // 개소
items: OrderSettingItem[]; // 하위 항목들
}
// 제품검사 입력 데이터
export interface ProductInspectionData {
productName: string;
specification: string;
// 제품 사진
productImages: string[];
// 겉모양 검사
appearanceProcessing: 'pass' | 'fail' | null; // 가공상태
appearanceSewing: 'pass' | 'fail' | null; // 재봉상태
appearanceAssembly: 'pass' | 'fail' | null; // 조립상태
appearanceSmokeBarrier: 'pass' | 'fail' | null; // 연기차단재
appearanceBottomFinish: 'pass' | 'fail' | null; // 하단마감재
motor: 'pass' | 'fail' | null; // 모터
// 재질/치수 검사
material: 'pass' | 'fail' | null; // 재질
lengthValue: number | null; // 길이 측정값
lengthJudgment: 'pass' | 'fail' | null; // 길이 판정
heightValue: number | null; // 높이 측정값
heightJudgment: 'pass' | 'fail' | null; // 높이 판정
guideRailGapValue: number | null; // 가이드레일 홈간격 측정값
guideRailGap: 'pass' | 'fail' | null; // 가이드레일 홈간격 판정
bottomFinishGapValue: number | null; // 하단마감재 간격 측정값
bottomFinishGap: 'pass' | 'fail' | null; // 하단마감재 간격 판정
// 시험 검사
fireResistanceTest: 'pass' | 'fail' | null; // 내화시험
smokeLeakageTest: 'pass' | 'fail' | null; // 차연시험
openCloseTest: 'pass' | 'fail' | null; // 개폐시험
impactTest: 'pass' | 'fail' | null; // 내충격시험
// 특이사항
hasSpecialNotes: boolean;
specialNotes: string;
}
// 수주 선택 모달 항목
@@ -227,7 +273,7 @@ export interface InspectionReportDocument {
inspectionDate: string; // 검사일자
siteName: string; // 현장명
inspector: string; // 검사자
productImage?: string; // 제품 사진 URL
productImages?: string[]; // 제품 사진 URLs (2개)
inspectionItems: ReportInspectionItem[];
specialNotes: string; // 특이사항
finalJudgment: JudgmentResult; // 최종 판정