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

@@ -8,7 +8,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
import { FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
@@ -22,14 +22,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { createWorkOrder, getProcessOptions, searchItemsForWorkOrder, type ProcessOption, type ManualItemOption } from './actions';
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
import { type SalesOrder } from './types';
import { workOrderCreateConfig } from './workOrderConfig';
import { useDevFill } from '@/components/dev';
@@ -44,20 +43,6 @@ interface ManualItem {
unit: string;
}
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
selectedOrder: '수주',
client: '발주처',
projectName: '현장명',
processId: '공정',
shipmentDate: '출고예정일',
};
type RegistrationMode = 'linked' | 'manual';
interface FormData {
@@ -102,7 +87,7 @@ export function WorkOrderCreate() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
@@ -114,6 +99,17 @@ export function WorkOrderCreate() {
const [isSearchingItems, setIsSearchingItems] = useState(false);
const [showItemSearch, setShowItemSearch] = useState(false);
// 필드 에러 클리어 헬퍼
const clearFieldError = useCallback((field: string) => {
if (validationErrors[field]) {
setValidationErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
}, [validationErrors]);
// 공정 옵션 로드
useEffect(() => {
async function loadProcessOptions() {
@@ -173,6 +169,7 @@ export function WorkOrderCreate() {
orderNo: order.orderNo,
itemCount: order.itemCount,
});
clearFieldError('selectedOrder');
};
// 수주 해제
@@ -217,6 +214,7 @@ export function WorkOrderCreate() {
setShowItemSearch(false);
setItemSearchQuery('');
setItemSearchResults([]);
clearFieldError('items');
};
// 품목 수량 변경
@@ -232,7 +230,7 @@ export function WorkOrderCreate() {
// 폼 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
const errors: Record<string, string> = {};
if (mode === 'linked') {
if (!formData.selectedOrder) {
@@ -261,8 +259,8 @@ export function WorkOrderCreate() {
// 에러가 있으면 상태 업데이트 후 리턴
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
// 페이지 상단으로 스크롤
window.scrollTo({ top: 0, behavior: 'smooth' });
const firstError = Object.values(errors)[0];
toast.error(firstError);
return { success: false, error: '' };
}
@@ -318,35 +316,6 @@ export function WorkOrderCreate() {
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(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">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 등록 방식 */}
<section className="bg-white border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
@@ -381,7 +350,7 @@ export function WorkOrderCreate() {
<section className="bg-muted/30 border rounded-lg p-6">
<h3 className="font-semibold mb-4"> </h3>
{!formData.selectedOrder ? (
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
<div className={`flex items-center justify-between p-4 bg-white border rounded-lg ${validationErrors.selectedOrder ? 'border-red-500' : ''}`}>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<div>
@@ -448,6 +417,7 @@ export function WorkOrderCreate() {
</div>
</div>
)}
{validationErrors.selectedOrder && <p className="text-sm text-red-500 mt-1">{validationErrors.selectedOrder}</p>}
</section>
)}
@@ -459,21 +429,29 @@ export function WorkOrderCreate() {
<Label> *</Label>
<Input
value={formData.client}
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, client: e.target.value });
clearFieldError('client');
}}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
disabled={mode === 'linked'}
className="bg-white"
className={`bg-white ${validationErrors.client ? 'border-red-500' : ''}`}
/>
{validationErrors.client && <p className="text-sm text-red-500">{validationErrors.client}</p>}
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
value={formData.projectName}
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, projectName: e.target.value });
clearFieldError('projectName');
}}
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
disabled={mode === 'linked'}
className="bg-white"
className={`bg-white ${validationErrors.projectName ? 'border-red-500' : ''}`}
/>
{validationErrors.projectName && <p className="text-sm text-red-500">{validationErrors.projectName}</p>}
</div>
<div className="space-y-2">
<Label></Label>
@@ -506,10 +484,13 @@ export function WorkOrderCreate() {
<Label> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
onValueChange={(value) => {
setFormData({ ...formData, processId: parseInt(value) });
clearFieldError('processId');
}}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
</SelectTrigger>
<SelectContent>
@@ -520,6 +501,7 @@ export function WorkOrderCreate() {
))}
</SelectContent>
</Select>
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
<p className="text-xs text-muted-foreground">
: {getSelectedProcessCode()}
</p>
@@ -529,8 +511,13 @@ export function WorkOrderCreate() {
<Label> *</Label>
<DatePicker
value={formData.shipmentDate}
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
onChange={(date) => {
setFormData({ ...formData, shipmentDate: date });
clearFieldError('shipmentDate');
}}
className={validationErrors.shipmentDate ? 'border-red-500' : ''}
/>
{validationErrors.shipmentDate && <p className="text-sm text-red-500">{validationErrors.shipmentDate}</p>}
</div>
<div className="space-y-2">
@@ -717,7 +704,7 @@ export function WorkOrderCreate() {
/>
</section>
</div>
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems]);
), [mode, formData, validationErrors, processOptions, isLoadingProcesses, assigneeNames, getSelectedProcessCode, manualItems, showItemSearch, itemSearchQuery, itemSearchResults, isSearchingItems, clearFieldError]);
return (
<>
@@ -751,4 +738,4 @@ export function WorkOrderCreate() {
/>
</>
);
}
}