From 00a6209347694e26c72c044eb1b79b93a3ee10b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 5 Mar 2026 13:35:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83/?= =?UTF-8?q?=EC=B6=9C=ED=95=98/=EC=83=9D=EC=82=B0/=ED=9A=8C=EA=B3=84/?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A0=84=EB=B0=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 불필요 데이터 제거 --- .../(protected)/dev/bill-prototype/page.tsx | 955 ++++++++++++++++++ src/app/api/pdf/generate/route.ts | 16 +- .../accounting/DailyReport/index.tsx | 18 +- .../VendorManagement/VendorDetail.tsx | 58 +- .../business/CEODashboard/CEODashboard.tsx | 14 +- .../business/CEODashboard/mockData.ts | 46 - .../sections/CardManagementSection.tsx | 71 +- .../sections/DailyAttendanceSection.tsx | 2 +- .../sections/DailyProductionSection.tsx | 7 + .../document-system/viewer/DocumentViewer.tsx | 3 +- src/components/layout/HeaderFavoritesBar.tsx | 315 ++++-- src/components/layout/Sidebar.tsx | 8 +- .../ReceivingManagement/InspectionCreate.tsx | 142 +-- .../ShipmentManagement/ShipmentCreate.tsx | 65 +- .../VehicleDispatchDetail.tsx | 42 +- .../VehicleDispatchEdit.tsx | 28 +- .../VehicleDispatchManagement/actions.ts | 129 ++- .../production/WorkOrders/WorkOrderCreate.tsx | 107 +- .../production/WorkOrders/WorkOrderEdit.tsx | 69 +- .../production/WorkerScreen/index.tsx | 48 +- src/hooks/useCEODashboard.ts | 35 +- src/layouts/AuthenticatedLayout.tsx | 12 +- src/lib/api/dashboard/transformers/expense.ts | 16 +- 23 files changed, 1689 insertions(+), 517 deletions(-) create mode 100644 src/app/[locale]/(protected)/dev/bill-prototype/page.tsx diff --git a/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx b/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx new file mode 100644 index 00000000..f7139955 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx @@ -0,0 +1,955 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { Plus, Trash2, AlertTriangle, Info, ChevronDown, ChevronUp } from 'lucide-react'; +import { PageLayout } from '@/components/organisms/PageLayout'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +import { CurrencyInput } from '@/components/ui/currency-input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +// ===== 증권 종류 ===== +const INSTRUMENT_TYPE_OPTIONS = [ + { value: 'promissory', label: '약속어음' }, + { value: 'exchange', label: '환어음' }, + { value: 'cashierCheck', label: '자기앞수표 (가게수표)' }, + { value: 'currentCheck', label: '당좌수표' }, +]; + +// ===== 거래 방향 ===== +const BILL_DIRECTION_OPTIONS = [ + { value: 'received', label: '수취 (받을어음)' }, + { value: 'issued', label: '발행 (지급어음)' }, +]; + +// ===== 전자/지류 ===== +const MEDIUM_OPTIONS = [ + { value: 'electronic', label: '전자' }, + { value: 'paper', label: '지류 (종이)' }, +]; + +// ===== 배서 가능 여부 ===== +const ENDORSEMENT_OPTIONS = [ + { value: 'endorsable', label: '배서 가능' }, + { value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' }, +]; + +// ===== 상태 (수취) ===== +const RECEIVED_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'endorsed', label: '배서양도' }, + { value: 'discounted', label: '할인' }, + { value: 'collected', label: '추심' }, + { value: 'maturityAlert', label: '만기임박 (7일전)' }, + { value: 'maturityDeposit', label: '만기입금' }, + { value: 'paymentComplete', label: '결제완료' }, + { value: 'dishonored', label: '부도' }, +]; + +// ===== 상태 (발행) ===== +const ISSUED_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'maturityAlert', label: '만기임박 (7일전)' }, + { value: 'maturityPayment', label: '만기결제' }, + { value: 'collectionRequest', label: '추심의뢰' }, + { value: 'collectionComplete', label: '추심완료' }, + { value: 'suing', label: '추소중' }, + { value: 'dishonored', label: '부도' }, +]; + +// ===== 차수 처리구분 ===== +const INSTALLMENT_TYPE_OPTIONS = [ + { value: 'endorsement', label: '배서양도' }, + { value: 'collection', label: '추심' }, + { value: 'discount', label: '할인' }, + { value: 'payment', label: '결제' }, + { value: 'split', label: '분할' }, + { value: 'other', label: '기타' }, +]; + +// ===== 부도사유 ===== +const DISHONOR_REASON_OPTIONS = [ + { value: 'insufficient_funds', label: '자금부족 (1호 부도)' }, + { value: 'trading_suspension', label: '거래정지처분 (2호 부도)' }, + { value: 'formal_defect', label: '형식불비' }, + { value: 'signature_mismatch', label: '서명/인감 불일치' }, + { value: 'expired', label: '제시기간 경과' }, + { value: 'other', label: '기타' }, +]; + +// ===== 차수 레코드 (확장) ===== +interface InstallmentRecord { + id: string; + date: string; + type: string; + amount: number; + counterparty: string; + note: string; +} + +// ===== 폼 데이터 ===== +interface BillFormData { + // 기본 정보 + billNumber: string; + instrumentType: string; + direction: string; + medium: string; + endorsement: string; + vendorId: string; + amount: number; + issueDate: string; + maturityDate: string; + status: string; + note: string; + issuerBank: string; + paymentPlace: string; + bankAccountInfo: string; + // 전자어음 추가 (조건: medium = electronic) + electronicBillNo: string; + registrationOrg: string; + // 환어음 추가 (조건: instrumentType = exchange) + drawee: string; + acceptanceStatus: string; + acceptanceDate: string; + // 할인 정보 (조건: status = discounted) + discountDate: string; + discountBank: string; + discountRate: number; + discountAmount: number; + netReceivedAmount: number; + // 배서양도 정보 (조건: status = endorsed) + endorsementDate: string; + endorsee: string; + endorsementReason: string; + // 추심 정보 (조건: status = collected/collectionRequest) + collectionBank: string; + collectionRequestDate: string; + collectionFee: number; + // 분할 정보 + isSplit: boolean; + splitCount: number; + splitAmount: number; + // 부도 정보 (조건: status = dishonored) + dishonoredDate: string; + dishonoredReason: string; + // 차수 관리 + installments: InstallmentRecord[]; +} + +const INITIAL_FORM: BillFormData = { + billNumber: '', + instrumentType: 'promissory', + direction: 'received', + medium: 'paper', + endorsement: 'endorsable', + vendorId: '', + amount: 0, + issueDate: '', + maturityDate: '', + status: 'stored', + note: '', + issuerBank: '', + paymentPlace: '', + bankAccountInfo: '', + electronicBillNo: '', + registrationOrg: '', + drawee: '', + acceptanceStatus: '', + acceptanceDate: '', + discountDate: '', + discountBank: '', + discountRate: 0, + discountAmount: 0, + netReceivedAmount: 0, + endorsementDate: '', + endorsee: '', + endorsementReason: '', + collectionBank: '', + collectionRequestDate: '', + collectionFee: 0, + isSplit: false, + splitCount: 0, + splitAmount: 0, + dishonoredDate: '', + dishonoredReason: '', + installments: [], +}; + +// ===== NEW 뱃지 ===== +function NewBadge() { + return ( + + NEW + + ); +} + +// ===== 조건부 뱃지 ===== +function CondBadge({ label }: { label: string }) { + return ( + + {label} + + ); +} + +export default function BillPrototypePage() { + const [formData, setFormData] = useState(INITIAL_FORM); + + const updateField = useCallback(( + field: K, + value: BillFormData[K] + ) => { + setFormData(prev => ({ ...prev, [field]: value })); + }, []); + + // 상태 옵션 (방향에 따라) + const statusOptions = formData.direction === 'received' + ? RECEIVED_STATUS_OPTIONS + : ISSUED_STATUS_OPTIONS; + + // 조건부 표시 플래그 + const showElectronic = formData.medium === 'electronic'; + const showExchangeBill = formData.instrumentType === 'exchange'; + const showDiscount = formData.status === 'discounted'; + const showEndorsement = formData.status === 'endorsed'; + const showCollection = ['collected', 'collectionRequest', 'collectionComplete'].includes(formData.status); + const showDishonored = formData.status === 'dishonored'; + + // 할인 실수령액 자동계산 + const calcNetReceived = useMemo(() => { + if (formData.amount > 0 && formData.discountAmount > 0) { + return formData.amount - formData.discountAmount; + } + return 0; + }, [formData.amount, formData.discountAmount]); + + // 분할 합계 + const splitTotal = formData.splitCount * formData.splitAmount; + + // 차수 관리 + const handleAddInstallment = useCallback(() => { + setFormData(prev => ({ + ...prev, + installments: [...prev.installments, { + id: `inst-${Date.now()}`, + date: '', + type: 'payment', + amount: 0, + counterparty: '', + note: '', + }], + })); + }, []); + + const handleRemoveInstallment = useCallback((id: string) => { + setFormData(prev => ({ + ...prev, + installments: prev.installments.filter(inst => inst.id !== id), + })); + }, []); + + const handleUpdateInstallment = useCallback(( + id: string, + field: keyof InstallmentRecord, + value: string | number + ) => { + setFormData(prev => ({ + ...prev, + installments: prev.installments.map(inst => + inst.id === id ? { ...inst, [field]: value } : inst + ), + })); + }, []); + + return ( + + {/* 페이지 헤더 */} +
+

