- 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 불필요 데이터 제거
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 배차차량 수정 페이지
|
|
* 3개 섹션: 기본정보(운임비용만 편집), 배차정보(모두 편집), 배송비정보(공급가액 편집→자동계산)
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
|
import { vehicleDispatchEditConfig } from './vehicleDispatchConfig';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import { getVehicleDispatchById, updateVehicleDispatch } from './actions';
|
|
import {
|
|
VEHICLE_DISPATCH_STATUS_LABELS,
|
|
VEHICLE_DISPATCH_STATUS_STYLES,
|
|
FREIGHT_COST_LABELS,
|
|
} from './types';
|
|
import type {
|
|
VehicleDispatchDetail,
|
|
VehicleDispatchEditFormData,
|
|
FreightCostType,
|
|
} from './types';
|
|
|
|
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
|
|
|
// 운임비용 옵션
|
|
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
|
|
FREIGHT_COST_LABELS
|
|
).map(([value, label]) => ({ value: value as FreightCostType, label }));
|
|
|
|
interface VehicleDispatchEditProps {
|
|
id: string;
|
|
}
|
|
|
|
export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
|
const router = useRouter();
|
|
|
|
// 상세 데이터
|
|
const [detail, setDetail] = useState<VehicleDispatchDetail | null>(null);
|
|
|
|
// 폼 상태
|
|
const [formData, setFormData] = useState<VehicleDispatchEditFormData>({
|
|
freightCostType: 'prepaid',
|
|
logisticsCompany: '',
|
|
arrivalDateTime: '',
|
|
tonnage: '',
|
|
vehicleNo: '',
|
|
driverContact: '',
|
|
remarks: '',
|
|
supplyAmount: 0,
|
|
vat: 0,
|
|
totalAmount: 0,
|
|
});
|
|
|
|
// 로딩/에러
|
|
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 loadData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await getVehicleDispatchById(id);
|
|
if (result.success && result.data) {
|
|
const d = result.data;
|
|
setDetail(d);
|
|
|
|
setFormData({
|
|
freightCostType: d.freightCostType,
|
|
logisticsCompany: d.logisticsCompany,
|
|
arrivalDateTime: d.arrivalDateTime,
|
|
tonnage: d.tonnage,
|
|
vehicleNo: d.vehicleNo,
|
|
driverContact: d.driverContact,
|
|
remarks: d.remarks,
|
|
supplyAmount: d.supplyAmount,
|
|
vat: d.vat,
|
|
totalAmount: d.totalAmount,
|
|
});
|
|
} else {
|
|
setError(result.error || '배차차량 정보를 불러오는 데 실패했습니다.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[VehicleDispatchEdit] loadData error:', err);
|
|
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// 공급가액 변경 → 부가세/합계 자동 계산
|
|
const handleSupplyAmountChange = useCallback((value: string) => {
|
|
const amount = parseInt(value.replace(/[^0-9]/g, ''), 10) || 0;
|
|
const vat = Math.round(amount * 0.1);
|
|
const total = amount + vat;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
supplyAmount: amount,
|
|
vat,
|
|
totalAmount: total,
|
|
}));
|
|
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
|
}, [validationErrors]);
|
|
|
|
// 폼 입력 핸들러
|
|
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
|
};
|
|
|
|
const handleCancel = useCallback(() => {
|
|
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
|
|
}, [router, id]);
|
|
|
|
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
const result = await updateVehicleDispatch(id, formData);
|
|
if (!result.success) {
|
|
return { success: false, error: result.error || '배차차량 수정에 실패했습니다.' };
|
|
}
|
|
return { success: true };
|
|
} catch (err) {
|
|
if (isNextRedirectError(err)) throw err;
|
|
console.error('[VehicleDispatchEdit] handleSubmit error:', err);
|
|
const errorMessage = err instanceof Error ? err.message : '저장 중 오류가 발생했습니다.';
|
|
return { success: false, error: errorMessage };
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [id, formData]);
|
|
|
|
// 동적 config
|
|
const dynamicConfig = {
|
|
...vehicleDispatchEditConfig,
|
|
title: detail?.dispatchNo ? `배차차량 수정 (${detail.dispatchNo})` : '배차차량 수정',
|
|
};
|
|
|
|
// 폼 컨텐츠 렌더링
|
|
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 ${VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}`}>
|
|
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 카드 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.dispatchNo}</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground">로트번호</Label>
|
|
<div className="font-medium">{detail.lotNo || detail.shipmentNo}</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.orderCustomer}</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>운임비용</Label>
|
|
<Select
|
|
key={`freight-${formData.freightCostType}`}
|
|
value={formData.freightCostType}
|
|
onValueChange={(value) => handleInputChange('freightCostType', value)}
|
|
disabled={isSubmitting}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{freightCostOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground">상태</Label>
|
|
<div>
|
|
<Badge className={VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}>
|
|
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-muted-foreground">작성자</Label>
|
|
<div className="font-medium">{detail.writer}</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-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>물류업체</Label>
|
|
<Input
|
|
value={formData.logisticsCompany}
|
|
onChange={(e) => handleInputChange('logisticsCompany', e.target.value)}
|
|
placeholder="물류업체명"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>입차일시</Label>
|
|
<DateTimePicker
|
|
value={formData.arrivalDateTime}
|
|
onChange={(val) => handleInputChange('arrivalDateTime', val)}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>구분</Label>
|
|
<Input
|
|
value={formData.tonnage}
|
|
onChange={(e) => handleInputChange('tonnage', e.target.value)}
|
|
placeholder="예: 3.5 톤"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>차량번호</Label>
|
|
<Input
|
|
value={formData.vehicleNo}
|
|
onChange={(e) => handleInputChange('vehicleNo', e.target.value)}
|
|
placeholder="예: 경기12차1234"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>기사연락처</Label>
|
|
<Input
|
|
value={formData.driverContact}
|
|
onChange={(e) => handleInputChange('driverContact', e.target.value)}
|
|
placeholder="010-0000-0000"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>비고</Label>
|
|
<Input
|
|
value={formData.remarks}
|
|
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
|
placeholder="비고"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 카드 3: 배송비 정보 (공급가액 편집 → 자동계산) */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">배송비 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>공급가액</Label>
|
|
<Input
|
|
type="text"
|
|
value={formatAmount(formData.supplyAmount)}
|
|
onChange={(e) => handleSupplyAmountChange(e.target.value)}
|
|
placeholder="0"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>부가세 (10% 자동계산)</Label>
|
|
<Input
|
|
type="text"
|
|
value={formatAmount(formData.vat)}
|
|
readOnly
|
|
className="bg-muted"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>합계</Label>
|
|
<Input
|
|
type="text"
|
|
value={formatAmount(formData.totalAmount)}
|
|
readOnly
|
|
className="bg-muted font-bold"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
},
|
|
[detail, formData, validationErrors, isSubmitting, handleSupplyAmountChange]
|
|
);
|
|
|
|
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>;
|
|
}) => (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">
|
|
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
|
</div>
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<IntegratedDetailTemplate
|
|
config={dynamicConfig}
|
|
mode="edit"
|
|
isLoading={isLoading}
|
|
onCancel={handleCancel}
|
|
onSubmit={async () => {
|
|
return await handleSubmit();
|
|
}}
|
|
renderForm={renderFormContent}
|
|
/>
|
|
);
|
|
}
|