Files
sam-react-prod/src/components/quality/InspectionManagement/InspectionCreate.tsx

790 lines
29 KiB
TypeScript

'use client';
/**
* 제품검사 등록 페이지
*
* 기획서 기반 전면 재구축:
* - 기본정보 입력
* - 건축공사장, 자재유통업자, 공사시공자, 공사감리자 정보
* - 검사 정보 (검사방문요청일, 기간, 검사자, 현장주소)
* - 수주 설정 정보 (수주 선택 → 규격 비교 테이블)
*/
import { useState, useCallback, useMemo } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { Plus, Trash2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
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';
import { createInspection } from './actions';
import { calculateOrderSummary } from './mockData';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
const OrderSelectModal = dynamic(
() => import('./OrderSelectModal').then(mod => ({ default: mod.OrderSelectModal })),
);
const ProductInspectionInputModal = dynamic(
() => import('./ProductInspectionInputModal').then(mod => ({ default: mod.ProductInspectionInputModal })),
);
const SupplierSearchModal = dynamic(
() => import('@/components/material/ReceivingManagement/SupplierSearchModal').then(mod => ({ default: mod.SupplierSearchModal })),
);
import type { InspectionFormData, OrderSettingItem, OrderSelectItem, OrderGroup, ProductInspectionData } from './types';
import {
emptyConstructionSite,
emptyMaterialDistributor,
emptyConstructor,
emptySupervisor,
emptyScheduleInfo,
} from './mockData';
export function InspectionCreate() {
const router = useRouter();
// 폼 상태
const [formData, setFormData] = useState<InspectionFormData>({
qualityDocNumber: '',
siteName: '',
client: '',
manager: '',
managerContact: '',
receptionDate: new Date().toISOString().slice(0, 10),
constructionSite: { ...emptyConstructionSite },
materialDistributor: { ...emptyMaterialDistributor },
constructorInfo: { ...emptyConstructor },
supervisor: { ...emptySupervisor },
scheduleInfo: { ...emptyScheduleInfo },
orderItems: [],
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [clientModalOpen, setClientModalOpen] = useState(false);
// 제품검사 입력 모달
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
const [selectedOrderItem, setSelectedOrderItem] = useState<OrderSettingItem | null>(null);
// ===== 수주 선택 처리 =====
const handleOrderSelect = useCallback((items: OrderSelectItem[]) => {
const newOrderItems: OrderSettingItem[] = items.flatMap((item) =>
item.locations.length > 0
? item.locations.map((loc) => ({
id: `${item.id}-${loc.nodeId}`,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: loc.floor,
symbol: loc.symbol,
orderWidth: loc.orderWidth,
orderHeight: loc.orderHeight,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}))
: [{
id: item.id,
orderId: Number(item.id),
orderNumber: item.orderNumber,
siteName: item.siteName,
clientId: item.clientId,
clientName: item.clientName,
itemId: item.itemId,
itemName: item.itemName,
deliveryDate: item.deliveryDate,
floor: '',
symbol: '',
orderWidth: 0,
orderHeight: 0,
constructionWidth: 0,
constructionHeight: 0,
changeReason: '',
}]
);
setFormData((prev) => {
const updated = { ...prev, orderItems: [...prev.orderItems, ...newOrderItems] };
// 수주처 미선택 상태에서 수주 선택 시 → 수주처 자동 채움
if (!prev.clientId && items.length > 0 && items[0].clientId) {
updated.clientId = items[0].clientId ?? undefined;
updated.client = items[0].clientName || '';
}
return updated;
});
}, []);
// ===== 수주 항목 삭제 =====
const handleRemoveOrderItem = useCallback((itemId: string) => {
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.filter((item) => item.id !== itemId),
}));
}, []);
// ===== 폼 필드 변경 헬퍼 =====
const updateField = useCallback(<K extends keyof InspectionFormData>(
key: K,
value: InspectionFormData[K]
) => {
setFormData((prev) => ({ ...prev, [key]: value }));
}, []);
const updateNested = useCallback((
section: 'constructionSite' | 'materialDistributor' | 'constructorInfo' | 'supervisor' | 'scheduleInfo',
field: string,
value: string
) => {
setFormData((prev) => ({
...prev,
[section]: {
...(prev[section] as unknown as Record<string, unknown>),
[field]: value,
},
}));
}, []);
// ===== 수주 설정 요약 =====
const orderSummary = useMemo(
() => calculateOrderSummary(formData.orderItems),
[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');
}, [router]);
// ===== 등록 제출 =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 필수 필드 검증
if (!formData.siteName.trim()) {
toast.error('현장명은 필수 입력 항목입니다.');
return { success: false, error: '현장명을 입력해주세요.' };
}
if (!formData.clientId) {
toast.error('수주처는 필수 선택 항목입니다.');
return { success: false, error: '수주처를 선택해주세요.' };
}
setIsSubmitting(true);
try {
const result = await createInspection(formData);
if (result.success) {
toast.success('제품검사가 등록되었습니다.');
router.push('/quality/inspections');
return { success: true };
}
return { success: false, error: result.error || '등록에 실패했습니다.' };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '등록 중 오류가 발생했습니다.' };
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
// ===== 수주 설정 아코디언 =====
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(() => (
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label> </Label>
<Input
value={formData.qualityDocNumber}
onChange={(e) => updateField('qualityDocNumber', e.target.value)}
placeholder="품질관리서 번호 입력"
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={formData.siteName}
onChange={(e) => updateField('siteName', e.target.value)}
placeholder="현장명 입력"
/>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<div className="flex gap-2">
<Input
value={formData.client}
readOnly
placeholder="거래처를 선택하세요"
className="cursor-pointer bg-muted/30"
onClick={() => setClientModalOpen(true)}
/>
{formData.clientId && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
updateField('client', '');
updateField('clientId', undefined);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.manager}
onChange={(e) => updateField('manager', e.target.value)}
placeholder="담당자 입력"
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={formData.managerContact}
onChange={(e) => updateField('managerContact', e.target.value)}
placeholder="담당자 연락처 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 건축공사장 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructionSite.siteName}
onChange={(e) => updateNested('constructionSite', 'siteName', e.target.value)}
placeholder="현장명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructionSite.landLocation}
onChange={(e) => updateNested('constructionSite', 'landLocation', e.target.value)}
placeholder="대지위치 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructionSite.lotNumber}
onChange={(e) => updateNested('constructionSite', 'lotNumber', e.target.value)}
placeholder="지번 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 자재유통업자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.materialDistributor.companyName}
onChange={(e) => updateNested('materialDistributor', 'companyName', e.target.value)}
placeholder="회사명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.materialDistributor.companyAddress}
onChange={(e) => updateNested('materialDistributor', 'companyAddress', e.target.value)}
placeholder="회사주소 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.materialDistributor.representativeName}
onChange={(e) => updateNested('materialDistributor', 'representativeName', e.target.value)}
placeholder="대표자명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.materialDistributor.phone}
onChange={(e) => updateNested('materialDistributor', 'phone', e.target.value)}
placeholder="전화번호 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 공사시공자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructorInfo.companyName}
onChange={(e) => updateNested('constructorInfo', 'companyName', e.target.value)}
placeholder="회사명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructorInfo.companyAddress}
onChange={(e) => updateNested('constructorInfo', 'companyAddress', e.target.value)}
placeholder="회사주소 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructorInfo.name}
onChange={(e) => updateNested('constructorInfo', 'name', e.target.value)}
placeholder="성명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.constructorInfo.phone}
onChange={(e) => updateNested('constructorInfo', 'phone', e.target.value)}
placeholder="전화번호 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 공사감리자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.supervisor.officeName}
onChange={(e) => updateNested('supervisor', 'officeName', e.target.value)}
placeholder="사무소명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.supervisor.officeAddress}
onChange={(e) => updateNested('supervisor', 'officeAddress', e.target.value)}
placeholder="사무소주소 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.supervisor.name}
onChange={(e) => updateNested('supervisor', 'name', e.target.value)}
placeholder="성명 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.supervisor.phone}
onChange={(e) => updateNested('supervisor', 'phone', e.target.value)}
placeholder="전화번호 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 검사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<DatePicker
value={formData.scheduleInfo.visitRequestDate}
onChange={(date) => updateNested('scheduleInfo', 'visitRequestDate', date)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={formData.scheduleInfo.startDate}
onChange={(date) => updateNested('scheduleInfo', 'startDate', date)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={formData.scheduleInfo.endDate}
onChange={(date) => updateNested('scheduleInfo', 'endDate', date)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.scheduleInfo.inspector}
onChange={(e) => updateNested('scheduleInfo', 'inspector', e.target.value)}
placeholder="검사자 입력"
/>
</div>
</div>
{/* 현장 주소 */}
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Input
value={formData.scheduleInfo.sitePostalCode}
onChange={(e) => updateNested('scheduleInfo', 'sitePostalCode', e.target.value)}
className="w-28"
/>
<Button variant="outline" size="sm" type="button">
</Button>
</div>
</div>
<div className="space-y-2 col-span-2">
<Label></Label>
<Input
value={formData.scheduleInfo.siteAddress}
onChange={(e) => updateNested('scheduleInfo', 'siteAddress', e.target.value)}
placeholder="주소 입력"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.scheduleInfo.siteAddressDetail}
onChange={(e) => updateNested('scheduleInfo', 'siteAddressDetail', e.target.value)}
placeholder="상세주소 입력"
/>
</div>
</div>
</CardContent>
</Card>
{/* 수주 설정 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base"> </CardTitle>
<Button variant="outline" size="sm" type="button" onClick={() => setOrderModalOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="flex items-center gap-3 text-sm">
<span>: <strong>{orderSummary.total}</strong></span>
<span className="text-green-600">: <strong>{orderSummary.same}</strong></span>
<span className="text-red-600">: <strong>{orderSummary.changed}</strong></span>
</div>
</CardHeader>
<CardContent className="p-4">
{renderOrderAccordion(orderGroups)}
</CardContent>
</Card>
</div>
), [formData, orderSummary, orderGroups, updateField, updateNested, handleRemoveOrderItem, handleOpenInspectionInput, handleUpdateOrderItemField, orderModalOpen]);
// 이미 선택된 수주 ID 목록 (orderId 기준, 중복 제거)
const excludeOrderIds = useMemo(
() => [...new Set(formData.orderItems.map((item) => String(item.orderId ?? item.id)))],
[formData.orderItems]
);
// 수주 선택 필터: 기본정보 수주처 또는 이미 선택된 수주 기준
const orderFilter = useMemo(() => {
// 기본정보에서 수주처가 선택된 경우 → 해당 거래처 필터
if (formData.clientId) {
const firstItem = formData.orderItems[0];
return {
clientId: formData.clientId,
itemId: firstItem?.itemId ?? undefined,
label: [formData.client, firstItem?.itemName].filter(Boolean).join(' / ') || undefined,
};
}
// 수주가 선택된 경우 → 첫 수주 기준 필터
if (formData.orderItems.length > 0) {
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}
return { clientId: undefined, itemId: undefined, label: undefined };
}, [formData.clientId, formData.client, formData.orderItems]);
return (
<>
<IntegratedDetailTemplate
config={qualityInspectionCreateConfig}
mode="create"
isLoading={false}
isSubmitting={isSubmitting}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
<OrderSelectModal
open={orderModalOpen}
onOpenChange={setOrderModalOpen}
onSelect={handleOrderSelect}
excludeIds={excludeOrderIds}
filterClientId={orderFilter.clientId}
filterItemId={orderFilter.itemId}
filterLabel={orderFilter.label}
/>
{/* 거래처(수주처) 검색 모달 */}
<SupplierSearchModal
open={clientModalOpen}
onOpenChange={setClientModalOpen}
onSelectSupplier={(supplier) => {
updateField('clientId', Number(supplier.id));
updateField('client', supplier.name);
setClientModalOpen(false);
}}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}
onOpenChange={setInspectionInputOpen}
orderItemId={selectedOrderItem?.id || ''}
productName="방화셔터"
specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''}
initialData={selectedOrderItem?.inspectionData}
onComplete={handleInspectionComplete}
/>
</>
);
}