Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx
유병철 68331be0ef feat: 회계/결재/생산/출하/대시보드 다수 개선 및 QA 수정
- BadDebtCollection, BillManagement, CardTransaction, TaxInvoice 회계 개선
- VendorManagement/VendorDetailClient 소폭 추가
- DocumentCreate/DraftBox 결재 기능 개선
- WorkOrder Create/Detail/Edit, ShipmentEdit 생산/출하 개선
- CEO 대시보드: PurchaseStatusSection, receivable/status-issue transformer 정비
- dashboard types/invalidation 확장
- LoginPage, Sidebar, HeaderFavoritesBar 레이아웃 수정
- QMS 페이지, StockStatusDetail, OrderRegistration 소폭 수정
- AttendanceManagement, VacationManagement HR 수정
- ConstructionDetailClient 건설 상세 개선
- claudedocs: 주간 구현내역, 대시보드 QA/수정계획, 결재/품질/생산/출하 문서 추가
2026-03-09 21:06:01 +09:00

793 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 { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { Plus, Trash2, ChevronDown, Search } from 'lucide-react';
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 { 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 || '출고 수정에 실패했습니다.' };
}
invalidateDashboard('shipment');
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">
<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: 제품내용 (읽기전용) */}
<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}
/>
);
}