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

View File

@@ -30,7 +30,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { AssigneeSelectModal } from './AssigneeSelectModal';
import { toast } from 'sonner';
@@ -52,17 +51,6 @@ interface EditableItem extends WorkOrderItem {
editQuantity?: number;
}
// Validation 에러 타입
interface ValidationErrors {
[key: string]: string;
}
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
processId: '공정',
scheduledDate: '출고예정일',
};
interface FormData {
// 기본 정보 (읽기 전용)
client: string;
@@ -101,7 +89,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [deleteTargetItemId, setDeleteTargetItemId] = useState<string | null>(null);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
@@ -213,7 +201,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 폼 제출
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
const errors: Record<string, string> = {};
if (!formData.processId) {
errors.processId = '공정을 선택해주세요';
@@ -226,7 +214,8 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 에러가 있으면 상태 업데이트 후 리턴
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: '입력 정보를 확인해주세요.' };
}
@@ -344,35 +333,6 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
// 폼 컨텐츠 렌더링 (기획서 4열 그리드)
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>
)}
{/* 기본 정보 (기획서 4열 구성) */}
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
<h3 className="font-semibold mb-4"> </h3>
@@ -391,10 +351,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<Label className="text-sm text-muted-foreground"> *</Label>
<Select
value={formData.processId?.toString() || ''}
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
onValueChange={(value) => {
setFormData({ ...formData, processId: parseInt(value) });
if (validationErrors.processId) {
setValidationErrors(prev => { const { processId: _, ...rest } = prev; return rest; });
}
}}
disabled={isLoadingProcesses}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors.processId ? 'border-red-500' : ''}`}>
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정 선택'} />
</SelectTrigger>
<SelectContent>
@@ -405,6 +370,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
))}
</SelectContent>
</Select>
{validationErrors.processId && <p className="text-sm text-red-500">{validationErrors.processId}</p>}
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
@@ -442,8 +408,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
<Label className="text-sm text-muted-foreground"> *</Label>
<DatePicker
value={formData.scheduledDate}
onChange={(date) => setFormData({ ...formData, scheduledDate: date })}
onChange={(date) => {
setFormData({ ...formData, scheduledDate: date });
if (validationErrors.scheduledDate) {
setValidationErrors(prev => { const { scheduledDate: _, ...rest } = prev; return rest; });
}
}}
className={validationErrors.scheduledDate ? 'border-red-500' : ''}
/>
{validationErrors.scheduledDate && <p className="text-sm text-red-500">{validationErrors.scheduledDate}</p>}
</div>
<div className="space-y-1">
<Label className="text-sm text-muted-foreground"></Label>
@@ -671,4 +644,4 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
/>
</>
);
}
}

View File

@@ -348,15 +348,28 @@ export default function WorkerScreen() {
// 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] }
const [stepProgressMap, setStepProgressMap] = useState<Record<string, StepProgressItem[]>>({});
// 데이터 로드
// 데이터 로드 (작업목록 + 공정목록 + 부서목록 병렬)
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getMyWorkOrders();
if (result.success) {
setWorkOrders(result.data);
const [workOrderResult, processResult, deptResult] = await Promise.all([
getMyWorkOrders(),
getProcessList({ size: 100 }),
getDepartments(),
]);
if (workOrderResult.success) {
setWorkOrders(workOrderResult.data);
} else {
toast.error(result.error || '작업 목록 조회에 실패했습니다.');
toast.error(workOrderResult.error || '작업 목록 조회에 실패했습니다.');
}
if (processResult.success && processResult.data?.items) {
setProcessListCache(processResult.data.items);
}
if (deptResult.success) {
setDepartmentList(deptResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -369,10 +382,6 @@ export default function WorkerScreen() {
useEffect(() => {
loadData();
// 부서 목록 로드
getDepartments().then((res) => {
if (res.success) setDepartmentList(res.data);
});
}, [loadData]);
// 부서 선택 시 해당 부서 사용자 목록 로드
@@ -455,21 +464,6 @@ export default function WorkerScreen() {
// 공정 목록 캐시
const [processListCache, setProcessListCache] = useState<Process[]>([]);
// 공정 목록 조회 (최초 1회)
useEffect(() => {
const fetchProcessList = async () => {
try {
const result = await getProcessList({ size: 100 });
if (result.success && result.data?.items) {
setProcessListCache(result.data.items);
}
} catch (error) {
console.error('Failed to fetch process list:', error);
}
};
fetchProcessList();
}, []);
// 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만
const processTabs = useMemo(() => {
return processListCache.filter((p) => p.status === '사용중');
@@ -1478,6 +1472,9 @@ export default function WorkerScreen() {
</div>
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */}
{isLoading ? (
<ContentSkeleton type="list" rows={1} />
) : (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v)}
@@ -1708,11 +1705,12 @@ export default function WorkerScreen() {
</TabsContent>
))}
</Tabs>
)}
</div>
{/* 하단 고정 버튼 */}
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
<div className={`fixed bottom-4 left-3 right-3 px-3 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:left-auto md:right-[24px] md:px-6 ${sidebarCollapsed ? 'md:left-[113px]' : 'md:left-[304px]'}`}>
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex gap-2 md:gap-3">
{hasWipItems ? (
<Button