feat(WEB): 출고관리 대폭 개선, 차량배차관리 신규 추가 및 QMS/캘린더 기능 강화
- 출고관리: ShipmentCreate/Detail/Edit/List 개선, ShipmentOrderDocument 신규 추가 - 차량배차관리: VehicleDispatchManagement 모듈 신규 추가 - QMS: InspectionModalV2 개선 - 캘린더: WeekTimeView 신규 추가, CalendarHeader/types 확장 - 문서: ConstructionApprovalTable/SalesOrderDocument/DeliveryConfirmation/ShippingSlip 개선 - 작업지시서: 검사보고서/작업일지 문서 개선 - 템플릿: IntegratedListTemplateV2/UniversalListPage 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 상세 페이지
|
||||
* 3개 섹션: 기본정보, 배차정보, 배송비정보
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
import {
|
||||
VEHICLE_DISPATCH_STATUS_LABELS,
|
||||
VEHICLE_DISPATCH_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
FREIGHT_COST_STYLES,
|
||||
} from './types';
|
||||
import type { VehicleDispatchDetail as VehicleDispatchDetailType } from './types';
|
||||
|
||||
interface VehicleDispatchDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// API 데이터 상태
|
||||
const [detail, setDetail] = useState<VehicleDispatchDetailType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getVehicleDispatchById(id);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setError(result.error || '배차차량 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VehicleDispatchDetail] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
// 정보 필드 렌더링 헬퍼
|
||||
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-muted-foreground mb-1">{label}</div>
|
||||
<div className="font-medium">{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 컨텐츠 렌더링
|
||||
const renderViewContent = useCallback((_data: Record<string, unknown>) => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('배차번호', detail.dispatchNo)}
|
||||
{renderInfoField('출고번호', detail.shipmentNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.orderCustomer)}
|
||||
{renderInfoField(
|
||||
'운임비용',
|
||||
<Badge className={FREIGHT_COST_STYLES[detail.freightCostType]}>
|
||||
{FREIGHT_COST_LABELS[detail.freightCostType]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'상태',
|
||||
<Badge className={VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField('작성자', detail.writer)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('물류업체', detail.logisticsCompany)}
|
||||
{renderInfoField('입차일시', detail.arrivalDateTime)}
|
||||
{renderInfoField('톤수', detail.tonnage)}
|
||||
{renderInfoField('차량번호', detail.vehicleNo)}
|
||||
{renderInfoField('기사연락처', detail.driverContact)}
|
||||
{renderInfoField('비고', detail.remarks || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 3: 배송비 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배송비 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('공급가액', `${formatAmount(detail.supplyAmount)}원`)}
|
||||
{renderInfoField('부가세', `${formatAmount(detail.vat)}원`)}
|
||||
{renderInfoField(
|
||||
'합계',
|
||||
<span className="text-lg font-bold">{formatAmount(detail.totalAmount)}원</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={vehicleDispatchConfig}
|
||||
mode="view"
|
||||
initialData={(detail ?? undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
renderView={renderViewContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 수정 페이지
|
||||
* 3개 섹션: 기본정보(운임비용만 편집), 배차정보(모두 편집), 배송비정보(공급가액 편집→자동계산)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { 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';
|
||||
|
||||
// 운임비용 옵션
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
|
||||
FREIGHT_COST_LABELS
|
||||
).map(([value, label]) => ({ value: value as FreightCostType, label }));
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
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 () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateVehicleDispatch(id, formData);
|
||||
if (result.success) {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
|
||||
} else {
|
||||
setValidationErrors([result.error || '배차차량 수정에 실패했습니다.']);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VehicleDispatchEdit] handleSubmit error:', err);
|
||||
setValidationErrors(['저장 중 오류가 발생했습니다.']);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [id, formData, router]);
|
||||
|
||||
// 동적 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.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>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.arrivalDateTime}
|
||||
onChange={(e) => handleInputChange('arrivalDateTime', e.target.value)}
|
||||
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 (_data: Record<string, unknown>) => {
|
||||
await handleSubmit();
|
||||
return { success: true };
|
||||
}}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 목록 페이지
|
||||
*
|
||||
* - DateRangeSelector
|
||||
* - 통계 카드 3개: 선불, 착불, 합계
|
||||
* - 상태 필터: 전체/작성대기/작성완료
|
||||
* - 테이블 18컬럼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Truck,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
Calculator,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getVehicleDispatches, getVehicleDispatchStats } from './actions';
|
||||
import {
|
||||
VEHICLE_DISPATCH_STATUS_LABELS,
|
||||
VEHICLE_DISPATCH_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
FREIGHT_COST_STYLES,
|
||||
} from './types';
|
||||
import type { VehicleDispatchItem, VehicleDispatchStats } from './types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
export function VehicleDispatchList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 =====
|
||||
const [dispatchStats, setDispatchStats] = useState<VehicleDispatchStats | null>(null);
|
||||
|
||||
// ===== 날짜 범위 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await getVehicleDispatchStats();
|
||||
if (result.success && result.data) {
|
||||
setDispatchStats(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchList] loadStats error:', error);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: VehicleDispatchItem) => {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${item.id}?mode=view`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 통계 카드 (3개: 선불, 착불, 합계) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '선불',
|
||||
value: `${formatAmount(dispatchStats?.prepaidAmount || 0)}원`,
|
||||
icon: CreditCard,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '착불',
|
||||
value: `${formatAmount(dispatchStats?.collectAmount || 0)}원`,
|
||||
icon: Banknote,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '합계',
|
||||
value: `${formatAmount(dispatchStats?.totalAmount || 0)}원`,
|
||||
icon: Calculator,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
],
|
||||
[dispatchStats]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<VehicleDispatchItem> = useMemo(
|
||||
() => ({
|
||||
title: '배차차량 목록',
|
||||
description: '배차차량을 관리합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getVehicleDispatches({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 다시 로드
|
||||
const statsResult = await getVehicleDispatchStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setDispatchStats(statsResult.data);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchList] getList error:', error);
|
||||
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'dispatchNo', label: '배차번호', className: 'min-w-[130px]' },
|
||||
{ key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
|
||||
{ key: 'logisticsCompany', label: '물류업체', className: 'min-w-[90px]' },
|
||||
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'w-[100px] text-right' },
|
||||
{ key: 'vat', label: '부가세', className: 'w-[90px] text-right' },
|
||||
{ key: 'totalAmount', label: '합계', className: 'w-[100px] text-right' },
|
||||
{ key: 'freightCostType', label: '선/착불', className: 'w-[70px] text-center' },
|
||||
{ key: 'vehicleNo', label: '차량번호', className: 'min-w-[100px]' },
|
||||
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px]' },
|
||||
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'remarks', label: '비고', className: 'min-w-[100px]' },
|
||||
],
|
||||
|
||||
// 상태 필터
|
||||
filterConfig: [{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: 'draft', label: '작성대기' },
|
||||
{ value: 'completed', label: '작성완료' },
|
||||
],
|
||||
allOptionLabel: '전체',
|
||||
}],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '배차번호, 출고번호, 현장명, 수주처, 차량번호 검색...',
|
||||
searchFilter: (item: VehicleDispatchItem, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
},
|
||||
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: VehicleDispatchItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleDispatchItem>
|
||||
) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.dispatchNo}</TableCell>
|
||||
<TableCell>{item.shipmentNo}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer}</TableCell>
|
||||
<TableCell>{item.logisticsCompany}</TableCell>
|
||||
<TableCell className="text-center">{item.tonnage}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.vat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
|
||||
{FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.vehicleNo}</TableCell>
|
||||
<TableCell>{item.driverContact}</TableCell>
|
||||
<TableCell className="text-center">{item.writer}</TableCell>
|
||||
<TableCell className="text-center">{item.arrivalDateTime}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: VehicleDispatchItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleDispatchItem>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.dispatchNo}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.siteName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="출고번호" value={item.shipmentNo} />
|
||||
<InfoField label="수주처" value={item.orderCustomer} />
|
||||
<InfoField label="물류업체" value={item.logisticsCompany} />
|
||||
<InfoField label="톤수" value={item.tonnage} />
|
||||
<InfoField label="합계" value={`${formatAmount(item.totalAmount)}원`} />
|
||||
<InfoField
|
||||
label="선/착불"
|
||||
value={FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
/>
|
||||
<InfoField label="차량번호" value={item.vehicleNo} />
|
||||
<InfoField label="입차일시" value={item.arrivalDateTime} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[stats, startDate, endDate, handleRowClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
162
src/components/outbound/VehicleDispatchManagement/actions.ts
Normal file
162
src/components/outbound/VehicleDispatchManagement/actions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 배차차량관리 서버 액션
|
||||
*
|
||||
* 현재: Mock 데이터 반환
|
||||
* 추후: API 연동 시 serverFetch 사용
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
VehicleDispatchItem,
|
||||
VehicleDispatchDetail,
|
||||
VehicleDispatchStats,
|
||||
VehicleDispatchEditFormData,
|
||||
} from './types';
|
||||
import {
|
||||
mockVehicleDispatchItems,
|
||||
mockVehicleDispatchDetail,
|
||||
mockVehicleDispatchStats,
|
||||
} from './mockData';
|
||||
|
||||
// ===== 페이지네이션 타입 =====
|
||||
interface PaginationMeta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 배차차량 목록 조회 =====
|
||||
export async function getVehicleDispatches(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: VehicleDispatchItem[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let items = [...mockVehicleDispatchItems];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const perPage = params?.perPage || 20;
|
||||
const total = items.length;
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const paginatedItems = items.slice(startIndex, startIndex + perPage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: paginatedItems,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
lastPage,
|
||||
perPage,
|
||||
total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatches error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
export async function getVehicleDispatchStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, data: mockVehicleDispatchStats };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
export async function getVehicleDispatchById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Mock: ID로 목록에서 찾아서 상세 데이터 생성
|
||||
const item = mockVehicleDispatchItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
// fallback으로 기본 상세 데이터 반환
|
||||
return { success: true, data: { ...mockVehicleDispatchDetail, id } };
|
||||
}
|
||||
|
||||
const detail: VehicleDispatchDetail = {
|
||||
id: item.id,
|
||||
dispatchNo: item.dispatchNo,
|
||||
shipmentNo: item.shipmentNo,
|
||||
siteName: item.siteName,
|
||||
orderCustomer: item.orderCustomer,
|
||||
freightCostType: item.freightCostType,
|
||||
status: item.status,
|
||||
writer: item.writer,
|
||||
logisticsCompany: item.logisticsCompany,
|
||||
arrivalDateTime: item.arrivalDateTime,
|
||||
tonnage: item.tonnage,
|
||||
vehicleNo: item.vehicleNo,
|
||||
driverContact: item.driverContact,
|
||||
remarks: item.remarks,
|
||||
supplyAmount: item.supplyAmount,
|
||||
vat: item.vat,
|
||||
totalAmount: item.totalAmount,
|
||||
};
|
||||
|
||||
return { success: true, data: detail };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 수정 =====
|
||||
export async function updateVehicleDispatch(
|
||||
id: string,
|
||||
_data: VehicleDispatchEditFormData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('[VehicleDispatchActions] updateVehicleDispatch:', id, _data);
|
||||
// Mock: 항상 성공 반환
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] updateVehicleDispatch error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
10
src/components/outbound/VehicleDispatchManagement/index.ts
Normal file
10
src/components/outbound/VehicleDispatchManagement/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 배차차량관리 컴포넌트 Export
|
||||
*/
|
||||
|
||||
export { VehicleDispatchList } from './VehicleDispatchList';
|
||||
export { VehicleDispatchDetail } from './VehicleDispatchDetail';
|
||||
export { VehicleDispatchEdit } from './VehicleDispatchEdit';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
198
src/components/outbound/VehicleDispatchManagement/mockData.ts
Normal file
198
src/components/outbound/VehicleDispatchManagement/mockData.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 배차차량관리 Mock 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
VehicleDispatchItem,
|
||||
VehicleDispatchDetail,
|
||||
VehicleDispatchStats,
|
||||
} from './types';
|
||||
|
||||
// 통계 데이터
|
||||
export const mockVehicleDispatchStats: VehicleDispatchStats = {
|
||||
prepaidAmount: 1250000,
|
||||
collectAmount: 880000,
|
||||
totalAmount: 2130000,
|
||||
};
|
||||
|
||||
// 배차차량 목록 Mock 데이터
|
||||
export const mockVehicleDispatchItems: VehicleDispatchItem[] = [
|
||||
{
|
||||
id: 'vd-001',
|
||||
dispatchNo: 'VD-260101-001',
|
||||
shipmentNo: 'SL-260101-01',
|
||||
siteName: '위브 청라',
|
||||
orderCustomer: '두산건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 250000,
|
||||
vat: 25000,
|
||||
totalAmount: 275000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '경기12차1234',
|
||||
driverContact: '010-1234-5678',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-01-01 09:00',
|
||||
status: 'completed',
|
||||
remarks: '1차 배차',
|
||||
},
|
||||
{
|
||||
id: 'vd-002',
|
||||
dispatchNo: 'VD-260101-002',
|
||||
shipmentNo: 'SL-260101-02',
|
||||
siteName: '대시앙 동탄',
|
||||
orderCustomer: '태영건설(주)',
|
||||
logisticsCompany: 'CJ대한통운',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '서울34나5678',
|
||||
driverContact: '010-2345-6789',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-01-01 14:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-003',
|
||||
dispatchNo: 'VD-260102-001',
|
||||
shipmentNo: 'SL-260102-01',
|
||||
siteName: '버킷 광교',
|
||||
orderCustomer: '호반건설(주)',
|
||||
logisticsCompany: '롯데글로벌로지스',
|
||||
tonnage: '11톤',
|
||||
supplyAmount: 500000,
|
||||
vat: 50000,
|
||||
totalAmount: 550000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '인천56마7890',
|
||||
driverContact: '010-3456-7890',
|
||||
writer: '이영희',
|
||||
arrivalDateTime: '2026-01-02 08:30',
|
||||
status: 'draft',
|
||||
remarks: '중량물 주의',
|
||||
},
|
||||
{
|
||||
id: 'vd-004',
|
||||
dispatchNo: 'VD-260102-002',
|
||||
shipmentNo: 'SL-260102-02',
|
||||
siteName: '자이 위례',
|
||||
orderCustomer: 'GS건설(주)',
|
||||
logisticsCompany: '현대글로비스',
|
||||
tonnage: '2.5톤',
|
||||
supplyAmount: 180000,
|
||||
vat: 18000,
|
||||
totalAmount: 198000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '경기78바1234',
|
||||
driverContact: '010-4567-8901',
|
||||
writer: '박민수',
|
||||
arrivalDateTime: '2026-01-02 11:00',
|
||||
status: 'draft',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-005',
|
||||
dispatchNo: 'VD-260103-001',
|
||||
shipmentNo: 'SL-260103-01',
|
||||
siteName: '푸르지오 일산',
|
||||
orderCustomer: '대우건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 320000,
|
||||
vat: 32000,
|
||||
totalAmount: 352000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '서울90사2345',
|
||||
driverContact: '010-5678-9012',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-01-03 09:30',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-006',
|
||||
dispatchNo: 'VD-260103-002',
|
||||
shipmentNo: 'SL-260103-02',
|
||||
siteName: '힐스테이트 판교',
|
||||
orderCustomer: '현대건설(주)',
|
||||
logisticsCompany: 'CJ대한통운',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 280000,
|
||||
vat: 28000,
|
||||
totalAmount: 308000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '경기23아4567',
|
||||
driverContact: '010-6789-0123',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-01-03 15:00',
|
||||
status: 'draft',
|
||||
remarks: '지하주차장 진입',
|
||||
},
|
||||
{
|
||||
id: 'vd-007',
|
||||
dispatchNo: 'VD-260104-001',
|
||||
shipmentNo: 'SL-260104-01',
|
||||
siteName: '래미안 강남 포레스트',
|
||||
orderCustomer: '삼성물산(주)',
|
||||
logisticsCompany: '롯데글로벌로지스',
|
||||
tonnage: '11톤',
|
||||
supplyAmount: 620000,
|
||||
vat: 62000,
|
||||
totalAmount: 682000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '서울45나6789',
|
||||
driverContact: '010-7890-1234',
|
||||
writer: '이영희',
|
||||
arrivalDateTime: '2026-01-04 08:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-008',
|
||||
dispatchNo: 'VD-260104-002',
|
||||
shipmentNo: 'SL-260104-02',
|
||||
siteName: '더샵 송도',
|
||||
orderCustomer: '포스코건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 400000,
|
||||
vat: 40000,
|
||||
totalAmount: 440000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '인천67마8901',
|
||||
driverContact: '010-8901-2345',
|
||||
writer: '박민수',
|
||||
arrivalDateTime: '2026-01-04 13:30',
|
||||
status: 'completed',
|
||||
remarks: '2차 배차',
|
||||
},
|
||||
];
|
||||
|
||||
// 배차차량 상세 Mock 데이터
|
||||
export const mockVehicleDispatchDetail: VehicleDispatchDetail = {
|
||||
id: 'vd-001',
|
||||
// 기본 정보
|
||||
dispatchNo: 'VD-260101-001',
|
||||
shipmentNo: 'SL-260101-01',
|
||||
siteName: '위브 청라',
|
||||
orderCustomer: '두산건설(주)',
|
||||
freightCostType: 'prepaid',
|
||||
status: 'completed',
|
||||
writer: '홍길동',
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany: '한진물류',
|
||||
arrivalDateTime: '2026-01-01 09:00',
|
||||
tonnage: '3.5톤',
|
||||
vehicleNo: '경기12차1234',
|
||||
driverContact: '010-1234-5678',
|
||||
remarks: '1차 배차',
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: 250000,
|
||||
vat: 25000,
|
||||
totalAmount: 275000,
|
||||
};
|
||||
104
src/components/outbound/VehicleDispatchManagement/types.ts
Normal file
104
src/components/outbound/VehicleDispatchManagement/types.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 배차차량관리 타입 정의
|
||||
*/
|
||||
|
||||
// 배차 상태
|
||||
export type VehicleDispatchStatus = 'draft' | 'completed';
|
||||
|
||||
// 상태 라벨
|
||||
export const VEHICLE_DISPATCH_STATUS_LABELS: Record<VehicleDispatchStatus, string> = {
|
||||
draft: '작성대기',
|
||||
completed: '작성완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const VEHICLE_DISPATCH_STATUS_STYLES: Record<VehicleDispatchStatus, string> = {
|
||||
draft: 'bg-red-100 text-red-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 선/착불 타입
|
||||
export type FreightCostType = 'prepaid' | 'collect';
|
||||
|
||||
export const FREIGHT_COST_LABELS: Record<FreightCostType, string> = {
|
||||
prepaid: '선불',
|
||||
collect: '착불',
|
||||
};
|
||||
|
||||
export const FREIGHT_COST_STYLES: Record<FreightCostType, string> = {
|
||||
prepaid: 'bg-blue-100 text-blue-800',
|
||||
collect: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
// 배차차량 목록 아이템
|
||||
export interface VehicleDispatchItem {
|
||||
id: string;
|
||||
dispatchNo: string; // 배차번호
|
||||
shipmentNo: string; // 출고번호
|
||||
siteName: string; // 현장명
|
||||
orderCustomer: string; // 수주처
|
||||
logisticsCompany: string; // 물류업체
|
||||
tonnage: string; // 톤수
|
||||
supplyAmount: number; // 공급가액
|
||||
vat: number; // 부가세
|
||||
totalAmount: number; // 합계
|
||||
freightCostType: FreightCostType; // 선/착불
|
||||
vehicleNo: string; // 차량번호
|
||||
driverContact: string; // 기사연락처
|
||||
writer: string; // 작성자
|
||||
arrivalDateTime: string; // 입차일시
|
||||
status: VehicleDispatchStatus; // 상태
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
// 배차차량 상세 정보
|
||||
export interface VehicleDispatchDetail {
|
||||
id: string;
|
||||
// 기본 정보
|
||||
dispatchNo: string; // 배차번호
|
||||
shipmentNo: string; // 출고번호
|
||||
siteName: string; // 현장명
|
||||
orderCustomer: string; // 수주처
|
||||
freightCostType: FreightCostType; // 운임비용
|
||||
status: VehicleDispatchStatus; // 상태
|
||||
writer: string; // 작성자
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany: string; // 물류업체
|
||||
arrivalDateTime: string; // 입차일시
|
||||
tonnage: string; // 톤수
|
||||
vehicleNo: string; // 차량번호
|
||||
driverContact: string; // 기사연락처
|
||||
remarks: string; // 비고
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: number; // 공급가액
|
||||
vat: number; // 부가세
|
||||
totalAmount: number; // 합계
|
||||
}
|
||||
|
||||
// 배차차량 수정 폼 데이터
|
||||
export interface VehicleDispatchEditFormData {
|
||||
// 기본 정보 (운임비용만 편집 가능)
|
||||
freightCostType: FreightCostType;
|
||||
|
||||
// 배차 정보 (모두 편집 가능)
|
||||
logisticsCompany: string;
|
||||
arrivalDateTime: string;
|
||||
tonnage: string;
|
||||
vehicleNo: string;
|
||||
driverContact: string;
|
||||
remarks: string;
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: number;
|
||||
vat: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface VehicleDispatchStats {
|
||||
prepaidAmount: number; // 선불 금액
|
||||
collectAmount: number; // 착불 금액
|
||||
totalAmount: number; // 합계 금액
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Truck } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 배차차량 상세 페이지 Config
|
||||
*/
|
||||
export const vehicleDispatchConfig: DetailConfig = {
|
||||
title: '배차차량 상세',
|
||||
description: '배차차량 정보를 조회합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
fields: [],
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: true,
|
||||
showDelete: false,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 배차차량 수정 페이지 Config
|
||||
*/
|
||||
export const vehicleDispatchEditConfig: DetailConfig = {
|
||||
title: '배차차량 수정',
|
||||
description: '배차차량 정보를 수정합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: false,
|
||||
showDelete: false,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user