- ExpectedExpenseManagement DatePicker 적용 및 간소화 - BoardForm 날짜 필드 개선 - AttendanceInfoDialog, ReasonInfoDialog 코드 정리 - ReceivingDetail 기능 보강 - ShipmentCreate/Edit DatePicker 적용 - VehicleDispatchEdit 수정 - WorkOrderCreate 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
791 lines
29 KiB
TypeScript
791 lines
29 KiB
TypeScript
'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}
|
||
/>
|
||
);
|
||
}
|