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:
유병철
2026-03-05 13:35:48 +09:00
parent c18c68b6b7
commit 00a6209347
23 changed files with 1689 additions and 517 deletions

View File

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

View File

@@ -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>

View File

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

View File

@@ -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,