Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx
유병철 00a6209347 feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
2026-03-05 13:35:48 +09:00

761 lines
28 KiB
TypeScript

'use client';
/**
* 출고 등록 페이지
* 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Trash2, ChevronDown, Search } from 'lucide-react';
import { getTodayString } from '@/lib/utils/date';
import { Input } from '@/components/ui/input';
import { DateTimePicker } from '@/components/ui/date-time-picker';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { shipmentCreateConfig } from './shipmentConfig';
import {
createShipment,
getLotOptions,
getLogisticsOptions,
getVehicleTonnageOptions,
} from './actions';
import type {
ShipmentCreateFormData,
DeliveryMethod,
FreightCostType,
VehicleDispatch,
LotOption,
LogisticsOption,
VehicleTonnageOption,
ProductGroup,
ProductPart,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { toast } from 'sonner';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useDevFill } from '@/components/dev';
import { generateShipmentData } from '@/components/dev/generators/shipmentData';
import { mockProductGroups, mockOtherParts } from './mockData';
// 배송방식 옵션
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
{ value: 'direct_dispatch', label: '직접배차' },
{ value: 'loading', label: '상차' },
{ value: 'kyungdong_delivery', label: '경동택배' },
{ value: 'daesin_delivery', label: '대신택배' },
{ value: 'kyungdong_freight', label: '경동화물' },
{ value: 'daesin_freight', label: '대신화물' },
{ value: 'self_pickup', label: '직접수령' },
];
// 운임비용 옵션 (선불, 착불, 없음)
const freightCostOptions: { value: FreightCostType; label: string }[] = [
{ value: 'prepaid', label: '선불' },
{ value: 'collect', label: '착불' },
{ value: 'none', label: '없음' },
];
// 빈 배차 행 생성
function createEmptyDispatch(): VehicleDispatch {
return {
id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
logisticsCompany: '',
arrivalDateTime: '',
tonnage: '',
vehicleNo: '',
driverContact: '',
remarks: '',
};
}
export function ShipmentCreate() {
const router = useRouter();
// 폼 상태
const [formData, setFormData] = useState<ShipmentCreateFormData>({
lotNo: '',
scheduledDate: getTodayString(),
priority: 'normal',
deliveryMethod: 'direct_dispatch',
shipmentDate: '',
freightCost: 'none',
receiver: '',
receiverContact: '',
zipCode: '',
address: '',
addressDetail: '',
vehicleDispatches: [createEmptyDispatch()],
logisticsCompany: '',
vehicleTonnage: '',
loadingTime: '',
loadingManager: '',
remarks: '',
});
// API 옵션 데이터 상태
const [lotOptions, setLotOptions] = useState<LotOption[]>([]);
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
// 제품 데이터 (LOT 선택 시 표시)
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
const [otherParts, setOtherParts] = useState<ProductPart[]>([]);
// 로딩/에러 상태
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// 아코디언 상태
const [accordionValue, setAccordionValue] = useState<string[]>([]);
// 우편번호 찾기
const { openPostcode } = useDaumPostcode({
onComplete: (result) => {
setFormData(prev => ({
...prev,
zipCode: result.zonecode,
address: result.address,
}));
},
});
// 옵션 데이터 로드
const loadOptions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [lotsResult, logisticsResult, tonnageResult] = await Promise.all([
getLotOptions(),
getLogisticsOptions(),
getVehicleTonnageOptions(),
]);
if (lotsResult.success && lotsResult.data) {
setLotOptions(lotsResult.data);
}
if (logisticsResult.success && logisticsResult.data) {
setLogisticsOptions(logisticsResult.data);
}
if (tonnageResult.success && tonnageResult.data) {
setVehicleTonnageOptions(tonnageResult.data);
}
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentCreate] loadOptions error:', err);
setError('옵션 데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadOptions();
}, [loadOptions]);
// DevToolbar 자동 채우기
useDevFill(
'shipment',
useCallback(() => {
const lotOptionsForGenerator = lotOptions.map(o => ({
lotNo: o.value,
customerName: o.customerName,
siteName: o.siteName,
}));
const logisticsOptionsForGenerator = logisticsOptions.map(o => ({
id: o.value,
name: o.label,
}));
const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({
value: o.value,
label: o.label,
}));
const sampleData = generateShipmentData({
lotOptions: lotOptionsForGenerator as unknown as LotOption[],
logisticsOptions: logisticsOptionsForGenerator as unknown as LogisticsOption[],
tonnageOptions: tonnageOptionsForGenerator,
});
setFormData(prev => ({ ...prev, ...sampleData }));
toast.success('[Dev] 출고 폼이 자동으로 채워졌습니다.');
}, [lotOptions, logisticsOptions, vehicleTonnageOptions])
);
// LOT 선택 시 현장명/수주처 자동 매핑 + 목데이터 제품 표시
const handleLotChange = useCallback((lotNo: string) => {
setFormData(prev => ({ ...prev, lotNo }));
if (lotNo) {
// 목데이터로 제품 그룹 표시
setProductGroups(mockProductGroups);
setOtherParts(mockOtherParts);
} else {
setProductGroups([]);
setOtherParts([]);
}
if (validationErrors.lotNo) {
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
}
}, [validationErrors]);
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
const isFreightCostLocked = (method: DeliveryMethod) =>
method === 'direct_dispatch' || method === 'self_pickup';
// 폼 입력 핸들러
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
if (field === 'deliveryMethod') {
const method = value as DeliveryMethod;
if (isFreightCostLocked(method)) {
setFormData(prev => ({ ...prev, deliveryMethod: method, freightCost: 'none' as FreightCostType }));
} else {
setFormData(prev => ({ ...prev, deliveryMethod: method }));
}
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
if (validationErrors[field]) {
setValidationErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// 배차 정보 핸들러
const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => {
setFormData(prev => {
const newDispatches = [...prev.vehicleDispatches];
newDispatches[index] = { ...newDispatches[index], [field]: value };
return { ...prev, vehicleDispatches: newDispatches };
});
};
const handleAddDispatch = () => {
setFormData(prev => ({
...prev,
vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()],
}));
};
const handleRemoveDispatch = (index: number) => {
setFormData(prev => ({
...prev,
vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index),
}));
};
// 아코디언 제어
const handleExpandAll = useCallback(() => {
const allIds = [
...productGroups.map(g => g.id),
...(otherParts.length > 0 ? ['other-parts'] : []),
];
setAccordionValue(allIds);
}, [productGroups, otherParts]);
const handleCollapseAll = useCallback(() => {
setAccordionValue([]);
}, []);
const handleCancel = useCallback(() => {
router.push('/ko/outbound/shipments');
}, [router]);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
setValidationErrors(errors);
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
toast.error(firstError);
}
return Object.keys(errors).length === 0;
};
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!validateForm()) return { success: false, error: '' };
setIsSubmitting(true);
try {
const result = await createShipment(formData);
if (!result.success) {
return { success: false, error: result.error || '출고 등록에 실패했습니다.' };
}
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentCreate] handleSubmit error:', err);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [formData]);
// 제품 부품 테이블 렌더링
const renderPartsTable = (parts: ProductPart[]) => (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16 text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-32"></TableHead>
<TableHead className="w-20 text-center"></TableHead>
<TableHead className="w-20 text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parts.map((part) => (
<TableRow key={part.id}>
<TableCell className="text-center">{part.seq}</TableCell>
<TableCell>{part.itemName}</TableCell>
<TableCell>{part.specification}</TableCell>
<TableCell className="text-center">{part.quantity}</TableCell>
<TableCell className="text-center">{part.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
// LOT에서 선택한 정보 표시
const selectedLot = lotOptions.find(o => o.value === formData.lotNo);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
<div className="space-y-6">
{/* 카드 1: 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4">
{/* 출고번호 - 자동생성 */}
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium text-muted-foreground"></div>
</div>
{/* 로트번호 - Select */}
<div className="space-y-1">
<div className="text-sm text-muted-foreground"> *</div>
<Select
value={formData.lotNo}
onValueChange={handleLotChange}
disabled={isSubmitting}
>
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
<SelectValue placeholder="로트 선택" />
</SelectTrigger>
<SelectContent>
{lotOptions.filter(o => o.value).map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label} ({option.customerName} - {option.siteName})
</SelectItem>
))}
</SelectContent>
</Select>
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
</div>
{/* 현장명 - LOT 선택 시 자동 매핑 */}
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{selectedLot?.siteName || '-'}</div>
</div>
{/* 수주처 - LOT 선택 시 자동 매핑 */}
<div>
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{selectedLot?.customerName || '-'}</div>
</div>
</div>
</CardContent>
</Card>
{/* 카드 2: 수주/배송 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base">/ </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label> *</Label>
<DatePicker
value={formData.scheduledDate}
onChange={(date) => handleInputChange('scheduledDate', date)}
disabled={isSubmitting}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker
value={formData.shipmentDate || ''}
onChange={(date) => handleInputChange('shipmentDate', date)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={formData.deliveryMethod}
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
disabled={isSubmitting}
>
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
<SelectValue placeholder="배송방식 선택" />
</SelectTrigger>
<SelectContent>
{deliveryMethodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.freightCost || ''}
onValueChange={(value) => handleInputChange('freightCost', value)}
disabled={isSubmitting || isFreightCostLocked(formData.deliveryMethod)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{freightCostOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={formData.receiver || ''}
onChange={(e) => handleInputChange('receiver', e.target.value)}
placeholder="수신자명"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.receiverContact || ''}
onChange={(e) => handleInputChange('receiverContact', e.target.value)}
placeholder="수신처"
disabled={isSubmitting}
/>
</div>
</div>
{/* 주소 */}
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Input
value={formData.zipCode || ''}
placeholder="우편번호"
className="w-32"
readOnly
disabled={isSubmitting}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={openPostcode}
disabled={isSubmitting}
>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
<Input
value={formData.address || ''}
placeholder="주소"
readOnly
disabled={isSubmitting}
/>
<Input
value={formData.addressDetail || ''}
onChange={(e) => handleInputChange('addressDetail', e.target.value)}
placeholder="상세주소"
disabled={isSubmitting}
/>
</div>
</CardContent>
</Card>
{/* 카드 3: 배차 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddDispatch}
disabled={isSubmitting}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.vehicleDispatches.map((dispatch, index) => (
<TableRow key={dispatch.id}>
<TableCell className="p-1">
<Select
value={dispatch.logisticsCompany}
onValueChange={(value) => handleDispatchChange(index, 'logisticsCompany', value)}
disabled={isSubmitting}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{logisticsOptions.filter(o => o.value).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<DateTimePicker
value={dispatch.arrivalDateTime}
onChange={(val) => handleDispatchChange(index, 'arrivalDateTime', val)}
size="sm"
disabled={isSubmitting}
/>
</TableCell>
<TableCell className="p-1">
<Select
value={dispatch.tonnage}
onValueChange={(value) => handleDispatchChange(index, 'tonnage', value)}
disabled={isSubmitting}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{vehicleTonnageOptions.filter(o => o.value).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
value={dispatch.vehicleNo}
onChange={(e) => handleDispatchChange(index, 'vehicleNo', e.target.value)}
placeholder="차량번호"
className="h-8"
disabled={isSubmitting}
/>
</TableCell>
<TableCell className="p-1">
<Input
value={dispatch.driverContact}
onChange={(e) => handleDispatchChange(index, 'driverContact', e.target.value)}
placeholder="연락처"
className="h-8"
disabled={isSubmitting}
/>
</TableCell>
<TableCell className="p-1">
<Input
value={dispatch.remarks}
onChange={(e) => handleDispatchChange(index, 'remarks', e.target.value)}
placeholder="비고"
className="h-8"
disabled={isSubmitting}
/>
</TableCell>
<TableCell className="p-1 text-center">
{formData.vehicleDispatches.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveDispatch(index)}
disabled={isSubmitting}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"></CardTitle>
{productGroups.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
/
<ChevronDown className="w-4 h-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExpandAll}>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCollapseAll}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</CardHeader>
<CardContent>
{productGroups.length > 0 || otherParts.length > 0 ? (
<Accordion
type="multiple"
value={accordionValue}
onValueChange={setAccordionValue}
>
{productGroups.map((group: ProductGroup) => (
<AccordionItem key={group.id} value={group.id}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<span className="font-medium">{group.productName}</span>
<span className="text-muted-foreground text-sm">
({group.specification})
</span>
<Badge variant="secondary" className="text-xs">
{group.partCount}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
{renderPartsTable(group.parts)}
</AccordionContent>
</AccordionItem>
))}
{otherParts.length > 0 && (
<AccordionItem value="other-parts">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<span className="font-medium"></span>
<Badge variant="secondary" className="text-xs">
{otherParts.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
{renderPartsTable(otherParts)}
</AccordionContent>
</AccordionItem>
)}
</Accordion>
) : (
<div className="text-center text-muted-foreground text-sm py-4">
{formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'}
</div>
)}
</CardContent>
</Card>
</div>
), [
formData, validationErrors, isSubmitting, lotOptions, logisticsOptions,
vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue,
handleLotChange, handleExpandAll, handleCollapseAll, openPostcode,
]);
if (error) {
return (
<IntegratedDetailTemplate
config={shipmentCreateConfig}
mode="create"
isLoading={false}
onCancel={handleCancel}
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
)}
/>
);
}
return (
<IntegratedDetailTemplate
config={shipmentCreateConfig}
mode="create"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>
);
}