Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx
유병철 5344bfc426 fix(WEB): 폼 컴포넌트 DatePicker 적용 및 코드 정리
- ExpectedExpenseManagement DatePicker 적용 및 간소화
- BoardForm 날짜 필드 개선
- AttendanceInfoDialog, ReasonInfoDialog 코드 정리
- ReceivingDetail 기능 보강
- ShipmentCreate/Edit DatePicker 적용
- VehicleDispatchEdit 수정
- WorkOrderCreate 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:46:41 +09:00

791 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* 출고 수정 페이지
* 4개 섹션 구조: 기본정보(읽기전용), 수주/배송정보, 배차정보, 제품내용(읽기전용)
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X as XIcon, ChevronDown, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
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 { Alert, AlertDescription } from '@/components/ui/alert';
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 { shipmentEditConfig } from './shipmentConfig';
import {
getShipmentById,
getLogisticsOptions,
getVehicleTonnageOptions,
updateShipment,
} from './actions';
import {
SHIPMENT_STATUS_LABELS,
SHIPMENT_STATUS_STYLES,
} from './types';
import type {
ShipmentDetail,
ShipmentEditFormData,
DeliveryMethod,
FreightCostType,
VehicleDispatch,
LogisticsOption,
VehicleTonnageOption,
ProductGroup,
ProductPart,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
// 배송방식 옵션
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: '',
};
}
interface ShipmentEditProps {
id: string;
}
export function ShipmentEdit({ id }: ShipmentEditProps) {
const router = useRouter();
// 상세 데이터 상태
const [detail, setDetail] = useState<ShipmentDetail | null>(null);
// 폼 상태
const [formData, setFormData] = useState<ShipmentEditFormData>({
scheduledDate: '',
shipmentDate: '',
priority: 'normal',
deliveryMethod: 'direct_dispatch',
freightCost: undefined,
receiver: '',
receiverContact: '',
zipCode: '',
address: '',
addressDetail: '',
vehicleDispatches: [createEmptyDispatch()],
loadingManager: '',
logisticsCompany: '',
vehicleTonnage: '',
vehicleNo: '',
shippingCost: undefined,
driverName: '',
driverContact: '',
expectedArrival: '',
confirmedArrival: '',
changeReason: '',
remarks: '',
});
// API 옵션 데이터 상태
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
// 로딩/에러 상태
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// 아코디언 상태
const [accordionValue, setAccordionValue] = useState<string[]>([]);
// 우편번호 찾기
const { openPostcode } = useDaumPostcode({
onComplete: (result) => {
setFormData(prev => ({
...prev,
zipCode: result.zonecode,
address: result.address,
}));
},
});
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const [detailResult, logisticsResult, tonnageResult] = await Promise.all([
getShipmentById(id),
getLogisticsOptions(),
getVehicleTonnageOptions(),
]);
if (detailResult.success && detailResult.data) {
const shipmentDetail = detailResult.data;
setDetail(shipmentDetail);
// 폼 초기값 설정
const lockedFreight = shipmentDetail.deliveryMethod === 'direct_dispatch' || shipmentDetail.deliveryMethod === 'self_pickup';
setFormData({
scheduledDate: shipmentDetail.scheduledDate,
shipmentDate: shipmentDetail.shipmentDate || '',
priority: shipmentDetail.priority,
deliveryMethod: shipmentDetail.deliveryMethod,
freightCost: lockedFreight ? 'none' : shipmentDetail.freightCost,
receiver: shipmentDetail.receiver || '',
receiverContact: shipmentDetail.receiverContact || '',
zipCode: shipmentDetail.zipCode || '',
address: shipmentDetail.address || shipmentDetail.deliveryAddress || '',
addressDetail: shipmentDetail.addressDetail || '',
vehicleDispatches: shipmentDetail.vehicleDispatches.length > 0
? shipmentDetail.vehicleDispatches
: [createEmptyDispatch()],
loadingManager: shipmentDetail.loadingManager || '',
logisticsCompany: shipmentDetail.logisticsCompany || '',
vehicleTonnage: shipmentDetail.vehicleTonnage || '',
vehicleNo: shipmentDetail.vehicleNo || '',
shippingCost: shipmentDetail.shippingCost,
driverName: shipmentDetail.driverName || '',
driverContact: shipmentDetail.driverContact || '',
expectedArrival: '',
confirmedArrival: '',
changeReason: '',
remarks: shipmentDetail.remarks || '',
});
} else {
setError(detailResult.error || '출고 정보를 불러오는 데 실패했습니다.');
}
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('[ShipmentEdit] loadData error:', err);
setError('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
const isFreightCostLocked = (method: DeliveryMethod) =>
method === 'direct_dispatch' || method === 'self_pickup';
// 폼 입력 핸들러
const handleInputChange = (field: keyof ShipmentEditFormData, value: string | number | undefined) => {
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.length > 0) setValidationErrors([]);
};
// 배차 정보 핸들러
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(() => {
if (!detail) return;
const allIds = [
...detail.productGroups.map(g => g.id),
...(detail.otherParts.length > 0 ? ['other-parts'] : []),
];
setAccordionValue(allIds);
}, [detail]);
const handleCollapseAll = useCallback(() => {
setAccordionValue([]);
}, []);
const handleCancel = useCallback(() => {
router.push(`/ko/outbound/shipments/${id}?mode=view`);
}, [router, id]);
const validateForm = (): boolean => {
const errors: string[] = [];
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
if (!formData.changeReason.trim()) errors.push('변경 사유는 필수 입력 항목입니다.');
setValidationErrors(errors);
return errors.length === 0;
};
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
if (!validateForm()) return { success: false, error: '' };
setIsSubmitting(true);
try {
const result = await updateShipment(id, formData);
if (!result.success) {
return { success: false, error: result.error || '출고 수정에 실패했습니다.' };
}
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;
console.error('[ShipmentEdit] handleSubmit error:', err);
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
return { success: false, error: errorMessage };
} finally {
setIsSubmitting(false);
}
}, [id, 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>
);
// 동적 config
const dynamicConfig = {
...shipmentEditConfig,
title: detail?.lotNo ? `출고 (${detail.lotNo})` : '출고',
};
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => {
if (!detail) return null;
return (
<div className="space-y-6">
{/* 상태 배지 */}
<div className="flex items-center gap-2">
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[detail.status]}`}>
{SHIPMENT_STATUS_LABELS[detail.status]}
</Badge>
</div>
{/* Validation 에러 표시 */}
{validationErrors.length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({validationErrors.length} )
</strong>
<ul className="space-y-1 text-sm">
{validationErrors.map((err, index) => (
<li key={index} className="flex items-start gap-1">
<span></span>
<span>{err}</span>
</li>
))}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 카드 1: 기본 정보 (읽기전용) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.lotNo}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.siteName}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.customerName}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.orderer || '-'}</div>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground"></Label>
<div className="font-medium">{detail.registrant || '-'}</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}
/>
</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
key={`delivery-${formData.deliveryMethod}`}
value={formData.deliveryMethod}
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
</SelectTrigger>
<SelectContent>
{deliveryMethodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
key={`freight-${formData.freightCost}`}
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">
<Input
type="datetime-local"
value={dispatch.arrivalDateTime}
onChange={(e) => handleDispatchChange(index, 'arrivalDateTime', e.target.value)}
className="h-8"
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}
>
<XIcon className="w-4 h-4 text-red-500" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 카드 4: 제품내용 (읽기전용) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"></CardTitle>
{(detail.productGroups.length > 0 || detail.otherParts.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>
{detail.productGroups.length > 0 || detail.otherParts.length > 0 ? (
<Accordion
type="multiple"
value={accordionValue}
onValueChange={setAccordionValue}
>
{detail.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>
))}
{detail.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">
{detail.otherParts.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
{renderPartsTable(detail.otherParts)}
</AccordionContent>
</AccordionItem>
)}
</Accordion>
) : (
<div className="text-center text-muted-foreground text-sm py-4">
.
</div>
)}
</CardContent>
</Card>
{/* 변경 사유 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.changeReason}
onChange={(e) => handleInputChange('changeReason', e.target.value)}
placeholder="변경 사유를 입력하세요 (예: 고객 요청, 물류사 일정 조율 등)"
disabled={isSubmitting}
/>
</div>
</CardContent>
</Card>
</div>
);
}, [
detail, formData, validationErrors, isSubmitting, logisticsOptions,
vehicleTonnageOptions, accordionValue, handleExpandAll, handleCollapseAll, openPostcode,
]);
if (error && !isLoading) {
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="edit"
isLoading={false}
onCancel={handleCancel}
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
{error || '출고 정보를 찾을 수 없습니다.'}
</AlertDescription>
</Alert>
)}
/>
);
}
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="edit"
isLoading={isLoading}
onCancel={handleCancel}
onSubmit={async () => {
return await handleSubmit();
}}
renderForm={renderFormContent}
/>
);
}