Files
sam-react-prod/src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx
유병철 b1686aaf66 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화 (104 files)
- 생산대시보드/작업지시 모바일 호환성 강화
- 견적서/주문관리 반응형 그리드 적용
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:27:40 +09:00

396 lines
14 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 { Alert, AlertDescription } from '@/components/ui/alert';
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<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 (validationErrors.length > 0) setValidationErrors([]);
}, [validationErrors]);
// 폼 입력 핸들러
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (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>
{/* Validation 에러 표시 */}
{validationErrors.length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<ul className="space-y-1 text-sm">
{validationErrors.map((err, index) => (
<li key={index}> {err}</li>
))}
</ul>
</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.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>;
}) => (
<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}
/>
);
}