어음 등록 (개선안 프로토타입 v2)

+

실무자 확인용 - 조건부 필드 포함

+
+ + {/* 안내 배너 */} + + +
+
+ + 이 페이지는 프로토타입입니다. 실제 데이터 저장되지 않습니다. +
+
+ + NEW + 신규 필드 + + + 조건부 + 특정 조건에서만 표시 + +
+
+
+
+ + {/* ===== 기본 정보 ===== */} + + + 기본 정보 + + +
+ {/* 어음번호 */} +
+ + updateField('billNumber', e.target.value)} + placeholder="자동생성 또는 직접입력" + /> +
+ + {/* 증권종류 */} +
+ + +
+ + {/* 거래방향 */} +
+ + +
+ + {/* 전자/지류 */} +
+ + +
+ + {/* 배서 여부 */} +
+ + +
+ + {/* 거래처 */} +
+ + +
+ + {/* 금액 */} +
+ + updateField('amount', value ?? 0)} /> +
+ + {/* 발행일 */} +
+ + updateField('issueDate', date)} /> +
+ + {/* 만기일 */} +
+ + updateField('maturityDate', date)} /> +
+ + {/* 발행은행 */} +
+ + updateField('issuerBank', e.target.value)} + placeholder="예: 국민은행" + /> +
+ + {/* 지급지 */} +
+ + updateField('paymentPlace', e.target.value)} + placeholder="예: 국민은행 강남지점" + /> +
+ + {/* 상태 */} +
+ + +
+ + {/* 입금/출금 계좌 */} +
+ + +
+ + {/* 비고 */} +
+ + updateField('note', e.target.value)} + placeholder="비고를 입력해주세요" + /> +
+
+
+
+ + {/* ===== 전자어음 추가 정보 (조건: 전자/지류 = 전자) ===== */} + {showElectronic && ( + + + + 전자어음 정보 + + + + +
+
+ + updateField('electronicBillNo', e.target.value)} + placeholder="전자어음시스템 발급번호" + /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 환어음 추가 정보 (조건: 증권종류 = 환어음) ===== */} + {showExchangeBill && ( + + + + 환어음 정보 + + + + +
+
+ + updateField('drawee', e.target.value)} + placeholder="지급 의무자" + /> +
+
+ + +
+
+ + updateField('acceptanceDate', date)} + /> +
+
+
+
+ )} + + {/* ===== 할인 정보 (조건: 상태 = 할인) ===== */} + {showDiscount && ( + + + + 할인 정보 + + + + +
+
+ + updateField('discountDate', date)} + /> +
+
+ + updateField('discountBank', e.target.value)} + placeholder="예: 국민은행 강남지점" + /> +
+
+ + { + const rate = parseFloat(e.target.value) || 0; + updateField('discountRate', rate); + // 할인율 변경 시 할인금액 자동계산 + if (formData.amount > 0 && rate > 0) { + updateField('discountAmount', Math.round(formData.amount * rate / 100)); + } + }} + placeholder="예: 3.5" + /> +
+
+ + updateField('discountAmount', value ?? 0)} + /> +
+
+ +
+ {calcNetReceived > 0 + ? ₩ {calcNetReceived.toLocaleString()} + : 어음금액 - 할인금액 + } +
+
+
+
+
+ )} + + {/* ===== 배서양도 정보 (조건: 상태 = 배서양도) ===== */} + {showEndorsement && ( + + + + 배서양도 정보 + + + + +
+
+ + updateField('endorsementDate', date)} + /> +
+
+ + updateField('endorsee', e.target.value)} + placeholder="어음을 넘겨받는 자" + /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 추심 정보 (조건: 상태 = 추심/추심의뢰/추심완료) ===== */} + {showCollection && ( + + + + 추심 정보 + + + + +
+
+ + updateField('collectionBank', e.target.value)} + placeholder="추심 의뢰 은행" + /> +
+
+ + updateField('collectionRequestDate', date)} + /> +
+
+ + updateField('collectionFee', value ?? 0)} + /> +
+
+
+
+ )} + + {/* ===== 분할 정보 ===== */} + + + + 분할 정보 + + + +
+
+ { + updateField('isSplit', checked); + if (!checked) { updateField('splitCount', 0); updateField('splitAmount', 0); } + }} + /> + +
+ {formData.isSplit && ( +
+
+ + updateField('splitCount', parseInt(e.target.value) || 0)} + placeholder="장수 입력" + /> +
+
+ + updateField('splitAmount', value ?? 0)} /> +
+
+ +
+ {splitTotal > 0 ? `₩ ${splitTotal.toLocaleString()}` : '-'} + {splitTotal > 0 && formData.amount > 0 && splitTotal !== formData.amount && ( + + + 어음 금액과 불일치 + + )} +
+
+
+ )} +
+
+
+ + {/* ===== 부도 정보 (조건: 상태 = 부도) ===== */} + {showDishonored && ( + + + + 부도 정보 + + 부도 + + + +
+
+ + updateField('dishonoredDate', date)} + /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 차수 관리 ===== */} + + + + 차수 관리확장 + + + + +
+ + + + No + 일자 + 처리구분 + 금액 + 상대처 + 비고 + 삭제 + + + + {formData.installments.length === 0 ? ( + + + 등록된 차수가 없습니다 + + + ) : ( + formData.installments.map((inst, index) => ( + + {index + 1} + + handleUpdateInstallment(inst.id, 'date', date)} size="sm" /> + + + + + + handleUpdateInstallment(inst.id, 'amount', value ?? 0)} + className="h-8 text-sm" + /> + + + handleUpdateInstallment(inst.id, 'counterparty', e.target.value)} + placeholder="양수인/추심처" + className="h-8 text-sm" + /> + + + handleUpdateInstallment(inst.id, 'note', e.target.value)} + className="h-8 text-sm" + /> + + + + + + )) + )} + +
+
+
+
+ + {/* ===== 조건부 필드 가이드 (실무자 확인용) ===== */} + + + 조건부 필드 가이드 (실무자 확인용) + + +
+

