Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 === '완료' ? '합격' : '합격',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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; // 최종 판정
|
||||
|
||||
Reference in New Issue
Block a user