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:
유병철
2026-02-02 11:14:05 +09:00
parent e684c495ee
commit 1a69324d59
41 changed files with 4134 additions and 1440 deletions

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
}

View 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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,10 @@
/**
* 배차차량관리 컴포넌트 Export
*/
export { VehicleDispatchList } from './VehicleDispatchList';
export { VehicleDispatchDetail } from './VehicleDispatchDetail';
export { VehicleDispatchEdit } from './VehicleDispatchEdit';
// Types
export * from './types';

View 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,
};

View 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; // 합계 금액
}

View File

@@ -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: '저장',
},
};