feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- 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 불필요 데이터 제거
This commit is contained in:
@@ -16,7 +16,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -138,7 +137,7 @@ export function ShipmentCreate() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
@@ -226,7 +225,9 @@ export function ShipmentCreate() {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors.lotNo) {
|
||||
setValidationErrors(prev => { const { lotNo: _, ...rest } = prev; return rest; });
|
||||
}
|
||||
}, [validationErrors]);
|
||||
|
||||
// 배송방식에 따라 운임비용 '없음' 고정 여부 판단
|
||||
@@ -245,7 +246,13 @@ export function ShipmentCreate() {
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (validationErrors[field]) {
|
||||
setValidationErrors(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 배차 정보 핸들러
|
||||
@@ -289,12 +296,16 @@ export function ShipmentCreate() {
|
||||
}, [router]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
if (!formData.lotNo) errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
const errors: Record<string, string> = {};
|
||||
if (!formData.lotNo) errors.lotNo = '로트번호는 필수 선택 항목입니다.';
|
||||
if (!formData.scheduledDate) errors.scheduledDate = '출고예정일은 필수 입력 항목입니다.';
|
||||
if (!formData.deliveryMethod) errors.deliveryMethod = '배송방식은 필수 선택 항목입니다.';
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(firstError);
|
||||
}
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -349,30 +360,6 @@ export function ShipmentCreate() {
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -393,7 +380,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.lotNo ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -404,6 +391,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.lotNo && <p className="text-sm text-red-500">{validationErrors.lotNo}</p>}
|
||||
</div>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
@@ -432,7 +420,9 @@ export function ShipmentCreate() {
|
||||
value={formData.scheduledDate}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
@@ -449,7 +439,7 @@ export function ShipmentCreate() {
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={validationErrors.deliveryMethod ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -460,6 +450,7 @@ export function ShipmentCreate() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{validationErrors.deliveryMethod && <p className="text-sm text-red-500">{validationErrors.deliveryMethod}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
@@ -748,9 +739,7 @@ export function ShipmentCreate() {
|
||||
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>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">{error}</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,6 @@ 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
@@ -111,34 +103,20 @@ export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 (테이블 형태) */}
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>{detail.logisticsCompany || '-'}</TableCell>
|
||||
<TableCell>{detail.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell>{detail.tonnage || '-'}</TableCell>
|
||||
<TableCell>{detail.vehicleNo || '-'}</TableCell>
|
||||
<TableCell>{detail.driverContact || '-'}</TableCell>
|
||||
<TableCell>{detail.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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,
|
||||
@@ -70,7 +69,7 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -121,13 +120,13 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
vat,
|
||||
totalAmount: total,
|
||||
}));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
}, [validationErrors]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
if (Object.keys(validationErrors).length > 0) setValidationErrors({});
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -177,19 +176,6 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
</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>
|
||||
@@ -370,11 +356,9 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
mode: string;
|
||||
errors: Record<string, string>;
|
||||
}) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,93 @@ interface PaginationMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 목데이터 (API 미응답 시 fallback) =====
|
||||
const MOCK_LIST: VehicleDispatchItem[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
dispatchNo: 'DC-20260301-001',
|
||||
shipmentNo: 'SH-20260228-012',
|
||||
lotNo: 'LOT-260228-01',
|
||||
siteName: '삼성전자 평택캠퍼스',
|
||||
orderCustomer: '삼성전자(주)',
|
||||
logisticsCompany: '한진택배',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '경기12가3456',
|
||||
driverContact: '010-1234-5678',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-03-05T09:00:00',
|
||||
status: 'draft',
|
||||
remarks: '오전 입차 요청',
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
dispatchNo: 'DC-20260301-002',
|
||||
shipmentNo: 'SH-20260227-008',
|
||||
lotNo: 'LOT-260227-03',
|
||||
siteName: 'LG디스플레이 파주공장',
|
||||
orderCustomer: 'LG디스플레이(주)',
|
||||
logisticsCompany: '대한통운',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 220000,
|
||||
vat: 22000,
|
||||
totalAmount: 242000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '서울34나7890',
|
||||
driverContact: '010-9876-5432',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-03-04T14:30:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DETAIL: Record<string, VehicleDispatchDetail> = {
|
||||
'mock-1': {
|
||||
id: 'mock-1',
|
||||
dispatchNo: 'DC-20260301-001',
|
||||
shipmentNo: 'SH-20260228-012',
|
||||
lotNo: 'LOT-260228-01',
|
||||
siteName: '삼성전자 평택캠퍼스',
|
||||
orderCustomer: '삼성전자(주)',
|
||||
freightCostType: 'prepaid',
|
||||
status: 'draft',
|
||||
writer: '홍길동',
|
||||
logisticsCompany: '한진택배',
|
||||
arrivalDateTime: '2026-03-05T09:00:00',
|
||||
tonnage: '5톤',
|
||||
vehicleNo: '경기12가3456',
|
||||
driverContact: '010-1234-5678',
|
||||
remarks: '오전 입차 요청',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
},
|
||||
'mock-2': {
|
||||
id: 'mock-2',
|
||||
dispatchNo: 'DC-20260301-002',
|
||||
shipmentNo: 'SH-20260227-008',
|
||||
lotNo: 'LOT-260227-03',
|
||||
siteName: 'LG디스플레이 파주공장',
|
||||
orderCustomer: 'LG디스플레이(주)',
|
||||
freightCostType: 'collect',
|
||||
status: 'completed',
|
||||
writer: '김철수',
|
||||
logisticsCompany: '대한통운',
|
||||
arrivalDateTime: '2026-03-04T14:30:00',
|
||||
tonnage: '3.5톤',
|
||||
vehicleNo: '서울34나7890',
|
||||
driverContact: '010-9876-5432',
|
||||
remarks: '',
|
||||
supplyAmount: 220000,
|
||||
vat: 22000,
|
||||
totalAmount: 242000,
|
||||
},
|
||||
};
|
||||
|
||||
// ===== API 응답 → 프론트 타입 변환 =====
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformToListItem(data: any): VehicleDispatchItem {
|
||||
@@ -89,7 +176,7 @@ export async function getVehicleDispatches(params?: {
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
return executePaginatedAction({
|
||||
const result = await executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/vehicle-dispatches', {
|
||||
search: params?.search,
|
||||
status: params?.status !== 'all' ? params?.status : undefined,
|
||||
@@ -101,6 +188,30 @@ export async function getVehicleDispatches(params?: {
|
||||
transform: transformToListItem,
|
||||
errorMessage: '배차차량 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 데이터가 없으면 목데이터 합산
|
||||
if (result.success && result.data.length === 0) {
|
||||
let mockFiltered = [...MOCK_LIST];
|
||||
if (params?.status && params.status !== 'all') {
|
||||
mockFiltered = mockFiltered.filter((m) => m.status === params.status);
|
||||
}
|
||||
if (params?.search) {
|
||||
const q = params.search.toLowerCase();
|
||||
mockFiltered = mockFiltered.filter((m) =>
|
||||
m.dispatchNo.toLowerCase().includes(q) ||
|
||||
m.lotNo?.toLowerCase().includes(q) ||
|
||||
m.siteName.toLowerCase().includes(q) ||
|
||||
m.orderCustomer.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
data: mockFiltered,
|
||||
pagination: { ...result.pagination, total: mockFiltered.length, lastPage: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
@@ -109,7 +220,7 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
return executeServerAction<
|
||||
const result = await executeServerAction<
|
||||
{ prepaid_amount: number; collect_amount: number; total_amount: number },
|
||||
VehicleDispatchStats
|
||||
>({
|
||||
@@ -121,6 +232,15 @@ export async function getVehicleDispatchStats(): Promise<{
|
||||
}),
|
||||
errorMessage: '배차차량 통계 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 통계가 모두 0이면 목데이터 기반 통계
|
||||
if (result.success && result.data && result.data.totalAmount === 0) {
|
||||
const prepaid = MOCK_LIST.filter((m) => m.freightCostType === 'prepaid').reduce((s, m) => s + m.totalAmount, 0);
|
||||
const collect = MOCK_LIST.filter((m) => m.freightCostType === 'collect').reduce((s, m) => s + m.totalAmount, 0);
|
||||
return { ...result, data: { prepaidAmount: prepaid, collectAmount: collect, totalAmount: prepaid + collect } };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
@@ -129,6 +249,11 @@ export async function getVehicleDispatchById(id: string): Promise<{
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
// 목데이터 ID인 경우 바로 반환
|
||||
if (id.startsWith('mock-') && MOCK_DETAIL[id]) {
|
||||
return { success: true, data: MOCK_DETAIL[id] };
|
||||
}
|
||||
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`),
|
||||
transform: transformToDetail,
|
||||
|
||||
Reference in New Issue
Block a user