아래 조건을 변경하면 해당 섹션이 자동으로 나타납니다. 직접 선택해서 확인해보세요.

+ + + + + 조건 + 나타나는 섹션 + 포함 필드 + 현재 + + + + + 전자/지류 = 전자 + 전자어음 정보 + 전자어음관리번호, 등록기관 + {showElectronic ? 표시중 : 숨김} + + + 증권종류 = 환어음 + 환어음 정보 + 지급인(drawee), 인수여부, 인수일자 + {showExchangeBill ? 표시중 : 숨김} + + + 상태 = 할인 + 할인 정보 + 할인일자, 할인처, 할인율, 할인금액, 실수령액(자동) + {showDiscount ? 표시중 : 숨김} + + + 상태 = 배서양도 + 배서양도 정보 + 배서일자, 피배서인(양수인), 배서사유 + {showEndorsement ? 표시중 : 숨김} + + + 상태 = 추심/추심의뢰 + 추심 정보 + 추심은행, 추심의뢰일, 추심수수료 + {showCollection ? 표시중 : 숨김} + + + 상태 = 부도 + 부도 정보 + 부도일자, 부도사유 (1호/2호/형식불비 등) + {showDishonored ? 표시중 : 숨김} + + + 분할 토글 ON + 분할 상세 + 장수, 장당금액, 합계(자동/불일치 경고) + {formData.isSplit ? 표시중 : 숨김} + + +
+
+
+
+ + {/* 하단 버튼 */} +
+ + +
+
+ ); +} diff --git a/src/app/api/pdf/generate/route.ts b/src/app/api/pdf/generate/route.ts index 38238644..b3e8bbd4 100644 --- a/src/app/api/pdf/generate/route.ts +++ b/src/app/api/pdf/generate/route.ts @@ -114,9 +114,21 @@ export async function POST(request: NextRequest) { deviceScaleFactor: 2, }); - // HTML 설정 + // 외부 리소스 요청 차단 (이미지는 이미 base64 인라인) + await page.setRequestInterception(true); + page.on('request', (req) => { + const resourceType = req.resourceType(); + // 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지 + if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) { + req.abort(); + } else { + req.continue(); + } + }); + + // HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함) await page.setContent(fullHtml, { - waitUntil: 'networkidle0', + waitUntil: 'domcontentloaded', }); // 헤더 템플릿 (문서번호, 생성일) diff --git a/src/components/accounting/DailyReport/index.tsx b/src/components/accounting/DailyReport/index.tsx index 31cfe33f..9b6e7f3f 100644 --- a/src/components/accounting/DailyReport/index.tsx +++ b/src/components/accounting/DailyReport/index.tsx @@ -20,6 +20,7 @@ import { Input } from '@/components/ui/input'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { formatNumber as formatAmount } from '@/lib/utils/amount'; +import { printElement } from '@/lib/print-utils'; import type { NoteReceivableItem, DailyAccountItem } from './types'; import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions'; import { toast } from 'sonner'; @@ -204,9 +205,19 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts }, []); // ===== 인쇄 ===== + const printAreaRef = useRef(null); const handlePrint = useCallback(() => { - window.print(); - }, []); + if (printAreaRef.current) { + printElement(printAreaRef.current, { + title: `일일일보_${startDate}`, + styles: ` + .print-container { font-size: 11px; } + table { width: 100%; margin-bottom: 12px; } + h3 { margin-bottom: 8px; } + `, + }); + } + }, [startDate]); // ===== USD 금액 포맷 ===== const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []); @@ -299,6 +310,8 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts + {/* 인쇄 영역 */} +
{/* 일자별 입출금 합계 */} @@ -660,6 +673,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
+ ); } diff --git a/src/components/accounting/VendorManagement/VendorDetail.tsx b/src/components/accounting/VendorManagement/VendorDetail.tsx index a42df570..6fcdf49d 100644 --- a/src/components/accounting/VendorManagement/VendorDetail.tsx +++ b/src/components/accounting/VendorManagement/VendorDetail.tsx @@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai import { vendorConfig } from './vendorConfig'; import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal'; -// 필드명 매핑 -const FIELD_NAME_MAP: Record = { - businessNumber: '사업자등록번호', - vendorName: '거래처명', - category: '거래처 유형', -}; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -29,7 +23,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Alert, AlertDescription } from '@/components/ui/alert'; // 새 입력 컴포넌트 import { PhoneInput } from '@/components/ui/phone-input'; import { BusinessNumberInput } from '@/components/ui/business-number-input'; @@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { } setValidationErrors(errors); + if (Object.keys(errors).length > 0) { + const firstError = Object.values(errors)[0]; + toast.error(firstError); + } return Object.keys(errors).length === 0; }, [formData.businessNumber, formData.vendorName, formData.category]); // 필드 변경 핸들러 const handleChange = useCallback((field: string, value: string | number | boolean) => { setFormData(prev => ({ ...prev, [field]: value })); - }, []); + // 에러 클리어 + if (validationErrors[field]) { + setValidationErrors(prev => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }, [validationErrors]); // 파일 검증 및 추가 const validateAndAddFiles = useCallback((files: FileList | File[]) => { @@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { // 저장 핸들러 (IntegratedDetailTemplate용) const handleSubmit = useCallback(async () => { if (!validateForm()) { - window.scrollTo({ top: 0, behavior: 'smooth' }); return { success: false, error: '입력 내용을 확인해주세요.' }; } @@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)} placeholder={placeholder} disabled={isViewMode || disabled} - className="bg-white" + className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`} /> + {validationErrors[field] &&

{validationErrors[field]}

} ); }; @@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { onValueChange={(val) => handleChange(field, val)} disabled={isViewMode} > - + @@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { ))} + {validationErrors[field] &&

{validationErrors[field]}

} ); }; @@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { // 폼 콘텐츠 렌더링 (View/Edit 공통) const renderFormContent = () => (
- {/* Validation 에러 표시 */} - {Object.keys(validationErrors).length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류) - -
    - {Object.entries(validationErrors).map(([field, message]) => { - const fieldName = FIELD_NAME_MAP[field] || field; - return ( -
  • - - - {fieldName}: {message} - -
  • - ); - })} -
-
-
-
-
- )} - {/* 기본 정보 */} @@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { showValidation={!isViewMode} error={!!validationErrors.businessNumber} /> + {validationErrors.businessNumber &&

{validationErrors.businessNumber}

}
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })} {renderField('거래처명', 'vendorName', formData.vendorName, { required: true })} diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 4f8da43c..40fd0109 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -49,14 +49,14 @@ import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, t export function CEODashboard() { const router = useRouter(); - // API 데이터 Hook (신규 6개는 백엔드 API 구현 전까지 비활성) + // API 데이터 Hook const apiData = useCEODashboard({ - salesStatus: false, - purchaseStatus: false, - dailyProduction: false, - unshipped: false, - construction: false, - dailyAttendance: false, + salesStatus: true, + purchaseStatus: true, + dailyProduction: true, + unshipped: true, + construction: true, + dailyAttendance: true, }); // TodayIssue API Hook (Phase 2) diff --git a/src/components/business/CEODashboard/mockData.ts b/src/components/business/CEODashboard/mockData.ts index e7c41222..032c2370 100644 --- a/src/components/business/CEODashboard/mockData.ts +++ b/src/components/business/CEODashboard/mockData.ts @@ -100,52 +100,6 @@ const _originalMockData: CEODashboardData = { }, ], }, - cardManagement: { - warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의', - cards: [ - { id: 'cm1', label: '카드', amount: 3123000, previousLabel: '미정리 5건' }, - { id: 'cm2', label: '경조사', amount: 3123000, previousLabel: '미증빙 5건' }, - { id: 'cm3', label: '상품권', amount: 3123000, previousLabel: '미증빙 5건' }, - { id: 'cm4', label: '접대비', amount: 3123000, previousLabel: '미증빙 5건' }, - { id: 'cm_total', label: '총 가지급금 합계', amount: 350000000 }, - ], - checkPoints: [ - { - id: 'cm-cp1', - type: 'success', - message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.', - highlights: [ - { text: '850만원', color: 'red' }, - { text: '가지급금', color: 'red' }, - { text: '연 4.6% 인정이자', color: 'red' }, - ], - }, - { - id: 'cm-cp2', - type: 'success', - message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.', - highlights: [ - { text: '연 약 1,400만원의 인정이자', color: 'red' }, - ], - }, - { - id: 'cm-cp3', - type: 'success', - message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.', - highlights: [ - { text: '불인정 항목 결제 감지', color: 'red' }, - ], - }, - { - id: 'cm-cp4', - type: 'success', - message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.', - highlights: [ - { text: '주말 카드 사용 100만원 결제 감지', color: 'red' }, - ], - }, - ], - }, entertainment: { cards: [ { id: 'et1', label: '주말/심야', amount: 3123000, previousLabel: '미증빙 5건' }, diff --git a/src/components/business/CEODashboard/sections/CardManagementSection.tsx b/src/components/business/CEODashboard/sections/CardManagementSection.tsx index 487ccd66..83524f90 100644 --- a/src/components/business/CEODashboard/sections/CardManagementSection.tsx +++ b/src/components/business/CEODashboard/sections/CardManagementSection.tsx @@ -1,7 +1,9 @@ 'use client'; +import { useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { CreditCard, Wallet, Receipt, AlertTriangle, Gift } from 'lucide-react'; +import { CreditCard, Wallet, Receipt, AlertTriangle, Gift, CheckCircle2, ShieldAlert } from 'lucide-react'; +import { formatKoreanAmount } from '@/lib/utils/amount'; import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components'; import type { CardManagementData } from '../types'; @@ -14,9 +16,33 @@ interface CardManagementSectionProps { onCardClick?: (cardId: string) => void; } +/** subLabel에서 "미정리 N건", "미증빙 N건" 등의 건수를 파싱 */ +function parseIssueCount(subLabel?: string): number { + if (!subLabel) return 0; + const match = subLabel.match(/(\d+)\s*건/); + return match ? parseInt(match[1], 10) : 0; +} + export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) { const router = useRouter(); + // 카드별 미정리/미증빙 건수 집계 + const issueStats = useMemo(() => { + let totalCount = 0; + let totalAmount = 0; + const issueCards: string[] = []; + + for (const card of data.cards) { + const count = parseIssueCount(card.subLabel); + if (count > 0 || card.isHighlighted) { + totalCount += count; + totalAmount += card.subAmount ?? 0; + issueCards.push(card.label); + } + } + return { totalCount, totalAmount, issueCards, hasIssues: totalCount > 0 }; + }, [data.cards]); + const handleClick = (cardId: string) => { if (onCardClick) { onCardClick(cardId); @@ -31,9 +57,46 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti title="가지급금 현황" subtitle="가지급금 관리 현황" > - {data.warningBanner && ( -
- + {/* 상태 배너: 미정리 있으면 빨간 펄스, 정상이면 초록 */} + {issueStats.hasIssues ? ( +
+ {/* 펄스 배경 */} +
+
+
+
+ +
+
+

+ 미정리 {issueStats.totalCount}건 +

+

+ {issueStats.issueCards.join(' · ')} +

+
+
+ {issueStats.totalAmount > 0 && ( +
+

+ {formatKoreanAmount(issueStats.totalAmount)} +

+

미정리 총액

+
+ )} +
+
+ ) : ( +
+ + 미정리 건 없음 — 가지급금 정상 관리 중 +
+ )} + + {/* 기존 warningBanner 호환 (issueStats과 별도 메시지가 있는 경우) */} + {data.warningBanner && issueStats.hasIssues && ( +
+ {data.warningBanner}
)} diff --git a/src/components/business/CEODashboard/sections/DailyAttendanceSection.tsx b/src/components/business/CEODashboard/sections/DailyAttendanceSection.tsx index e4a1171b..60c93f65 100644 --- a/src/components/business/CEODashboard/sections/DailyAttendanceSection.tsx +++ b/src/components/business/CEODashboard/sections/DailyAttendanceSection.tsx @@ -53,7 +53,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) { + + + {favorites.map((item) => { + const Icon = getIcon(item.iconName); + return ( + onItemClick(item)} + className="flex items-center gap-2 cursor-pointer" + > + {Icon && } + {item.label} + + ); + })} + + + ); +} + export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) { const router = useRouter(); const { favorites } = useFavoritesStore(); const [isTablet, setIsTablet] = useState(false); + const containerRef = useRef(null); + const chipWidthsRef = useRef([]); + const measuredRef = useRef(false); + const [visibleCount, setVisibleCount] = useState(favorites.length); + const [hoveredId, setHoveredId] = useState(null); // 태블릿 감지 (768~1024) useEffect(() => { @@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps return () => window.removeEventListener('resize', check); }, []); + // 즐겨찾기 변경 시 측정 리셋 + useEffect(() => { + measuredRef.current = false; + chipWidthsRef.current = []; + setVisibleCount(favorites.length); + }, [favorites.length]); + + // 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋 + useEffect(() => { + if (!isMobile && !isTablet) { + measuredRef.current = false; + chipWidthsRef.current = []; + setVisibleCount(favorites.length); + } + }, [isMobile, isTablet, favorites.length]); + + // 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산 + useEffect(() => { + if (isMobile || isTablet) return; + const container = containerRef.current; + if (!container) return; + + const calculate = () => { + // 최초: 전체 chip 렌더 상태에서 폭 저장 + if (!measuredRef.current) { + const chips = container.querySelectorAll('[data-chip]'); + if (chips.length === favorites.length && chips.length > 0) { + chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth); + measuredRef.current = true; + } else { + return; + } + } + + const containerWidth = container.offsetWidth; + const widths = chipWidthsRef.current; + + // 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운 + const minChipWidth = Math.min(...widths); + if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) { + setVisibleCount(0); + return; + } + + let totalWidth = 0; + let count = 0; + + for (let i = 0; i < widths.length; i++) { + const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0); + const hasMore = i < widths.length - 1; + const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0; + if (needed + reserve > containerWidth && count > 0) break; + totalWidth = needed; + count++; + } + setVisibleCount(count); + }; + + requestAnimationFrame(calculate); + const observer = new ResizeObserver(calculate); + observer.observe(container); + return () => observer.disconnect(); + }, [isMobile, isTablet, favorites.length]); + const handleClick = useCallback( (item: FavoriteItem) => { router.push(item.path); @@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps if (favorites.length === 0) return null; - const getIcon = (iconName: string) => { - return iconMap[iconName] || null; - }; + const getIcon = (iconName: string) => iconMap[iconName] || null; - // 모바일 & 태블릿: 별 아이콘 드롭다운 - if (isMobile || isTablet) { + // 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요) + if (isMobile) { return ( - - - - - - {favorites.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - + ); } - // 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운 - const DESKTOP_ICON_LIMIT = 8; - - if (favorites.length > DESKTOP_ICON_LIMIT) { + // 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지) + if (isTablet) { return ( - - - - - - {favorites.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - +
+ +
); } + // 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성) + const visibleItems = favorites.slice(0, visibleCount); + const overflowItems = favorites.slice(visibleCount); + const showStarOnly = measuredRef.current && visibleCount === 0; + return ( -
- {favorites.map((item) => { - const Icon = getIcon(item.iconName); - if (!Icon) return null; - return ( - - - - - -

{item.label}

-
-
- ); - })} +
setHoveredId(null)} + > + {showStarOnly ? ( + + ) : ( + <> + {visibleItems.map((item) => { + const Icon = getIcon(item.iconName); + const isHovered = hoveredId === item.id; + const isOtherHovered = hoveredId !== null && !isHovered; + + const textMaxWidth = isHovered + ? TEXT_EXPANDED_MAX + : isOtherHovered + ? TEXT_SHRUNK_MAX + : TEXT_DEFAULT_MAX; + + return ( + + + + + +

{item.label}

+
+
+ ); + })} + {overflowItems.length > 0 && ( + + + + + + {overflowItems.map((item) => { + const Icon = getIcon(item.iconName); + return ( + handleClick(item)} + className="flex items-center gap-2 cursor-pointer" + > + {Icon && } + {item.label} + + ); + })} + + + )} + + )}
); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6df741aa..fe7212f7 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react'; +import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react'; import type { MenuItem } from '@/stores/menuStore'; import { useEffect, useRef, useCallback } from 'react'; import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore'; @@ -159,7 +159,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )}
@@ -224,7 +224,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )}
@@ -291,7 +291,7 @@ function MenuItemComponent({ }`} title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'} > - + )}
diff --git a/src/components/material/ReceivingManagement/InspectionCreate.tsx b/src/components/material/ReceivingManagement/InspectionCreate.tsx index 905f10d5..95c40421 100644 --- a/src/components/material/ReceivingManagement/InspectionCreate.tsx +++ b/src/components/material/ReceivingManagement/InspectionCreate.tsx @@ -12,12 +12,12 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; import { getTodayString } from '@/lib/utils/date'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { materialInspectionCreateConfig } from './inspectionConfig'; import { ContentSkeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; @@ -29,7 +29,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Alert, AlertDescription } from '@/components/ui/alert'; import { getReceivings } from './actions'; import type { InspectionCheckItem, ReceivingItem } from './types'; import { SuccessDialog } from './SuccessDialog'; @@ -81,7 +80,7 @@ export function InspectionCreate({ id }: Props) { const [opinion, setOpinion] = useState(''); // 유효성 검사 에러 - const [validationErrors, setValidationErrors] = useState([]); + const [validationErrors, setValidationErrors] = useState>({}); // 성공 다이얼로그 const [showSuccess, setShowSuccess] = useState(false); @@ -117,15 +116,22 @@ export function InspectionCreate({ id }: Props) { // 대상 선택 핸들러 const handleTargetSelect = useCallback((targetId: string) => { setSelectedTargetId(targetId); - setValidationErrors([]); }, []); // 판정 변경 핸들러 - const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => { + const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => { setInspectionItems((prev) => prev.map((item) => (item.id === itemId ? { ...item, judgment } : item)) ); - setValidationErrors([]); + // 해당 항목의 에러 클리어 + setValidationErrors((prev) => { + const key = `judgment_${index}`; + if (prev[key]) { + const { [key]: _, ...rest } = prev; + return rest; + } + return prev; + }); }, []); // 비고 변경 핸들러 @@ -137,22 +143,29 @@ export function InspectionCreate({ id }: Props) { // 유효성 검사 const validateForm = useCallback((): boolean => { - const errors: string[] = []; + const errors: Record = {}; // 필수 필드: 검사자 if (!inspector.trim()) { - errors.push('검사자는 필수 입력 항목입니다.'); + errors.inspector = '검사자는 필수 입력 항목입니다.'; } // 검사 항목 판정 확인 inspectionItems.forEach((item, index) => { if (!item.judgment) { - errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`); + errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`; } }); setValidationErrors(errors); - return errors.length === 0; + + if (Object.keys(errors).length > 0) { + const firstError = Object.values(errors)[0]; + toast.error(firstError); + return false; + } + + return true; }, [inspector, inspectionItems]); // 검사 저장 @@ -214,30 +227,6 @@ export function InspectionCreate({ id }: Props) { {/* 우측: 검사 정보 및 항목 */}
- {/* Validation 에러 표시 */} - {validationErrors.length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({validationErrors.length}개 오류) - -
    - {validationErrors.map((error, index) => ( -
  • - - {error} -
  • - ))} -
-
-
-
-
- )} - {/* 검사 정보 */}

검사 정보

@@ -257,10 +246,19 @@ export function InspectionCreate({ id }: Props) { value={inspector} onChange={(e) => { setInspector(e.target.value); - setValidationErrors([]); + if (validationErrors.inspector) { + setValidationErrors((prev) => { + const { inspector: _, ...rest } = prev; + return rest; + }); + } }} placeholder="검사자명 입력" + className={validationErrors.inspector ? 'border-red-500' : ''} /> + {validationErrors.inspector && ( +

{validationErrors.inspector}

+ )}
@@ -284,39 +282,45 @@ export function InspectionCreate({ id }: Props) { - {inspectionItems.map((item) => ( - - {item.name} - - {item.specification} - - {item.method} - - - - - handleRemarkChange(item.id, e.target.value)} - placeholder="비고" - className="h-8" - /> - - - ))} + {inspectionItems.map((item, index) => { + const judgmentErrorKey = `judgment_${index}`; + return ( + + {item.name} + + {item.specification} + + {item.method} + + + {validationErrors[judgmentErrorKey] && ( +

{validationErrors[judgmentErrorKey]}

+ )} + + + handleRemarkChange(item.id, e.target.value)} + placeholder="비고" + className="h-8" + /> + + + ); + })}
@@ -361,4 +365,4 @@ export function InspectionCreate({ id }: Props) { renderForm={renderFormContent} /> ); -} \ No newline at end of file +} diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 7b13305c..226a442a 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -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(null); - const [validationErrors, setValidationErrors] = useState([]); + const [validationErrors, setValidationErrors] = useState>({}); // 아코디언 상태 const [accordionValue, setAccordionValue] = useState([]); @@ -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 = {}; + 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; onChange: (key: string, value: unknown) => void; mode: string; errors: Record }) => (
- {/* Validation 에러 표시 */} - {validationErrors.length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({validationErrors.length}개 오류) - -
    - {validationErrors.map((err, index) => ( -
  • - - {err} -
  • - ))} -
-
-
-
-
- )} - {/* 카드 1: 기본 정보 */} @@ -393,7 +380,7 @@ export function ShipmentCreate() { onValueChange={handleLotChange} disabled={isSubmitting} > - + @@ -404,6 +391,7 @@ export function ShipmentCreate() { ))} + {validationErrors.lotNo &&

{validationErrors.lotNo}

}
{/* 현장명 - LOT 선택 시 자동 매핑 */}
@@ -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 &&

{validationErrors.scheduledDate}

}
@@ -449,7 +439,7 @@ export function ShipmentCreate() { onValueChange={(value) => handleInputChange('deliveryMethod', value)} disabled={isSubmitting} > - + @@ -460,6 +450,7 @@ export function ShipmentCreate() { ))} + {validationErrors.deliveryMethod &&

{validationErrors.deliveryMethod}

}
@@ -748,9 +739,7 @@ export function ShipmentCreate() { isLoading={false} onCancel={handleCancel} renderForm={(_props: { formData: Record; onChange: (key: string, value: unknown) => void; mode: string; errors: Record }) => ( - - {error} - +
{error}
)} /> ); diff --git a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx index 2107f8d0..71659a57 100644 --- a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx +++ b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx @@ -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) { - {/* 카드 2: 배차 정보 (테이블 형태) */} + {/* 카드 2: 배차 정보 */} 배차 정보 - - - - - 물류업체 - 입차일시 - 구분 - 차량번호 - 기사연락처 - 비고 - - - - - {detail.logisticsCompany || '-'} - {detail.arrivalDateTime || '-'} - {detail.tonnage || '-'} - {detail.vehicleNo || '-'} - {detail.driverContact || '-'} - {detail.remarks || '-'} - - -
+ +
+ {renderInfoField('물류업체', detail.logisticsCompany)} + {renderInfoField('입차일시', detail.arrivalDateTime)} + {renderInfoField('구분', detail.tonnage)} + {renderInfoField('차량번호', detail.vehicleNo)} + {renderInfoField('기사연락처', detail.driverContact)} + {renderInfoField('비고', detail.remarks)} +
diff --git a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx index ab45e5d5..0fd0f7d6 100644 --- a/src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx +++ b/src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx @@ -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(null); - const [validationErrors, setValidationErrors] = useState([]); + const [validationErrors, setValidationErrors] = useState>({}); // 데이터 로드 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) {
- {/* Validation 에러 표시 */} - {validationErrors.length > 0 && ( - - -
    - {validationErrors.map((err, index) => ( -
  • • {err}
  • - ))} -
-
-
- )} - {/* 카드 1: 기본 정보 (운임비용만 편집 가능) */} @@ -370,11 +356,9 @@ export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) { mode: string; errors: Record; }) => ( - - - {error || '배차차량 정보를 찾을 수 없습니다.'} - - +
+ {error || '배차차량 정보를 찾을 수 없습니다.'} +
)} /> ); diff --git a/src/components/outbound/VehicleDispatchManagement/actions.ts b/src/components/outbound/VehicleDispatchManagement/actions.ts index 7c8737cc..42503011 100644 --- a/src/components/outbound/VehicleDispatchManagement/actions.ts +++ b/src/components/outbound/VehicleDispatchManagement/actions.ts @@ -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 = { + '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, diff --git a/src/components/production/WorkOrders/WorkOrderCreate.tsx b/src/components/production/WorkOrders/WorkOrderCreate.tsx index 7475f0e6..512ac0ab 100644 --- a/src/components/production/WorkOrders/WorkOrderCreate.tsx +++ b/src/components/production/WorkOrders/WorkOrderCreate.tsx @@ -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 = { - 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([]); - const [validationErrors, setValidationErrors] = useState({}); + const [validationErrors, setValidationErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [processOptions, setProcessOptions] = useState([]); 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 = {}; 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(() => (
- {/* Validation 에러 표시 */} - {Object.keys(validationErrors).length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류) - -
    - {Object.entries(validationErrors).map(([field, message]) => { - const fieldName = FIELD_NAME_MAP[field] || field; - return ( -
  • - - - {fieldName}: {message} - -
  • - ); - })} -
-
-
-
-
- )} - {/* 등록 방식 */}

등록 방식

@@ -381,7 +350,7 @@ export function WorkOrderCreate() {

수주 정보

{!formData.selectedOrder ? ( -
+
@@ -448,6 +417,7 @@ export function WorkOrderCreate() {
)} + {validationErrors.selectedOrder &&

{validationErrors.selectedOrder}

}
)} @@ -459,21 +429,29 @@ export function WorkOrderCreate() { 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 &&

{validationErrors.client}

}
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 &&

{validationErrors.projectName}

}
@@ -506,10 +484,13 @@ export function WorkOrderCreate() { + {validationErrors.processId &&

{validationErrors.processId}

}

공정코드: {getSelectedProcessCode()}

@@ -529,8 +511,13 @@ export function WorkOrderCreate() { setFormData({ ...formData, shipmentDate: date })} + onChange={(date) => { + setFormData({ ...formData, shipmentDate: date }); + clearFieldError('shipmentDate'); + }} + className={validationErrors.shipmentDate ? 'border-red-500' : ''} /> + {validationErrors.shipmentDate &&

{validationErrors.shipmentDate}

}
@@ -717,7 +704,7 @@ export function WorkOrderCreate() { />
- ), [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() { /> ); -} \ No newline at end of file +} diff --git a/src/components/production/WorkOrders/WorkOrderEdit.tsx b/src/components/production/WorkOrders/WorkOrderEdit.tsx index c7d80cd8..1eaabe58 100644 --- a/src/components/production/WorkOrders/WorkOrderEdit.tsx +++ b/src/components/production/WorkOrders/WorkOrderEdit.tsx @@ -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 = { - processId: '공정', - scheduledDate: '출고예정일', -}; - interface FormData { // 기본 정보 (읽기 전용) client: string; @@ -101,7 +89,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false); const [deleteTargetItemId, setDeleteTargetItemId] = useState(null); const [assigneeNames, setAssigneeNames] = useState([]); - const [validationErrors, setValidationErrors] = useState({}); + const [validationErrors, setValidationErrors] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [processOptions, setProcessOptions] = useState([]); @@ -213,7 +201,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { // 폼 제출 const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => { // Validation 체크 - const errors: ValidationErrors = {}; + const errors: Record = {}; 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(() => (
- {/* Validation 에러 표시 */} - {Object.keys(validationErrors).length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류) - -
    - {Object.entries(validationErrors).map(([field, message]) => { - const fieldName = FIELD_NAME_MAP[field] || field; - return ( -
  • - - - {fieldName}: {message} - -
  • - ); - })} -
-
-
-
-
- )} - {/* 기본 정보 (기획서 4열 구성) */}

기본 정보

@@ -391,10 +351,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { + {validationErrors.processId &&

{validationErrors.processId}

}
@@ -442,8 +408,15 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { 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 &&

{validationErrors.scheduledDate}

}
@@ -671,4 +644,4 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) { /> ); -} \ No newline at end of file +} diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 422a3bca..335835af 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -348,15 +348,28 @@ export default function WorkerScreen() { // 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] } const [stepProgressMap, setStepProgressMap] = useState>({}); - // 데이터 로드 + // 데이터 로드 (작업목록 + 공정목록 + 부서목록 병렬) 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([]); - // 공정 목록 조회 (최초 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() {
{/* 공정별 탭 (공정관리 API 기반 동적 생성) */} + {isLoading ? ( + + ) : ( setActiveTab(v)} @@ -1708,11 +1705,12 @@ export default function WorkerScreen() { ))} + )}
{/* 하단 고정 버튼 */} {(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && ( -
+
{hasWipItems ? (