From a4f99ae3398aaceebf310e0932506f919694281c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Mar 2026 03:03:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=B6=9C=ED=95=98/=EB=B0=B0=EC=B0=A8/?= =?UTF-8?q?=ED=9A=8C=EA=B3=84]=20=EB=B0=B0=EC=B0=A8=20=EB=8B=A4=EC=A4=91?= =?UTF-8?q?=ED=96=89=20+=20=EC=96=B4=EC=9D=8C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20+=20=EC=B6=9C=EA=B3=A0=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배차차량관리 목업→API 연동, 배차정보 다중 행 - ShipmentManagement 출고관리 API 매핑 - BillManagement 리팩토링 (섹션 분리, hooks, constants) - 상품권 actions/types 확장 - 출하관리 캘린더 기본 뷰 week-time --- .../(protected)/dev/bill-prototype/page.tsx | 1322 +++++++++++++++++ src/app/api/pdf/generate/route.ts | 16 +- .../BankTransactionInquiry/index.tsx | 62 +- .../accounting/BillManagement/BillDetail.tsx | 517 ++----- .../BillManagement/BillManagementClient.tsx | 88 +- .../accounting/BillManagement/actions.ts | 33 +- .../accounting/BillManagement/billConfig.ts | 8 +- .../accounting/BillManagement/constants.ts | 178 +++ .../BillManagement/hooks/useBillConditions.ts | 69 + .../BillManagement/hooks/useBillForm.ts | 103 ++ .../sections/BasicInfoSection.tsx | 288 ++++ .../sections/BuybackSection.tsx | 35 + .../sections/CollectionSection.tsx | 69 + .../sections/DiscountInfoSection.tsx | 56 + .../sections/DishonoredSection.tsx | 88 ++ .../sections/ElectronicBillSection.tsx | 37 + .../sections/EndorsementSection.tsx | 44 + .../sections/ExchangeBillSection.tsx | 74 + .../sections/HistorySection.tsx | 150 ++ .../sections/RecourseSection.tsx | 48 + .../sections/RenewalSection.tsx | 46 + .../BillManagement/sections/index.ts | 11 + .../BillManagement/sections/types.ts | 7 + .../accounting/BillManagement/types.ts | 392 ++++- .../CardTransactionInquiry/index.tsx | 65 +- .../accounting/DailyReport/index.tsx | 291 +++- .../GiftCertificateManagement/actions.ts | 216 ++- .../GiftCertificateManagement/types.ts | 91 ++ .../accounting/ReceivablesStatus/index.tsx | 59 +- .../accounting/TaxInvoiceManagement/index.tsx | 77 +- .../accounting/VendorLedger/index.tsx | 65 +- .../VendorManagement/VendorDetail.tsx | 58 +- .../VendorManagementClient.tsx | 7 +- .../accounting/VendorManagement/actions.ts | 4 + .../accounting/VendorManagement/index.tsx | 1 + .../ShipmentManagement/ShipmentCreate.tsx | 65 +- .../ShipmentManagement/ShipmentDetail.tsx | 2 +- .../ShipmentManagement/ShipmentEdit.tsx | 2 +- .../ShipmentManagement/ShipmentList.tsx | 2 +- .../outbound/ShipmentManagement/actions.ts | 95 +- .../VehicleDispatchDetail.tsx | 42 +- .../VehicleDispatchEdit.tsx | 28 +- .../VehicleDispatchManagement/actions.ts | 207 +-- 43 files changed, 4135 insertions(+), 983 deletions(-) create mode 100644 src/app/[locale]/(protected)/dev/bill-prototype/page.tsx create mode 100644 src/components/accounting/BillManagement/constants.ts create mode 100644 src/components/accounting/BillManagement/hooks/useBillConditions.ts create mode 100644 src/components/accounting/BillManagement/hooks/useBillForm.ts create mode 100644 src/components/accounting/BillManagement/sections/BasicInfoSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/BuybackSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/CollectionSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/DiscountInfoSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/DishonoredSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/ElectronicBillSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/EndorsementSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/ExchangeBillSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/HistorySection.tsx create mode 100644 src/components/accounting/BillManagement/sections/RecourseSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/RenewalSection.tsx create mode 100644 src/components/accounting/BillManagement/sections/index.ts create mode 100644 src/components/accounting/BillManagement/sections/types.ts 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..10e637b1 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx @@ -0,0 +1,1322 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { Plus, Trash2, AlertTriangle, Info } 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 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 BILL_CATEGORY_OPTIONS = [ + { value: 'commercial', label: '상업어음 (매출채권)' }, + { value: 'other', 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: 'renewed', label: '개서 (만기연장)' }, + { value: 'recourse', label: '소구 (배서어음 상환)' }, + { value: 'buyback', label: '환매 (할인어음 부도)' }, + { value: 'dishonored', label: '부도' }, +]; + +// 받을수표 - 결제상태 (수표용: 일람출급이므로 만기/할인/개서/환매 없음) +const RECEIVED_CHECK_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'endorsed', label: '배서양도' }, + { value: 'collected', label: '추심' }, + { value: 'deposited', label: '추심입금' }, + { value: 'paymentComplete', label: '결제완료 (제시입금)' }, + { value: 'recourse', label: '소구 (수표법 제39조)' }, + { value: 'dishonored', label: '부도' }, +]; + +// 지급어음 - 지급상태 +const ISSUED_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'maturityAlert', label: '만기임박 (7일전)' }, + { value: 'maturityPayment', label: '만기결제' }, + { value: 'paid', label: '결제완료' }, + { value: 'renewed', label: '개서 (만기연장)' }, + { value: 'dishonored', label: '부도' }, +]; + +// 지급수표 - 지급상태 (수표용) +const ISSUED_CHECK_STATUS_OPTIONS = [ + { value: 'stored', label: '미결제' }, + { value: 'paid', label: '결제완료 (제시출금)' }, + { value: 'dishonored', label: '부도' }, +]; + +// 지급어음 - 결제방법 (엑셀) +const PAYMENT_METHOD_OPTIONS = [ + { value: 'autoTransfer', label: '만기자동이체' }, + { value: 'currentAccount', 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: '기타' }, +]; + +// 이력 처리구분 (받을어음용 - 어음 생애주기 순) +const HISTORY_TYPE_OPTIONS = [ + { value: 'received', label: '수취' }, + { value: 'endorsement', label: '배서양도' }, + { value: 'splitEndorsement', label: '분할배서' }, + { value: 'collection', label: '추심의뢰' }, + { value: 'collectionDeposit', label: '추심입금' }, + { value: 'discount', label: '할인' }, + { value: 'maturityDeposit', label: '만기입금' }, + { value: 'dishonored', label: '부도' }, + { value: 'recourse', label: '소구' }, + { value: 'buyback', label: '환매' }, + { value: 'renewal', label: '개서' }, + { value: 'other', label: '기타' }, +]; + +// 배서차수 (받을어음) - 지류: 실무상 4차까지, 전자: 법적 최대 20차 +const ENDORSEMENT_ORDER_PAPER = [ + { value: '1', label: '1차 (발행인 직접수취)' }, + { value: '2', label: '2차 (1개 업체 경유)' }, + { value: '3', label: '3차 (2개 업체 경유)' }, + { value: '4', label: '4차 (3개 업체 경유)' }, +]; +const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({ + value: String(i + 1), + label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`, +})); + +// 보관장소 +const STORAGE_OPTIONS = [ + { value: 'safe', label: '금고' }, + { value: 'bank', label: '은행 보관' }, + { value: 'other', label: '기타' }, +]; + +// 지급장소 (어음법 제75조 필수 기재사항) +const PAYMENT_PLACE_OPTIONS = [ + { value: 'issuerBank', label: '발행은행 본점' }, + { value: 'issuerBankBranch', label: '발행은행 지점' }, + { value: 'payerAddress', label: '지급인 주소지' }, + { value: 'designatedBank', label: '지정 은행' }, + { value: 'other', label: '기타' }, +]; + +// 수표 지급장소 (수표법 제3조: 지급인은 반드시 은행) +const PAYMENT_PLACE_CHECK_OPTIONS = [ + { value: 'issuerBank', label: '발행은행 본점' }, + { value: 'issuerBankBranch', label: '발행은행 지점' }, + { value: 'designatedBank', label: '지정 은행' }, +]; + +// 추심결과 +const COLLECTION_RESULT_OPTIONS = [ + { value: 'success', label: '추심 성공 (입금완료)' }, + { value: 'partial', label: '일부 입금' }, + { value: 'failed', label: '추심 실패 (부도)' }, + { value: 'pending', label: '추심 진행중' }, +]; + +// 소구사유 +const RECOURSE_REASON_OPTIONS = [ + { value: 'endorsedDishonor', label: '배서양도 어음 부도' }, + { value: 'discountDishonor', label: '할인 어음 부도 (환매)' }, + { value: 'other', label: '기타' }, +]; + +// 환어음 인수거절 사유 +const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [ + { value: 'financialDifficulty', label: '자금 사정 곤란' }, + { value: 'disputeOfClaim', label: '채권 분쟁' }, + { value: 'amountDispute', label: '금액 이의' }, + { value: 'other', label: '기타' }, +]; + +// 개서 사유 +const RENEWAL_REASON_OPTIONS = [ + { value: 'maturityExtension', label: '만기일 연장' }, + { value: 'amountChange', label: '금액 변경' }, + { value: 'conditionChange', 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; // received | issued + medium: string; // electronic | paper + amount: number; + issueDate: string; + maturityDate: string; + note: string; + // === 전자어음 (조건: medium=electronic) === + electronicBillNo: string; + registrationOrg: string; + // === 환어음 (조건: instrumentType=exchange) === + drawee: string; + acceptanceStatus: string; + acceptanceDate: string; + // === 받을어음 전용 === + vendor: string; // 거래처(발행인) + billCategory: string; // 상업어음/기타어음 + issuerBank: string; // 발행은행 + endorsement: string; // 배서 가능/불가 + endorsementOrder: string; // 배서차수 (1차~4차) + storagePlace: string; // 보관장소 + receivedStatus: string; // 결제상태 + isDiscounted: boolean; // 할인여부 + discountDate: string; + discountBank: string; + discountRate: number; + discountAmount: number; + // 배서양도 (조건: receivedStatus=endorsed) + endorsementDate: string; + endorsee: string; + endorsementReason: string; + // 추심 (조건: receivedStatus=collected) + collectionBank: string; + collectionRequestDate: string; + collectionFee: number; + collectionCompleteDate: string; // 추심완료일 + collectionResult: string; // 추심결과 + collectionDepositDate: string; // 추심입금일 + collectionDepositAmount: number; // 추심입금액(수수료 차감후) + // === 지급어음 전용 === + payee: string; // 수취인(거래처) + settlementBank: string; // 결제은행 + paymentMethod: string; // 결제방법 + issuedStatus: string; // 지급상태 + actualPaymentDate: string; // 실제결제일 + // === 공통 === + paymentPlace: string; // 지급장소 (어음법 필수) + paymentPlaceDetail: string; // 지급장소 상세 (기타 선택 시) + // === 개서 (공통 조건부: 상태=개서) === + renewalDate: string; // 개서일자 + renewalNewBillNo: string; // 신어음번호 + renewalReason: string; // 개서사유 + // === 소구/환매 (받을어음 조건부) === + recourseDate: string; // 소구일자 + recourseAmount: number; // 소구금액 + recourseTarget: string; // 소구대상 (배서인/발행인) + recourseReason: string; // 소구사유 + buybackDate: string; // 환매일자 + buybackAmount: number; // 환매금액 + buybackBank: string; // 환매요청 은행 + // === 환어음 인수거절 (조건: acceptanceStatus=refused) === + acceptanceRefusalDate: string; // 인수거절일 + acceptanceRefusalReason: string; // 인수거절사유 + // === 공통 조건부 === + isSplit: boolean; + splitCount: number; + splitAmount: number; + dishonoredDate: string; + dishonoredReason: string; + // 부도 법적 프로세스 + hasProtest: boolean; // 거절증서 작성 여부 + protestDate: string; // 거절증서 작성일 + recourseNoticeDate: string; // 소구 통지일 + recourseNoticeDeadline: string; // 소구 통지 기한 (자동: 부도일+4영업일) + // === 이력 관리 (받을어음만) === + installments: InstallmentRecord[]; + // === 입출금 계좌 === + bankAccountInfo: string; +} + +const INITIAL_FORM: BillFormData = { + billNumber: '', instrumentType: 'promissory', direction: 'received', + medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '', + electronicBillNo: '', registrationOrg: '', + drawee: '', acceptanceStatus: '', acceptanceDate: '', + vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1', + storagePlace: '', receivedStatus: 'stored', isDiscounted: false, + discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0, + endorsementDate: '', endorsee: '', endorsementReason: '', + collectionBank: '', collectionRequestDate: '', collectionFee: 0, + collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0, + payee: '', settlementBank: '', paymentMethod: 'autoTransfer', + issuedStatus: 'stored', actualPaymentDate: '', + paymentPlace: '', paymentPlaceDetail: '', + renewalDate: '', renewalNewBillNo: '', renewalReason: '', + recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '', + buybackDate: '', buybackAmount: 0, buybackBank: '', + acceptanceRefusalDate: '', acceptanceRefusalReason: '', + isSplit: false, splitCount: 0, splitAmount: 0, + dishonoredDate: '', dishonoredReason: '', + hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '', + installments: [], bankAccountInfo: '', +}; + +// ============================================= +// 뱃지 컴포넌트 +// ============================================= + +function NewBadge() { + return NEW; +} +function CondBadge({ label }: { label: string }) { + return {label}; +} +function RecvBadge() { + return 받을어음; +} +function IssuBadge() { + return 지급어음; +} + +// ============================================= +// 메인 컴포넌트 +// ============================================= + +export default function BillPrototypePage() { + const [formData, setFormData] = useState(INITIAL_FORM); + + const updateField = useCallback((field: K, value: BillFormData[K]) => { + setFormData(prev => ({ ...prev, [field]: value })); + }, []); + + const isReceived = formData.direction === 'received'; + const isIssued = formData.direction === 'issued'; + // 증권종류 분류 + const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck'; + const isBill = !isCheck; // 약속어음 또는 환어음 + const canBeElectronic = formData.instrumentType === 'promissory'; // 전자어음법: 약속어음만 + + const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus; + + // 조건부 플래그 + const showElectronic = formData.medium === 'electronic'; + const showExchangeBill = formData.instrumentType === 'exchange'; + const showDiscount = isReceived && formData.isDiscounted && isBill; // 수표 할인 불가 + const showEndorsement = isReceived && formData.receivedStatus === 'endorsed'; + const showCollection = isReceived && formData.receivedStatus === 'collected'; + const showDishonored = currentStatus === 'dishonored'; + const showRenewal = currentStatus === 'renewed' && isBill; // 수표 개서 불가 + const showRecourse = isReceived && formData.receivedStatus === 'recourse'; + const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill; // 수표 환매 불가 + const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused'; + + // 현재 증권종류에 맞는 상태 옵션 + const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS; + const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS; + // 현재 증권종류에 맞는 지급장소 옵션 + const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS; + + // 할인 실수령액 + const calcNetReceived = useMemo(() => { + if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount; + return 0; + }, [formData.amount, formData.discountAmount]); + + // 전자어음 여부 + const isElectronic = formData.medium === 'electronic'; + + // 이력에서 분할배서 건수 & 금액 합계 계산 + const splitEndorsementStats = useMemo(() => { + const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement'); + const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0); + return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount }; + }, [formData.installments, formData.amount]); + + // 분할배서 최대 횟수 (전자: 5회 미만=최대4, 지류: 실무상 10) + const maxSplitCount = isElectronic ? 4 : 10; + + // 이력 관리 핸들러 + 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 ( + + {/* 헤더 */} +
+

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

+

v7 + 분할/배서차수 관리 + 차수 관리 → "이력 관리" 단일 테이블로 통합

+
+ + {/* 안내 */} + + +
+
+ + 프로토타입입니다. 실제 저장되지 않습니다. +
+
+ 신규 필드 + 특정 조건 시 표시 + 받을어음 전용 + 지급어음 전용 +
+
+
+
+ + {/* ===== 1. 공통 기본 정보 ===== */} + + + 기본 정보 + + +
+ {/* 어음번호 */} +
+ + updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" /> +
+ + {/* 증권종류 */} +
+ + +
+ + {/* 거래방향 */} +
+ + +
+ + {/* 전자/지류 */} +
+ + +
+ + {/* 거래처 - 라벨 분기 */} +
+ + +
+ + {/* 금액 */} +
+ + updateField('amount', v ?? 0)} /> +
+ + {/* 발행일 */} +
+ + updateField('issueDate', d)} /> +
+ + {/* 만기일 (수표는 일람출급이므로 만기일 없음 - 수표법 제28조) */} + {isBill && ( +
+ + updateField('maturityDate', d)} /> +
+ )} + + {/* 은행 - 라벨 분기 */} +
+ + updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)} + placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'} + /> +
+ + {/* 지급장소 (어음법 제75조 / 수표법 제2조 필수 기재사항) */} +
+ + +
+ + {/* 지급장소 상세 (기타 선택 시) */} + {formData.paymentPlace === 'other' && ( +
+ + updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" /> +
+ )} + + {/* ===== 어음구분 (어음만, 수표 제외, 받을/지급 공통) ===== */} + {isBill && ( +
+ + +
+ )} + + {/* ===== 받을어음 전용 필드 ===== */} + {isReceived && ( + <> + + {/* 배서 여부 */} +
+ + +
+ + {/* 배서차수 (엑셀 신규) */} +
+ + +
+ + {/* 보관장소 */} +
+ + +
+ + {/* 결제상태 */} +
+ + +
+ + {/* 할인여부 (인라인 토글) - 수표는 일람출급이므로 할인 불가 */} + {isBill && ( +
+ +
+ { + updateField('isDiscounted', c); + if (c) updateField('receivedStatus', 'discounted'); + }} /> + {formData.isDiscounted ? '할인 적용' : '미적용'} +
+
+ )} + + )} + + {/* ===== 지급어음 전용 필드 ===== */} + {isIssued && ( + <> + {/* 결제방법 */} +
+ + +
+ + {/* 지급상태 */} +
+ + +
+ + {/* 실제결제일 */} +
+ + updateField('actualPaymentDate', d)} /> +
+ + )} + + {/* 입금/출금 계좌 (공통) */} +
+ + +
+ + {/* 비고 */} +
+ + updateField('note', e.target.value)} placeholder="비고를 입력해주세요" /> +
+
+
+
+ + {/* ===== 2. 전자어음 정보 (조건: 전자) ===== */} + {showElectronic && ( + + + 전자어음 정보 + + +
+
+ + updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 3. 환어음 정보 (조건: 환어음) ===== */} + {showExchangeBill && ( + + + 환어음 정보 + + +
+
+
+ + updateField('drawee', e.target.value)} placeholder="지급 의무자" /> +
+
+ + +
+
+ + updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)} /> +
+
+ {/* 인수거절 시 추가 필드 */} + {showAcceptanceRefusal && ( +
+
+ + 인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다. +
+
+
+ + +
+
+
+ )} +
+
+
+ )} + + {/* ===== 4. 할인 정보 (받을어음 + 할인여부 ON) ===== */} + {showDiscount && ( + + + 할인 정보 + + +
+
+ + updateField('discountDate', d)} /> +
+
+ + 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', v ?? 0)} /> +
+
+ +
+ {calcNetReceived > 0 + ? ₩ {calcNetReceived.toLocaleString()} + : 어음금액 - 할인금액} +
+
+
+
+
+ )} + + {/* ===== 5. 배서양도 정보 (받을어음 + 결제상태=배서양도) ===== */} + {showEndorsement && ( + + + 배서양도 정보 + + +
+
+ + updateField('endorsementDate', d)} /> +
+
+ + updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 6. 추심 정보 (받을어음 + 결제상태=추심) ===== */} + {showCollection && ( + + + 추심 정보 + + +
+ {/* 추심 의뢰 */} +

추심 의뢰

+
+
+ + updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" /> +
+
+ + updateField('collectionRequestDate', d)} /> +
+
+ + updateField('collectionFee', v ?? 0)} /> +
+
+ {/* 추심 결과 */} +
+

추심 결과

+
+
+ + +
+
+ + updateField('collectionCompleteDate', d)} /> +
+
+ + updateField('collectionDepositDate', d)} /> +
+
+ + updateField('collectionDepositAmount', v ?? 0)} /> +
+
+
+
+
+
+ )} + + {/* ===== 7. 이력 관리 (받을어음 전용) ===== */} + {isReceived && ( + + + 이력 관리 + + + +
+ {/* 분할배서 토글 + 요약 */} +
+
+ updateField('isSplit', c)} + /> + + {formData.isSplit && ( + + 최대 {maxSplitCount}회 + + )} +
+ {formData.isSplit && isElectronic && ( +
+ + 전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조) +
+ )} + {/* 분할배서 잔액 요약 */} + {formData.isSplit && splitEndorsementStats.count > 0 && ( +
+ 원금액: + ₩ {formData.amount.toLocaleString()} + | 분할배서 합계: + ₩ {splitEndorsementStats.totalAmount.toLocaleString()} + | 잔액: + + ₩ {splitEndorsementStats.remaining.toLocaleString()} + + {splitEndorsementStats.remaining < 0 && ( + 금액 초과 + )} +
+ )} +
+ + {/* 이력 테이블 */} +
+ + + + No + 일자 + 처리구분 + 금액 + 상대처 + 비고 + 삭제 + + + + {formData.installments.length === 0 ? ( + + 등록된 이력이 없습니다 + + ) : formData.installments.map((inst, idx) => ( + + {idx + 1} + handleUpdateInstallment(inst.id, 'date', d)} size="sm" /> + + + + handleUpdateInstallment(inst.id, 'amount', v ?? 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" /> + + + + + ))} + +
+
+
+
+
+ )} + + {/* ===== 8. 개서 정보 (공통, 상태=개서 시) ===== */} + {showRenewal && ( + + + + 개서 정보만기연장 + + + +
+
+ + updateField('renewalDate', d)} /> +
+
+ + updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 9. 소구 정보 (받을어음, 상태=소구 시) ===== */} + {showRecourse && ( + + + + 소구 (상환) 정보 + + + +

배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우

+
+
+ + updateField('recourseDate', d)} /> +
+
+ + updateField('recourseAmount', v ?? 0)} /> +
+
+ + updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" /> +
+
+ + +
+
+
+
+ )} + + {/* ===== 10. 환매 정보 (받을어음, 상태=환매 시) ===== */} + {showBuyback && ( + + + + 환매 정보 + + + +

할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우

+
+
+ + updateField('buybackDate', d)} /> +
+
+ + updateField('buybackAmount', v ?? 0)} /> +
+
+ + updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" /> +
+
+
+
+ )} + + {/* ===== 11. 부도 정보 (공통, 상태=부도 시) ===== */} + {showDishonored && ( + + + + 부도 정보부도 + + + +
+
+
+ + { + updateField('dishonoredDate', d); + if (d) { + const dt = new Date(d); + dt.setDate(dt.getDate() + 6); + updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]); + } + }} /> +
+
+ + +
+
+ {/* 법적 프로세스 */} +
+

법적 프로세스 (어음법 제44조·제45조)

+
+
+ +
+ updateField('hasProtest', c)} /> + {formData.hasProtest ? '작성 완료' : '미작성'} +
+
+ {formData.hasProtest && ( +
+ + updateField('protestDate', d)} /> +
+ )} +
+ + updateField('recourseNoticeDate', d)} /> +
+
+ +
+ {formData.recourseNoticeDeadline ? ( + + {formData.recourseNoticeDeadline} + {formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'} + + ) : 부도일자 입력 시 자동계산} +
+
+
+
+
+
+
+ )} + + {/* (기존 "차수 관리" → "이력 관리"로 통합 완료, 제거) */} + + {/* ===== 10. 조건부 필드 가이드 ===== */} + + + 조건부 필드 가이드 (실무자 확인용) + + +
+

거래방향, 상태 등을 변경하면 관련 섹션이 자동으로 나타납니다.

+ + + + 조건 + 섹션 + 필드 + 현재 + + + + + 거래방향 = 받을어음 + 받을어음 전용 필드 + 어음구분, 배서여부, 배서차수, 보관장소, 결제상태, 할인여부, 이력관리 + {isReceived ? 표시중 : 숨김} + + + 거래방향 = 지급어음 + 지급어음 전용 필드 + 결제방법, 지급상태, 실제결제일 + {isIssued ? 표시중 : 숨김} + + + 증권종류 = 수표 + 수표 제한 + 만기일 숨김, 전자 불가(지류 고정), 할인/개서/환매 불가, 전용 상태 옵션, 지급장소 은행만 + {isCheck ? 적용중 : 해당없음} + + + 전자/지류 = 전자 (약속어음만) + 전자어음 정보 + 전자어음관리번호, 등록기관 + {showElectronic ? 표시중 : 숨김} + + + 증권종류 = 환어음 + 환어음 정보 + 지급인, 인수여부, 인수일자 + {showExchangeBill ? 표시중 : 숨김} + + + 할인여부 ON (받을어음) + 할인 정보 + 할인일자, 할인처, 할인율, 할인금액, 실수령액(자동) + {showDiscount ? 표시중 : 숨김} + + + 결제상태 = 배서양도 + 배서양도 정보 + 배서일자, 피배서인, 배서사유 + {showEndorsement ? 표시중 : 숨김} + + + 결제상태 = 추심 + 추심 정보 + 추심의뢰(은행/의뢰일/수수료) + 추심결과(결과/완료일/입금일/입금액) + {showCollection ? 표시중 : 숨김} + + + 결제상태 = 소구 (받을어음) + 소구(상환) 정보 + 소구일자, 소구금액, 소구대상, 소구사유 + {showRecourse ? 표시중 : 숨김} + + + 결제상태 = 환매 (받을어음) + 환매 정보 + 환매일자, 환매금액, 환매요청 은행 + {showBuyback ? 표시중 : 숨김} + + + 상태 = 개서 (공통) + 개서 정보 + 개서일자, 신어음번호, 개서사유 + {showRenewal ? 표시중 : 숨김} + + + 상태 = 부도 (공통) + 부도 정보 + 법적 프로세스 + 부도일자, 부도사유, 거절증서 작성, 소구통지일, 통지기한(자동) + {showDishonored ? 표시중 : 숨김} + + + 환어음 인수 = 거절 + 인수거절 상세 + 인수거절일, 인수거절사유, 소구권 안내 + {showAcceptanceRefusal ? 표시중 : 숨김} + + + 분할배서 토글 ON (받을어음) + 분할배서 허용 + 이력에서 "분할배서" 처리구분 선택 가능 + 잔액 자동계산 — 전자: 최대4회, 지류: 실무10회 + {formData.isSplit && isReceived ? 표시중 : 숨김} + + + 지급장소 = 기타 + 지급장소 상세 + 지급장소 직접 입력 필드 + {formData.paymentPlace === 'other' ? 표시중 : 숨김} + + +
+
+
+
+ + {/* 하단 버튼 */} +
+ + +
+
+ ); +} 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/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index d58d9c7d..10eddf10 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -51,12 +51,27 @@ import { getBankAccountOptions, getFinancialInstitutions, batchSaveTransactions, - exportBankTransactionsExcel, type BankTransactionSummaryData, } from './actions'; import { TransactionFormModal } from './TransactionFormModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { formatNumber } from '@/lib/utils/amount'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; + +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '거래일시', key: 'transactionDate', width: 12 }, + { header: '구분', key: 'type', width: 8, + transform: (v) => v === 'deposit' ? '입금' : '출금' }, + { header: '은행명', key: 'bankName', width: 12 }, + { header: '계좌명', key: 'accountName', width: 15 }, + { header: '적요/내용', key: 'note', width: 20 }, + { header: '입금', key: 'depositAmount', width: 14 }, + { header: '출금', key: 'withdrawalAmount', width: 14 }, + { header: '잔액', key: 'balance', width: 14 }, + { header: '취급점', key: 'branch', width: 12 }, + { header: '상대계좌예금주명', key: 'depositorName', width: 18 }, +]; // ===== 테이블 컬럼 정의 (체크박스 제외 10개) ===== const tableColumns = [ @@ -226,22 +241,45 @@ export function BankTransactionInquiry() { } }, [localChanges, loadData]); - // 엑셀 다운로드 + // 엑셀 다운로드 (프론트 xlsx 생성) const handleExcelDownload = useCallback(async () => { try { - const result = await exportBankTransactionsExcel({ - startDate, - endDate, - accountCategory: accountCategoryFilter, - financialInstitution: financialInstitutionFilter, - }); - if (result.success && result.data) { - window.open(result.data.downloadUrl, '_blank'); + toast.info('엑셀 파일 생성 중...'); + const allData: BankTransaction[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getBankTransactionList({ + startDate, + endDate, + accountCategory: accountCategoryFilter, + financialInstitution: financialInstitutionFilter, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel({ + data: allData as (BankTransaction & Record)[], + columns: excelColumns, + filename: '계좌입출금내역', + sheetName: '입출금내역', + }); + toast.success('엑셀 다운로드 완료'); } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + toast.warning('다운로드할 데이터가 없습니다.'); } } catch { - toast.error('엑셀 다운로드 중 오류가 발생했습니다.'); + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]); diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index 1021d73e..a7b65937 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -1,99 +1,64 @@ 'use client'; -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Plus, Trash2 } from 'lucide-react'; -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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; import { toast } from 'sonner'; import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; import { billConfig } from './billConfig'; -import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types'; +import { apiDataToFormData, transformFormDataToApi } from './types'; +import type { BillApiData } from './types'; +import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions'; +import { useBillForm } from './hooks/useBillForm'; +import { useBillConditions } from './hooks/useBillConditions'; import { - BILL_TYPE_OPTIONS, - getBillStatusOptions, -} from './types'; -import { getBill, createBill, updateBill, deleteBill, getClients } from './actions'; - -// ===== 새 훅 import ===== + BasicInfoSection, + ElectronicBillSection, + ExchangeBillSection, + DiscountInfoSection, + EndorsementSection, + CollectionSection, + HistorySection, + RenewalSection, + RecourseSection, + BuybackSection, + DishonoredSection, +} from './sections'; import { useDetailData } from '@/hooks'; -// ===== Props ===== interface BillDetailProps { billId: string; mode: 'view' | 'edit' | 'new'; } -// ===== 거래처 타입 ===== interface ClientOption { id: string; name: string; } -// ===== 폼 데이터 타입 (개별 useState 대신 통합) ===== -interface BillFormData { - billNumber: string; - billType: BillType; - vendorId: string; - amount: number; - issueDate: string; - maturityDate: string; - status: BillStatus; - note: string; - installments: InstallmentRecord[]; -} - -const INITIAL_FORM_DATA: BillFormData = { - billNumber: '', - billType: 'received', - vendorId: '', - amount: 0, - issueDate: '', - maturityDate: '', - status: 'stored', - note: '', - installments: [], -}; - export function BillDetail({ billId, mode }: BillDetailProps) { const router = useRouter(); const isViewMode = mode === 'view'; const isNewMode = mode === 'new'; - // ===== 거래처 목록 ===== + // 거래처 목록 const [clients, setClients] = useState([]); - // ===== 폼 상태 (통합된 단일 state) ===== - const [formData, setFormData] = useState(INITIAL_FORM_DATA); + // V8 폼 훅 + const { + formData, + updateField, + handleInstrumentTypeChange, + handleDirectionChange, + addInstallment, + removeInstallment, + updateInstallment, + setFormDataFull, + } = useBillForm(); - // ===== 폼 필드 업데이트 헬퍼 ===== - const updateField = useCallback(( - field: K, - value: BillFormData[K] - ) => { - setFormData(prev => ({ ...prev, [field]: value })); - }, []); + // 조건부 표시 플래그 + const conditions = useBillConditions(formData); - // ===== 거래처 목록 로드 ===== + // 거래처 목록 로드 useEffect(() => { async function loadClients() { const result = await getClients(); @@ -104,41 +69,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) { loadClients(); }, []); - // ===== 새 훅: useDetailData로 데이터 로딩 ===== - // 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 + // API 데이터 로딩 (BillApiData 그대로) const fetchBillWrapper = useCallback( - (id: string | number) => getBill(String(id)), + (id: string | number) => getBillRaw(String(id)), [] ); const { - data: billData, + data: billApiData, isLoading, error: loadError, - } = useDetailData( + } = useDetailData( billId !== 'new' ? billId : null, fetchBillWrapper, { skip: isNewMode } ); - // ===== 데이터 로드 시 폼에 반영 ===== + // API 데이터 → V8 폼 데이터로 변환 useEffect(() => { - if (billData) { - setFormData({ - billNumber: billData.billNumber, - billType: billData.billType, - vendorId: billData.vendorId, - amount: billData.amount, - issueDate: billData.issueDate, - maturityDate: billData.maturityDate, - status: billData.status, - note: billData.note, - installments: billData.installments, - }); + if (billApiData) { + setFormDataFull(apiDataToFormData(billApiData)); } - }, [billData]); + }, [billApiData, setFormDataFull]); - // ===== 로드 에러 처리 ===== + // 로드 에러 useEffect(() => { if (loadError) { toast.error(loadError); @@ -146,43 +100,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) { } }, [loadError, router]); - // ===== 유효성 검사 함수 ===== + // 유효성 검사 const validateForm = useCallback((): { valid: boolean; error?: string } => { - if (!formData.billNumber.trim()) { - return { valid: false, error: '어음번호를 입력해주세요.' }; - } - if (!formData.vendorId) { - return { valid: false, error: '거래처를 선택해주세요.' }; - } - if (formData.amount <= 0) { - return { valid: false, error: '금액을 입력해주세요.' }; - } - if (!formData.issueDate) { - return { valid: false, error: '발행일을 입력해주세요.' }; - } - if (!formData.maturityDate) { - return { valid: false, error: '만기일을 입력해주세요.' }; - } - - // 차수 유효성 검사 - for (let i = 0; i < formData.installments.length; i++) { - const inst = formData.installments[i]; - if (!inst.date) { - return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` }; - } - if (inst.amount <= 0) { - return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` }; - } - } - + if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' }; + const vendorId = conditions.isReceived ? formData.vendor : formData.payee; + if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' }; + if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' }; + if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' }; + if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' }; return { valid: true }; - }, [formData]); + }, [formData, conditions.isReceived, conditions.isBill]); - // ===== 제출 상태 ===== + // 제출 const [isSubmitting, setIsSubmitting] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - // ===== 저장 핸들러 ===== const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => { const validation = validateForm(); if (!validation.valid) { @@ -192,28 +124,26 @@ export function BillDetail({ billId, mode }: BillDetailProps) { setIsSubmitting(true); try { - const billData: Partial = { - ...formData, - vendorName: clients.find(c => c.id === formData.vendorId)?.name || '', - }; + const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || ''; + const apiPayload = transformFormDataToApi(formData, vendorName); if (isNewMode) { - const result = await createBill(billData); + const result = await createBillRaw(apiPayload); if (result.success) { toast.success('등록되었습니다.'); router.push('/ko/accounting/bills'); - return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지 + return { success: false, error: '' }; } return result; } else { - return await updateBill(String(billId), billData); + const result = await updateBillRaw(String(billId), apiPayload); + return result; } } finally { setIsSubmitting(false); } - }, [formData, clients, isNewMode, billId, validateForm, router]); + }, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]); - // ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) ===== const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { setIsDeleting(true); try { @@ -223,284 +153,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) { } }, [billId]); - // ===== 차수 관리 핸들러 ===== - const handleAddInstallment = useCallback(() => { - const newInstallment: InstallmentRecord = { - id: `inst-${Date.now()}`, - date: '', - amount: 0, - note: '', - }; - setFormData(prev => ({ - ...prev, - installments: [...prev.installments, newInstallment], - })); - }, []); - - 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 - ), - })); - }, []); - - // ===== 상태 옵션 (구분에 따라 변경) ===== - const statusOptions = useMemo( - () => getBillStatusOptions(formData.billType), - [formData.billType] - ); - - // ===== 폼 콘텐츠 렌더링 ===== + // 폼 콘텐츠 렌더링 const renderFormContent = () => ( <> - {/* 기본 정보 섹션 */} - - - 기본 정보 - - -
- {/* 어음번호 */} -
- - updateField('billNumber', e.target.value)} - placeholder="어음번호를 입력해주세요" - disabled={isViewMode} - /> -
+ {/* 1. 기본 정보 */} + - {/* 구분 */} -
- - -
+ {/* 2. 전자어음 정보 */} + {conditions.showElectronic && ( + + )} - {/* 거래처 */} -
- - -
+ {/* 3. 환어음 정보 */} + {conditions.showExchangeBill && ( + + )} - {/* 금액 */} -
- - updateField('amount', value ?? 0)} - placeholder="금액을 입력해주세요" - disabled={isViewMode} - /> -
+ {/* 4. 할인 정보 */} + {conditions.showDiscount && ( + + )} - {/* 발행일 */} -
- - updateField('issueDate', date)} - disabled={isViewMode} - /> -
+ {/* 5. 배서양도 정보 */} + {conditions.showEndorsement && ( + + )} - {/* 만기일 */} -
- - updateField('maturityDate', date)} - disabled={isViewMode} - /> -
+ {/* 6. 추심 정보 */} + {conditions.showCollection && ( + + )} - {/* 상태 */} -
- - -
+ {/* 7. 이력 관리 (받을어음만) */} + {conditions.isReceived && ( + + )} - {/* 비고 */} -
- - updateField('note', e.target.value)} - placeholder="비고를 입력해주세요" - disabled={isViewMode} - /> -
-
-
-
+ {/* 8. 개서 정보 */} + {conditions.showRenewal && ( + + )} - {/* 차수 관리 섹션 */} - - - - * 차수 관리 - - {!isViewMode && ( - - )} - - -
- - - - No - 일자 - 금액 - 비고 - {!isViewMode && 삭제} - - - - {formData.installments.length === 0 ? ( - - - 등록된 차수가 없습니다 - - - ) : ( - formData.installments.map((inst, index) => ( - - {index + 1} - - handleUpdateInstallment(inst.id, 'date', date)} - disabled={isViewMode} - /> - - - handleUpdateInstallment(inst.id, 'amount', value ?? 0)} - disabled={isViewMode} - className="w-full" - /> - - - handleUpdateInstallment(inst.id, 'note', e.target.value)} - disabled={isViewMode} - className="w-full" - /> - - {!isViewMode && ( - - - - )} - - )) - )} - -
-
-
-
+ {/* 9. 소구 정보 */} + {conditions.showRecourse && ( + + )} + + {/* 10. 환매 정보 */} + {conditions.showBuyback && ( + + )} + + {/* 11. 부도 정보 */} + {conditions.showDishonored && ( + + )} ); - // ===== 템플릿 모드 및 동적 설정 ===== + // 템플릿 설정 const templateMode = isNewMode ? 'create' : mode; const dynamicConfig = { ...billConfig, - title: isViewMode ? '어음 상세' : '어음', + title: isViewMode ? '어음/수표 상세' : '어음/수표', actions: { ...billConfig.actions, submitLabel: isNewMode ? '등록' : '저장', diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index 72555b03..b2c94167 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -10,7 +10,7 @@ * - tableHeaderActions: 거래처, 구분, 상태 필터 */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { formatNumber } from '@/lib/utils/amount'; import { useDateRange } from '@/hooks'; @@ -32,8 +32,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Label } from '@/components/ui/label'; import { UniversalListPage, type UniversalListConfig, @@ -148,6 +146,16 @@ export function BillManagementClient({ } }, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]); + // ===== 필터 변경 시 자동 재조회 ===== + const isInitialMount = useRef(true); + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + loadData(1); + }, [loadData]); + // ===== 체크박스 핸들러 ===== const toggleSelection = useCallback((id: string) => { setSelectedItems(prev => { @@ -348,32 +356,8 @@ export function BillManagementClient({ ); }, - // 모바일 필터 설정 - filterConfig: [ - { - key: 'vendorFilter', - label: '거래처', - type: 'single', - options: vendorOptions.filter(o => o.value !== 'all'), - }, - { - key: 'billType', - label: '구분', - type: 'single', - options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), - }, - { - key: 'status', - label: '상태', - type: 'single', - options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'), - }, - ], - initialFilters: { - vendorFilter: vendorFilter, - billType: billTypeFilter, - status: statusFilter, - }, + // 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠) + filterConfig: [], filterTitle: '어음 필터', // 날짜 선택기 @@ -392,44 +376,12 @@ export function BillManagementClient({ icon: Plus, }, - // 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장 - // 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지) - // 데스크톱: 모두 표시 + // 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리) headerActions: () => ( -
-
- { setBillTypeFilter(value); loadData(1); }} - className="flex items-center gap-3" - > -
- - -
-
- - -
-
- -
- -
+ ), // 테이블 헤더 액션 (필터) @@ -448,7 +400,7 @@ export function BillManagementClient({ - @@ -461,7 +413,7 @@ export function BillManagementClient({ - diff --git a/src/components/accounting/BillManagement/actions.ts b/src/components/accounting/BillManagement/actions.ts index 35b9d1de..7aac9fd4 100644 --- a/src/components/accounting/BillManagement/actions.ts +++ b/src/components/accounting/BillManagement/actions.ts @@ -19,7 +19,8 @@ interface BillSummaryApiData { // ===== 어음 목록 조회 ===== export async function getBills(params: { search?: string; billType?: string; status?: string; clientId?: string; - isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string; + isElectronic?: boolean; instrumentType?: string; medium?: string; + issueStartDate?: string; issueEndDate?: string; maturityStartDate?: string; maturityEndDate?: string; sortBy?: string; sortDir?: string; perPage?: number; page?: number; }) { @@ -30,6 +31,8 @@ export async function getBills(params: { status: params.status && params.status !== 'all' ? params.status : undefined, client_id: params.clientId, is_electronic: params.isElectronic, + instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined, + medium: params.medium && params.medium !== 'all' ? params.medium : undefined, issue_start_date: params.issueStartDate, issue_end_date: params.issueEndDate, maturity_start_date: params.maturityStartDate, @@ -124,6 +127,34 @@ export async function getBillSummary(params: { }); } +// ===== V8: 어음 상세 조회 (BillApiData 그대로 반환) ===== +export async function getBillRaw(id: string): Promise> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/bills/${id}`), + errorMessage: '어음 조회에 실패했습니다.', + }); +} + +// ===== V8: 어음 등록 (raw payload) ===== +export async function createBillRaw(data: Record): Promise> { + return executeServerAction({ + url: buildApiUrl('/api/v1/bills'), + method: 'POST', + body: data, + errorMessage: '어음 등록에 실패했습니다.', + }); +} + +// ===== V8: 어음 수정 (raw payload) ===== +export async function updateBillRaw(id: string, data: Record): Promise> { + return executeServerAction({ + url: buildApiUrl(`/api/v1/bills/${id}`), + method: 'PUT', + body: data, + errorMessage: '어음 수정에 실패했습니다.', + }); +} + // ===== 거래처 목록 조회 ===== export async function getClients(): Promise> { return executeServerAction({ diff --git a/src/components/accounting/BillManagement/billConfig.ts b/src/components/accounting/BillManagement/billConfig.ts index c7ff8673..30ecdc6a 100644 --- a/src/components/accounting/BillManagement/billConfig.ts +++ b/src/components/accounting/BillManagement/billConfig.ts @@ -9,8 +9,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla * (차수 관리 테이블 등 특수 기능 유지) */ export const billConfig: DetailConfig = { - title: '어음 상세', - description: '어음 및 수취어음 상세 현황을 관리합니다', + title: '어음/수표 상세', + description: '어음/수표 상세 현황을 관리합니다', icon: FileText, basePath: '/accounting/bills', fields: [], // renderView/renderForm 사용으로 필드 정의 불필요 @@ -25,8 +25,8 @@ export const billConfig: DetailConfig = { submitLabel: '저장', cancelLabel: '취소', deleteConfirmMessage: { - title: '어음 삭제', - description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + title: '어음/수표 삭제', + description: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', }, }, }; diff --git a/src/components/accounting/BillManagement/constants.ts b/src/components/accounting/BillManagement/constants.ts new file mode 100644 index 00000000..5b897d52 --- /dev/null +++ b/src/components/accounting/BillManagement/constants.ts @@ -0,0 +1,178 @@ +// ===== 증권종류 ===== +export const INSTRUMENT_TYPE_OPTIONS = [ + { value: 'promissory', label: '약속어음' }, + { value: 'exchange', label: '환어음' }, + { value: 'cashierCheck', label: '자기앞수표 (가게수표)' }, + { value: 'currentCheck', label: '당좌수표' }, +] as const; + +// ===== 거래방향 ===== +export const DIRECTION_OPTIONS = [ + { value: 'received', label: '수취 (받을어음)' }, + { value: 'issued', label: '발행 (지급어음)' }, +] as const; + +// ===== 전자/지류 ===== +export const MEDIUM_OPTIONS = [ + { value: 'electronic', label: '전자' }, + { value: 'paper', label: '지류 (종이)' }, +] as const; + +// ===== 배서 여부 ===== +export const ENDORSEMENT_OPTIONS = [ + { value: 'endorsable', label: '배서 가능' }, + { value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' }, +] as const; + +// ===== 어음구분 ===== +export const BILL_CATEGORY_OPTIONS = [ + { value: 'commercial', label: '상업어음 (매출채권)' }, + { value: 'other', label: '기타어음 (대여금/미수금)' }, +] as const; + +// ===== 받을어음 - 결제상태 (어음용) ===== +export 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: 'renewed', label: '개서 (만기연장)' }, + { value: 'recourse', label: '소구 (배서어음 상환)' }, + { value: 'buyback', label: '환매 (할인어음 부도)' }, + { value: 'dishonored', label: '부도' }, +] as const; + +// ===== 받을수표 - 결제상태 (수표용) ===== +export const RECEIVED_CHECK_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'endorsed', label: '배서양도' }, + { value: 'collected', label: '추심' }, + { value: 'deposited', label: '추심입금' }, + { value: 'paymentComplete', label: '결제완료 (제시입금)' }, + { value: 'recourse', label: '소구 (수표법 제39조)' }, + { value: 'dishonored', label: '부도' }, +] as const; + +// ===== 지급어음 - 지급상태 ===== +export const ISSUED_STATUS_OPTIONS = [ + { value: 'stored', label: '보관중' }, + { value: 'maturityAlert', label: '만기임박 (7일전)' }, + { value: 'maturityPayment', label: '만기결제' }, + { value: 'paid', label: '결제완료' }, + { value: 'renewed', label: '개서 (만기연장)' }, + { value: 'dishonored', label: '부도' }, +] as const; + +// ===== 지급수표 - 지급상태 ===== +export const ISSUED_CHECK_STATUS_OPTIONS = [ + { value: 'stored', label: '미결제' }, + { value: 'paid', label: '결제완료 (제시출금)' }, + { value: 'dishonored', label: '부도' }, +] as const; + +// ===== 결제방법 ===== +export const PAYMENT_METHOD_OPTIONS = [ + { value: 'autoTransfer', label: '만기자동이체' }, + { value: 'currentAccount', label: '당좌결제' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 부도사유 ===== +export 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: '기타' }, +] as const; + +// ===== 이력 처리구분 ===== +export const HISTORY_TYPE_OPTIONS = [ + { value: 'received', label: '수취' }, + { value: 'endorsement', label: '배서양도' }, + { value: 'splitEndorsement', label: '분할배서' }, + { value: 'collection', label: '추심의뢰' }, + { value: 'collectionDeposit', label: '추심입금' }, + { value: 'discount', label: '할인' }, + { value: 'maturityDeposit', label: '만기입금' }, + { value: 'dishonored', label: '부도' }, + { value: 'recourse', label: '소구' }, + { value: 'buyback', label: '환매' }, + { value: 'renewal', label: '개서' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 배서차수 (지류: 4차, 전자: 20차) ===== +export const ENDORSEMENT_ORDER_PAPER = [ + { value: '1', label: '1차 (발행인 직접수취)' }, + { value: '2', label: '2차 (1개 업체 경유)' }, + { value: '3', label: '3차 (2개 업체 경유)' }, + { value: '4', label: '4차 (3개 업체 경유)' }, +] as const; + +export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({ + value: String(i + 1), + label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`, +})); + +// ===== 보관장소 ===== +export const STORAGE_OPTIONS = [ + { value: 'safe', label: '금고' }, + { value: 'bank', label: '은행 보관' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 지급장소 (어음법 제75조) ===== +export const PAYMENT_PLACE_OPTIONS = [ + { value: 'issuerBank', label: '발행은행 본점' }, + { value: 'issuerBankBranch', label: '발행은행 지점' }, + { value: 'payerAddress', label: '지급인 주소지' }, + { value: 'designatedBank', label: '지정 은행' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 수표 지급장소 (수표법 제3조: 은행만) ===== +export const PAYMENT_PLACE_CHECK_OPTIONS = [ + { value: 'issuerBank', label: '발행은행 본점' }, + { value: 'issuerBankBranch', label: '발행은행 지점' }, + { value: 'designatedBank', label: '지정 은행' }, +] as const; + +// ===== 추심결과 ===== +export const COLLECTION_RESULT_OPTIONS = [ + { value: 'success', label: '추심 성공 (입금완료)' }, + { value: 'partial', label: '일부 입금' }, + { value: 'failed', label: '추심 실패 (부도)' }, + { value: 'pending', label: '추심 진행중' }, +] as const; + +// ===== 소구사유 ===== +export const RECOURSE_REASON_OPTIONS = [ + { value: 'endorsedDishonor', label: '배서양도 어음 부도' }, + { value: 'discountDishonor', label: '할인 어음 부도 (환매)' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 인수거절 사유 ===== +export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [ + { value: 'financialDifficulty', label: '자금 사정 곤란' }, + { value: 'disputeOfClaim', label: '채권 분쟁' }, + { value: 'amountDispute', label: '금액 이의' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 개서 사유 ===== +export const RENEWAL_REASON_OPTIONS = [ + { value: 'maturityExtension', label: '만기일 연장' }, + { value: 'amountChange', label: '금액 변경' }, + { value: 'conditionChange', label: '조건 변경' }, + { value: 'other', label: '기타' }, +] as const; + +// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) ===== +export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored']; +export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored']; diff --git a/src/components/accounting/BillManagement/hooks/useBillConditions.ts b/src/components/accounting/BillManagement/hooks/useBillConditions.ts new file mode 100644 index 00000000..79f8f286 --- /dev/null +++ b/src/components/accounting/BillManagement/hooks/useBillConditions.ts @@ -0,0 +1,69 @@ +'use client'; + +import { useMemo } from 'react'; +import type { BillFormData } from '../types'; +import { + RECEIVED_STATUS_OPTIONS, + RECEIVED_CHECK_STATUS_OPTIONS, + ISSUED_STATUS_OPTIONS, + ISSUED_CHECK_STATUS_OPTIONS, + PAYMENT_PLACE_OPTIONS, + PAYMENT_PLACE_CHECK_OPTIONS, +} from '../constants'; + +export function useBillConditions(formData: BillFormData) { + return useMemo(() => { + const isReceived = formData.direction === 'received'; + const isIssued = formData.direction === 'issued'; + const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck'; + const isBill = !isCheck; + const canBeElectronic = formData.instrumentType === 'promissory'; + const isElectronic = formData.medium === 'electronic'; + + const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus; + + // 조건부 섹션 표시 플래그 + const showElectronic = isElectronic; + const showExchangeBill = formData.instrumentType === 'exchange'; + const showDiscount = isReceived && formData.isDiscounted && isBill; + const showEndorsement = isReceived && formData.receivedStatus === 'endorsed'; + const showCollection = isReceived && formData.receivedStatus === 'collected'; + const showDishonored = currentStatus === 'dishonored'; + const showRenewal = currentStatus === 'renewed' && isBill; + const showRecourse = isReceived && formData.receivedStatus === 'recourse'; + const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill; + const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused'; + + // 현재 증권종류에 맞는 옵션 목록 + const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS; + const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS; + const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS; + + // 분할배서 최대 횟수 + const maxSplitCount = isElectronic ? 4 : 10; + + return { + isReceived, + isIssued, + isCheck, + isBill, + canBeElectronic, + isElectronic, + currentStatus, + showElectronic, + showExchangeBill, + showDiscount, + showEndorsement, + showCollection, + showDishonored, + showRenewal, + showRecourse, + showBuyback, + showAcceptanceRefusal, + receivedStatusOptions, + issuedStatusOptions, + paymentPlaceOptions, + maxSplitCount, + }; + }, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]); +} diff --git a/src/components/accounting/BillManagement/hooks/useBillForm.ts b/src/components/accounting/BillManagement/hooks/useBillForm.ts new file mode 100644 index 00000000..21a3071f --- /dev/null +++ b/src/components/accounting/BillManagement/hooks/useBillForm.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { BillFormData } from '../types'; +import { INITIAL_BILL_FORM_DATA } from '../types'; +import { + VALID_CHECK_RECEIVED_STATUSES, + VALID_CHECK_ISSUED_STATUSES, +} from '../constants'; + +export function useBillForm(initialData?: Partial) { + const [formData, setFormData] = useState({ + ...INITIAL_BILL_FORM_DATA, + ...initialData, + }); + + const updateField = useCallback((field: K, value: BillFormData[K]) => { + setFormData(prev => ({ ...prev, [field]: value })); + }, []); + + // 증권종류 변경 시 연관 필드 초기화 + const handleInstrumentTypeChange = useCallback((newType: string) => { + setFormData(prev => { + const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] }; + const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck'; + + // 약속어음 외에는 전자 불가 → 지류로 리셋 + if (newType !== 'promissory' && prev.medium === 'electronic') { + next.medium = 'paper'; + } + + // 수표 전환 시: 만기일, 할인, 관련 필드 리셋 + if (isCheckType) { + next.maturityDate = ''; + next.isDiscounted = false; + if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) { + next.receivedStatus = 'stored'; + } + if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) { + next.issuedStatus = 'stored'; + } + if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') { + next.paymentPlace = ''; + } + } + + return next; + }); + }, []); + + // 거래방향 변경 시 상태 초기화 + const handleDirectionChange = useCallback((newDirection: string) => { + setFormData(prev => ({ + ...prev, + direction: newDirection as BillFormData['direction'], + receivedStatus: 'stored', + issuedStatus: 'stored', + })); + }, []); + + // 이력 관리 + const addInstallment = useCallback(() => { + setFormData(prev => ({ + ...prev, + installments: [ + ...prev.installments, + { id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' }, + ], + })); + }, []); + + const removeInstallment = useCallback((id: string) => { + setFormData(prev => ({ + ...prev, + installments: prev.installments.filter(inst => inst.id !== id), + })); + }, []); + + const updateInstallment = useCallback((id: string, field: string, value: string | number) => { + setFormData(prev => ({ + ...prev, + installments: prev.installments.map(inst => + inst.id === id ? { ...inst, [field]: value } : inst + ), + })); + }, []); + + // 폼 전체 덮어쓰기 (API 데이터 로드 시) + const setFormDataFull = useCallback((data: BillFormData) => { + setFormData(data); + }, []); + + return { + formData, + updateField, + handleInstrumentTypeChange, + handleDirectionChange, + addInstallment, + removeInstallment, + updateInstallment, + setFormDataFull, + }; +} diff --git a/src/components/accounting/BillManagement/sections/BasicInfoSection.tsx b/src/components/accounting/BillManagement/sections/BasicInfoSection.tsx new file mode 100644 index 00000000..49e47912 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/BasicInfoSection.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useMemo } from 'react'; +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 { Switch } from '@/components/ui/switch'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; +import { + INSTRUMENT_TYPE_OPTIONS, + DIRECTION_OPTIONS, + MEDIUM_OPTIONS, + ENDORSEMENT_OPTIONS, + BILL_CATEGORY_OPTIONS, + STORAGE_OPTIONS, + PAYMENT_METHOD_OPTIONS, + ENDORSEMENT_ORDER_PAPER, + ENDORSEMENT_ORDER_ELECTRONIC, +} from '../constants'; + +interface BasicInfoSectionProps extends SectionProps { + clients: { id: string; name: string }[]; + conditions: { + isReceived: boolean; + isIssued: boolean; + isCheck: boolean; + isBill: boolean; + canBeElectronic: boolean; + isElectronic: boolean; + receivedStatusOptions: readonly { value: string; label: string }[]; + issuedStatusOptions: readonly { value: string; label: string }[]; + paymentPlaceOptions: readonly { value: string; label: string }[]; + }; + onInstrumentTypeChange: (v: string) => void; + onDirectionChange: (v: string) => void; +} + +export function BasicInfoSection({ + formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange, +}: BasicInfoSectionProps) { + const { + isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic, + receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions, + } = conditions; + + const endorsementOrderOptions = useMemo( + () => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER], + [isElectronic] + ); + + return ( + + + 기본 정보 + + +
+ {/* 어음번호 */} +
+ + updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} /> +
+ + {/* 증권종류 */} +
+ + +
+ + {/* 거래방향 */} +
+ + +
+ + {/* 전자/지류 */} +
+ + +
+ + {/* 거래처 */} +
+ + +
+ + {/* 금액 */} +
+ + updateField('amount', v ?? 0)} disabled={isViewMode} /> +
+ + {/* 발행일 */} +
+ + updateField('issueDate', d)} disabled={isViewMode} /> +
+ + {/* 만기일 (수표는 일람출급이므로 없음) */} + {isBill && ( +
+ + updateField('maturityDate', d)} disabled={isViewMode} /> +
+ )} + + {/* 은행 */} +
+ + updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)} + placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'} + disabled={isViewMode} + /> +
+ + {/* 지급장소 */} +
+ + +
+ + {/* 지급장소 상세 */} + {formData.paymentPlace === 'other' && ( +
+ + updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} /> +
+ )} + + {/* 어음구분 (어음만) */} + {isBill && ( +
+ + +
+ )} + + {/* ===== 받을어음 전용 필드 ===== */} + {isReceived && ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* 할인여부 (수표 제외) */} + {isBill && ( +
+ +
+ { + updateField('isDiscounted', c); + if (c) updateField('receivedStatus', 'discounted'); + }} disabled={isViewMode} /> + {formData.isDiscounted ? '할인 적용' : '미적용'} +
+
+ )} + + )} + + {/* ===== 지급어음 전용 필드 ===== */} + {isIssued && ( + <> +
+ + +
+ +
+ + +
+ +
+ + updateField('actualPaymentDate', d)} disabled={isViewMode} /> +
+ + )} + + {/* 입출금 계좌 */} +
+ + updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} /> +
+ + {/* 비고 */} +
+ + updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} /> +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/BuybackSection.tsx b/src/components/accounting/BillManagement/sections/BuybackSection.tsx new file mode 100644 index 00000000..bb289338 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/BuybackSection.tsx @@ -0,0 +1,35 @@ +'use client'; + +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 type { SectionProps } from './types'; + +export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + 환매 정보 + + +

할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우

+
+
+ + updateField('buybackDate', d)} disabled={isViewMode} /> +
+
+ + updateField('buybackAmount', v ?? 0)} disabled={isViewMode} /> +
+
+ + updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} /> +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/CollectionSection.tsx b/src/components/accounting/BillManagement/sections/CollectionSection.tsx new file mode 100644 index 00000000..921be44c --- /dev/null +++ b/src/components/accounting/BillManagement/sections/CollectionSection.tsx @@ -0,0 +1,69 @@ +'use client'; + +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 { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; +import { COLLECTION_RESULT_OPTIONS } from '../constants'; + +export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + 추심 정보 + + +
+ {/* 추심 의뢰 */} +

추심 의뢰

+
+
+ + updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} /> +
+
+ + updateField('collectionRequestDate', d)} disabled={isViewMode} /> +
+
+ + updateField('collectionFee', v ?? 0)} disabled={isViewMode} /> +
+
+ {/* 추심 결과 */} +
+

추심 결과

+
+
+ + +
+
+ + updateField('collectionCompleteDate', d)} disabled={isViewMode} /> +
+
+ + updateField('collectionDepositDate', d)} disabled={isViewMode} /> +
+
+ + updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} /> +
+
+
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/DiscountInfoSection.tsx b/src/components/accounting/BillManagement/sections/DiscountInfoSection.tsx new file mode 100644 index 00000000..94d3edf2 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/DiscountInfoSection.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useMemo } from 'react'; +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 type { SectionProps } from './types'; + +export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) { + const calcNetReceived = useMemo(() => { + if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount; + return 0; + }, [formData.amount, formData.discountAmount]); + + return ( + + + 할인 정보 + + +
+
+ + updateField('discountDate', d)} disabled={isViewMode} /> +
+
+ + updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} /> +
+
+ + { + 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" disabled={isViewMode} /> +
+
+ + updateField('discountAmount', v ?? 0)} disabled={isViewMode} /> +
+
+ +
+ {calcNetReceived > 0 + ? ₩ {calcNetReceived.toLocaleString()} + : 어음금액 - 할인금액} +
+
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/DishonoredSection.tsx b/src/components/accounting/BillManagement/sections/DishonoredSection.tsx new file mode 100644 index 00000000..4b49f67e --- /dev/null +++ b/src/components/accounting/BillManagement/sections/DishonoredSection.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +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 type { SectionProps } from './types'; +import { DISHONOR_REASON_OPTIONS } from '../constants'; + +export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + + 부도 정보 + 부도 + + + +
+
+
+ + { + updateField('dishonoredDate', d); + if (d) { + const dt = new Date(d); + dt.setDate(dt.getDate() + 6); + updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]); + } + }} disabled={isViewMode} /> +
+
+ + +
+
+ {/* 법적 프로세스 */} +
+

법적 프로세스 (어음법 제44조·제45조)

+
+
+ +
+ updateField('hasProtest', c)} disabled={isViewMode} /> + {formData.hasProtest ? '작성 완료' : '미작성'} +
+
+ {formData.hasProtest && ( +
+ + updateField('protestDate', d)} disabled={isViewMode} /> +
+ )} +
+ + updateField('recourseNoticeDate', d)} disabled={isViewMode} /> +
+
+ +
+ {formData.recourseNoticeDeadline ? ( + + {formData.recourseNoticeDeadline} + {formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'} + + ) : 부도일자 입력 시 자동계산} +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/ElectronicBillSection.tsx b/src/components/accounting/BillManagement/sections/ElectronicBillSection.tsx new file mode 100644 index 00000000..cc47203a --- /dev/null +++ b/src/components/accounting/BillManagement/sections/ElectronicBillSection.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; + +export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + 전자어음 정보 + + +
+
+ + updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/EndorsementSection.tsx b/src/components/accounting/BillManagement/sections/EndorsementSection.tsx new file mode 100644 index 00000000..6c46000e --- /dev/null +++ b/src/components/accounting/BillManagement/sections/EndorsementSection.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; + +export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + 배서양도 정보 + + +
+
+ + updateField('endorsementDate', d)} disabled={isViewMode} /> +
+
+ + updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/ExchangeBillSection.tsx b/src/components/accounting/BillManagement/sections/ExchangeBillSection.tsx new file mode 100644 index 00000000..7c26ca0e --- /dev/null +++ b/src/components/accounting/BillManagement/sections/ExchangeBillSection.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { AlertTriangle } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; +import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants'; + +interface ExchangeBillSectionProps extends SectionProps { + showAcceptanceRefusal: boolean; +} + +export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) { + return ( + + + 환어음 정보 + + +
+
+
+ + updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} /> +
+
+ + +
+
+ + updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)} + disabled={isViewMode} + /> +
+
+ {showAcceptanceRefusal && ( +
+
+ + 인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다. +
+
+
+ + +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/HistorySection.tsx b/src/components/accounting/BillManagement/sections/HistorySection.tsx new file mode 100644 index 00000000..fa718243 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/HistorySection.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useMemo } from 'react'; +import { Plus, Trash2, AlertTriangle } from 'lucide-react'; +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 { Button } from '@/components/ui/button'; +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'; +import type { BillFormData } from '../types'; +import { HISTORY_TYPE_OPTIONS } from '../constants'; + +interface HistorySectionProps { + formData: BillFormData; + updateField: (field: K, value: BillFormData[K]) => void; + isViewMode: boolean; + isElectronic: boolean; + maxSplitCount: number; + onAddInstallment: () => void; + onRemoveInstallment: (id: string) => void; + onUpdateInstallment: (id: string, field: string, value: string | number) => void; +} + +export function HistorySection({ + formData, updateField, isViewMode, isElectronic, maxSplitCount, + onAddInstallment, onRemoveInstallment, onUpdateInstallment, +}: HistorySectionProps) { + const splitEndorsementStats = useMemo(() => { + const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement'); + const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0); + return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount }; + }, [formData.installments, formData.amount]); + + return ( + + + 이력 관리 + {!isViewMode && ( + + )} + + +
+ {/* 분할배서 토글 */} +
+
+ updateField('isSplit', c)} disabled={isViewMode} /> + + {formData.isSplit && ( + + 최대 {maxSplitCount}회 + + )} +
+ {formData.isSplit && isElectronic && ( +
+ + 전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조) +
+ )} + {formData.isSplit && splitEndorsementStats.count > 0 && ( +
+ 원금액: + ₩ {formData.amount.toLocaleString()} + | 분할배서 합계: + ₩ {splitEndorsementStats.totalAmount.toLocaleString()} + | 잔액: + + ₩ {splitEndorsementStats.remaining.toLocaleString()} + + {splitEndorsementStats.remaining < 0 && ( + 금액 초과 + )} +
+ )} +
+ + {/* 이력 테이블 */} +
+ + + + No + 일자 + 처리구분 + 금액 + 상대처 + 비고 + {!isViewMode && 삭제} + + + + {formData.installments.length === 0 ? ( + + 등록된 이력이 없습니다 + + ) : formData.installments.map((inst, idx) => ( + + {idx + 1} + + onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} /> + + + + + + onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} /> + + + onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} /> + + + onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} /> + + {!isViewMode && ( + + + + )} + + ))} + +
+
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/RecourseSection.tsx b/src/components/accounting/BillManagement/sections/RecourseSection.tsx new file mode 100644 index 00000000..0457ca96 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/RecourseSection.tsx @@ -0,0 +1,48 @@ +'use client'; + +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 { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; +import { RECOURSE_REASON_OPTIONS } from '../constants'; + +export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + 소구 (상환) 정보 + + +

배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우

+
+
+ + updateField('recourseDate', d)} disabled={isViewMode} /> +
+
+ + updateField('recourseAmount', v ?? 0)} disabled={isViewMode} /> +
+
+ + updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/RenewalSection.tsx b/src/components/accounting/BillManagement/sections/RenewalSection.tsx new file mode 100644 index 00000000..a07ae8c6 --- /dev/null +++ b/src/components/accounting/BillManagement/sections/RenewalSection.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import type { SectionProps } from './types'; +import { RENEWAL_REASON_OPTIONS } from '../constants'; + +export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) { + return ( + + + + 개서 정보 + 만기연장 + + + +
+
+ + updateField('renewalDate', d)} disabled={isViewMode} /> +
+
+ + updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/accounting/BillManagement/sections/index.ts b/src/components/accounting/BillManagement/sections/index.ts new file mode 100644 index 00000000..c7da511d --- /dev/null +++ b/src/components/accounting/BillManagement/sections/index.ts @@ -0,0 +1,11 @@ +export { BasicInfoSection } from './BasicInfoSection'; +export { ElectronicBillSection } from './ElectronicBillSection'; +export { ExchangeBillSection } from './ExchangeBillSection'; +export { DiscountInfoSection } from './DiscountInfoSection'; +export { EndorsementSection } from './EndorsementSection'; +export { CollectionSection } from './CollectionSection'; +export { HistorySection } from './HistorySection'; +export { RenewalSection } from './RenewalSection'; +export { RecourseSection } from './RecourseSection'; +export { BuybackSection } from './BuybackSection'; +export { DishonoredSection } from './DishonoredSection'; diff --git a/src/components/accounting/BillManagement/sections/types.ts b/src/components/accounting/BillManagement/sections/types.ts new file mode 100644 index 00000000..e34205ec --- /dev/null +++ b/src/components/accounting/BillManagement/sections/types.ts @@ -0,0 +1,7 @@ +import type { BillFormData } from '../types'; + +export interface SectionProps { + formData: BillFormData; + updateField: (field: K, value: BillFormData[K]) => void; + isViewMode: boolean; +} diff --git a/src/components/accounting/BillManagement/types.ts b/src/components/accounting/BillManagement/types.ts index 472f7585..9090f1dc 100644 --- a/src/components/accounting/BillManagement/types.ts +++ b/src/components/accounting/BillManagement/types.ts @@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) { export interface BillApiInstallment { id: number; bill_id: number; + type?: string; installment_date: string; amount: string; + counterparty?: string | null; note: string | null; created_at: string; updated_at: string; @@ -190,7 +192,7 @@ export interface BillApiData { client_name: string | null; amount: string; issue_date: string; - maturity_date: string; + maturity_date: string | null; status: BillStatus; reason: string | null; installment_count: number; @@ -211,6 +213,58 @@ export interface BillApiData { account_name: string; } | null; installments?: BillApiInstallment[]; + // V8 확장 필드 + instrument_type?: string; + medium?: string; + bill_category?: string; + electronic_bill_no?: string | null; + registration_org?: string | null; + drawee?: string | null; + acceptance_status?: string | null; + acceptance_date?: string | null; + acceptance_refusal_date?: string | null; + acceptance_refusal_reason?: string | null; + endorsement?: string | null; + endorsement_order?: string | null; + storage_place?: string | null; + issuer_bank?: string | null; + is_discounted?: boolean; + discount_date?: string | null; + discount_bank?: string | null; + discount_rate?: string | null; + discount_amount?: string | null; + endorsement_date?: string | null; + endorsee?: string | null; + endorsement_reason?: string | null; + collection_bank?: string | null; + collection_request_date?: string | null; + collection_fee?: string | null; + collection_complete_date?: string | null; + collection_result?: string | null; + collection_deposit_date?: string | null; + collection_deposit_amount?: string | null; + settlement_bank?: string | null; + payment_method?: string | null; + actual_payment_date?: string | null; + payment_place?: string | null; + payment_place_detail?: string | null; + renewal_date?: string | null; + renewal_new_bill_no?: string | null; + renewal_reason?: string | null; + recourse_date?: string | null; + recourse_amount?: string | null; + recourse_target?: string | null; + recourse_reason?: string | null; + buyback_date?: string | null; + buyback_amount?: string | null; + buyback_bank?: string | null; + dishonored_date?: string | null; + dishonored_reason?: string | null; + has_protest?: boolean; + protest_date?: string | null; + recourse_notice_date?: string | null; + recourse_notice_deadline?: string | null; + is_split?: boolean; } export interface BillApiResponse { @@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord { vendorName: apiData.client?.name || apiData.client_name || '', amount: parseFloat(apiData.amount), issueDate: apiData.issue_date, - maturityDate: apiData.maturity_date, + maturityDate: apiData.maturity_date || '', status: apiData.status, reason: apiData.reason || '', installmentCount: apiData.installment_count, @@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord { }; } -// ===== Frontend → API 변환 함수 ===== +// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) ===== export function transformFrontendToApi(data: Partial): Record { const result: Record = {}; @@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial): Record): Record { + const isReceived = data.direction === 'received'; + const orNull = (v: string) => v || null; + const orNullNum = (v: number) => v || null; + const orNullDate = (v: string) => v || null; + + return { + // 기존 12개 필드 + bill_number: data.billNumber, + bill_type: data.direction, + client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null), + client_name: vendorName || null, + amount: data.amount, + issue_date: data.issueDate, + maturity_date: orNullDate(data.maturityDate), + status: isReceived ? data.receivedStatus : data.issuedStatus, + note: orNull(data.note), + is_electronic: data.medium === 'electronic', + // V8 확장 필드 + instrument_type: data.instrumentType, + medium: data.medium, + bill_category: orNull(data.billCategory), + electronic_bill_no: orNull(data.electronicBillNo), + registration_org: orNull(data.registrationOrg), + drawee: orNull(data.drawee), + acceptance_status: orNull(data.acceptanceStatus), + acceptance_date: orNullDate(data.acceptanceDate), + acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate), + acceptance_refusal_reason: orNull(data.acceptanceRefusalReason), + endorsement: orNull(data.endorsement), + endorsement_order: orNull(data.endorsementOrder), + storage_place: orNull(data.storagePlace), + issuer_bank: orNull(data.issuerBank), + is_discounted: data.isDiscounted, + discount_date: orNullDate(data.discountDate), + discount_bank: orNull(data.discountBank), + discount_rate: orNullNum(data.discountRate), + discount_amount: orNullNum(data.discountAmount), + endorsement_date: orNullDate(data.endorsementDate), + endorsee: orNull(data.endorsee), + endorsement_reason: orNull(data.endorsementReason), + collection_bank: orNull(data.collectionBank), + collection_request_date: orNullDate(data.collectionRequestDate), + collection_fee: orNullNum(data.collectionFee), + collection_complete_date: orNullDate(data.collectionCompleteDate), + collection_result: orNull(data.collectionResult), + collection_deposit_date: orNullDate(data.collectionDepositDate), + collection_deposit_amount: orNullNum(data.collectionDepositAmount), + settlement_bank: orNull(data.settlementBank), + payment_method: orNull(data.paymentMethod), + actual_payment_date: orNullDate(data.actualPaymentDate), + payment_place: orNull(data.paymentPlace), + payment_place_detail: orNull(data.paymentPlaceDetail), + renewal_date: orNullDate(data.renewalDate), + renewal_new_bill_no: orNull(data.renewalNewBillNo), + renewal_reason: orNull(data.renewalReason), + recourse_date: orNullDate(data.recourseDate), + recourse_amount: orNullNum(data.recourseAmount), + recourse_target: orNull(data.recourseTarget), + recourse_reason: orNull(data.recourseReason), + buyback_date: orNullDate(data.buybackDate), + buyback_amount: orNullNum(data.buybackAmount), + buyback_bank: orNull(data.buybackBank), + dishonored_date: orNullDate(data.dishonoredDate), + dishonored_reason: orNull(data.dishonoredReason), + has_protest: data.hasProtest, + protest_date: orNullDate(data.protestDate), + recourse_notice_date: orNullDate(data.recourseNoticeDate), + recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline), + is_split: data.isSplit, + // 이력(차수) + installments: data.installments.map(inst => ({ + date: inst.date, + type: inst.type || 'other', + amount: inst.amount, + counterparty: orNull(inst.counterparty), + note: orNull(inst.note), + })), + }; +} + +// ============================================= +// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션) +// ============================================= + +// ===== 증권종류 ===== +export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck'; + +// ===== 거래방향 (Direction = BillType alias) ===== +export type Direction = 'received' | 'issued'; + +// ===== 매체 ===== +export type Medium = 'electronic' | 'paper'; + +// ===== 이력 레코드 (V8: 처리구분/상대처 추가) ===== +export interface HistoryRecord { + id: string; + date: string; + type: string; // 처리구분 (HISTORY_TYPE_OPTIONS) + amount: number; + counterparty: string; // 상대처 + note: string; +} + +// ===== V8 폼 데이터 (전체 ~45개 필드) ===== +export interface BillFormData { + // === 공통 === + billNumber: string; + instrumentType: InstrumentType; + direction: Direction; + medium: Medium; + amount: number; + issueDate: string; + maturityDate: string; + note: string; + // === 전자어음 (조건: medium=electronic) === + electronicBillNo: string; + registrationOrg: string; + // === 환어음 (조건: instrumentType=exchange) === + drawee: string; + acceptanceStatus: string; + acceptanceDate: string; + // === 받을어음 전용 === + vendor: string; + billCategory: string; + issuerBank: string; + endorsement: string; + endorsementOrder: string; + storagePlace: string; + receivedStatus: string; + isDiscounted: boolean; + discountDate: string; + discountBank: string; + discountRate: number; + discountAmount: number; + // 배서양도 + endorsementDate: string; + endorsee: string; + endorsementReason: string; + // 추심 + collectionBank: string; + collectionRequestDate: string; + collectionFee: number; + collectionCompleteDate: string; + collectionResult: string; + collectionDepositDate: string; + collectionDepositAmount: number; + // === 지급어음 전용 === + payee: string; + settlementBank: string; + paymentMethod: string; + issuedStatus: string; + actualPaymentDate: string; + // === 공통 === + paymentPlace: string; + paymentPlaceDetail: string; + // === 개서 === + renewalDate: string; + renewalNewBillNo: string; + renewalReason: string; + // === 소구/환매 === + recourseDate: string; + recourseAmount: number; + recourseTarget: string; + recourseReason: string; + buybackDate: string; + buybackAmount: number; + buybackBank: string; + // === 환어음 인수거절 === + acceptanceRefusalDate: string; + acceptanceRefusalReason: string; + // === 공통 조건부 === + isSplit: boolean; + splitCount: number; + splitAmount: number; + dishonoredDate: string; + dishonoredReason: string; + // 부도 법적 프로세스 + hasProtest: boolean; + protestDate: string; + recourseNoticeDate: string; + recourseNoticeDeadline: string; + // === 이력 관리 === + installments: HistoryRecord[]; + // === 입출금 계좌 === + bankAccountInfo: string; +} + +// ===== 초기 폼 데이터 ===== +export const INITIAL_BILL_FORM_DATA: BillFormData = { + billNumber: '', instrumentType: 'promissory', direction: 'received', + medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '', + electronicBillNo: '', registrationOrg: '', + drawee: '', acceptanceStatus: '', acceptanceDate: '', + vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1', + storagePlace: '', receivedStatus: 'stored', isDiscounted: false, + discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0, + endorsementDate: '', endorsee: '', endorsementReason: '', + collectionBank: '', collectionRequestDate: '', collectionFee: 0, + collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0, + payee: '', settlementBank: '', paymentMethod: 'autoTransfer', + issuedStatus: 'stored', actualPaymentDate: '', + paymentPlace: '', paymentPlaceDetail: '', + renewalDate: '', renewalNewBillNo: '', renewalReason: '', + recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '', + buybackDate: '', buybackAmount: 0, buybackBank: '', + acceptanceRefusalDate: '', acceptanceRefusalReason: '', + isSplit: false, splitCount: 0, splitAmount: 0, + dishonoredDate: '', dishonoredReason: '', + hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '', + installments: [], bankAccountInfo: '', +}; + +// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) ===== +export function apiDataToFormData(apiData: BillApiData): BillFormData { + const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0; + + return { + ...INITIAL_BILL_FORM_DATA, + billNumber: apiData.bill_number, + instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory', + direction: apiData.bill_type as Direction, + medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'), + amount: parseFloat(apiData.amount), + issueDate: apiData.issue_date, + maturityDate: apiData.maturity_date || '', + note: apiData.note || '', + // 전자어음 + electronicBillNo: apiData.electronic_bill_no || '', + registrationOrg: apiData.registration_org || '', + // 환어음 + drawee: apiData.drawee || '', + acceptanceStatus: apiData.acceptance_status || '', + acceptanceDate: apiData.acceptance_date || '', + acceptanceRefusalDate: apiData.acceptance_refusal_date || '', + acceptanceRefusalReason: apiData.acceptance_refusal_reason || '', + // 거래처 + vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '', + payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '', + // 받을어음 전용 + billCategory: apiData.bill_category || 'commercial', + issuerBank: apiData.issuer_bank || '', + endorsement: apiData.endorsement || 'endorsable', + endorsementOrder: apiData.endorsement_order || '1', + storagePlace: apiData.storage_place || '', + receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored', + isDiscounted: apiData.is_discounted ?? false, + discountDate: apiData.discount_date || '', + discountBank: apiData.discount_bank || '', + discountRate: pf(apiData.discount_rate), + discountAmount: pf(apiData.discount_amount), + endorsementDate: apiData.endorsement_date || '', + endorsee: apiData.endorsee || '', + endorsementReason: apiData.endorsement_reason || '', + collectionBank: apiData.collection_bank || '', + collectionRequestDate: apiData.collection_request_date || '', + collectionFee: pf(apiData.collection_fee), + collectionCompleteDate: apiData.collection_complete_date || '', + collectionResult: apiData.collection_result || '', + collectionDepositDate: apiData.collection_deposit_date || '', + collectionDepositAmount: pf(apiData.collection_deposit_amount), + // 지급어음 전용 + settlementBank: apiData.settlement_bank || '', + paymentMethod: apiData.payment_method || 'autoTransfer', + issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored', + actualPaymentDate: apiData.actual_payment_date || '', + // 공통 + paymentPlace: apiData.payment_place || '', + paymentPlaceDetail: apiData.payment_place_detail || '', + // 개서 + renewalDate: apiData.renewal_date || '', + renewalNewBillNo: apiData.renewal_new_bill_no || '', + renewalReason: apiData.renewal_reason || '', + // 소구/환매 + recourseDate: apiData.recourse_date || '', + recourseAmount: pf(apiData.recourse_amount), + recourseTarget: apiData.recourse_target || '', + recourseReason: apiData.recourse_reason || '', + buybackDate: apiData.buyback_date || '', + buybackAmount: pf(apiData.buyback_amount), + buybackBank: apiData.buyback_bank || '', + // 부도 + isSplit: apiData.is_split ?? false, + splitCount: 0, + splitAmount: 0, + dishonoredDate: apiData.dishonored_date || '', + dishonoredReason: apiData.dishonored_reason || '', + hasProtest: apiData.has_protest ?? false, + protestDate: apiData.protest_date || '', + recourseNoticeDate: apiData.recourse_notice_date || '', + recourseNoticeDeadline: apiData.recourse_notice_deadline || '', + // 이력 + installments: (apiData.installments || []).map(inst => ({ + id: String(inst.id), + date: inst.installment_date, + type: inst.type || 'other', + amount: parseFloat(inst.amount), + counterparty: inst.counterparty || '', + note: inst.note || '', + })), + bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '', + }; +} + +// ===== BillRecord → BillFormData 변환 (하위호환 유지) ===== +export function billRecordToFormData(record: BillRecord): BillFormData { + return { + ...INITIAL_BILL_FORM_DATA, + billNumber: record.billNumber, + direction: record.billType as Direction, + amount: record.amount, + issueDate: record.issueDate, + maturityDate: record.maturityDate, + note: record.note, + receivedStatus: record.billType === 'received' ? record.status : 'stored', + issuedStatus: record.billType === 'issued' ? record.status : 'stored', + vendor: record.billType === 'received' ? record.vendorId : '', + payee: record.billType === 'issued' ? record.vendorId : '', + installments: record.installments.map(inst => ({ + id: inst.id, + date: inst.date, + type: 'other', + amount: inst.amount, + counterparty: '', + note: inst.note, + })), + }; } \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 84e4e146..6357ef6d 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { formatNumber } from '@/lib/utils/amount'; import { filterByEnum } from '@/lib/utils/search'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; + +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn[] = [ + { header: '사용일시', key: 'usedAt', width: 18 }, + { header: '카드사', key: 'cardCompany', width: 10 }, + { header: '카드번호', key: 'card', width: 12 }, + { header: '카드명', key: 'cardName', width: 12 }, + { header: '공제', key: 'deductionType', width: 10, + transform: (v) => v === 'deductible' ? '공제' : '불공제' }, + { header: '사업자번호', key: 'businessNumber', width: 15 }, + { header: '가맹점명', key: 'merchantName', width: 15 }, + { header: '증빙/판매자상호', key: 'vendorName', width: 18 }, + { header: '내역', key: 'description', width: 15 }, + { header: '합계금액', key: 'totalAmount', width: 12 }, + { header: '공급가액', key: 'supplyAmount', width: 12 }, + { header: '세액', key: 'taxAmount', width: 10 }, + { header: '계정과목', key: 'accountSubject', width: 12, + transform: (v) => { + const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v); + return found?.label || String(v || ''); + }}, +]; // ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== const tableColumns = [ @@ -269,9 +292,45 @@ export function CardTransactionInquiry() { setShowJournalEntry(true); }, []); - const handleExcelDownload = useCallback(() => { - toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.'); - }, []); + const handleExcelDownload = useCallback(async () => { + try { + toast.info('엑셀 파일 생성 중...'); + const allData: CardTransaction[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getCardTransactionList({ + startDate, + endDate, + search: searchQuery || undefined, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel>({ + data: allData as (CardTransaction & Record)[], + columns: excelColumns as ExcelColumn>[], + filename: '카드사용내역', + sheetName: '카드사용내역', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); + } + }, [startDate, endDate, searchQuery]); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( diff --git a/src/components/accounting/DailyReport/index.tsx b/src/components/accounting/DailyReport/index.tsx index 555889e0..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,22 @@ 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)}`, []); // ===== 검색 필터링 ===== const filteredNoteReceivables = useMemo(() => { @@ -225,6 +239,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts ); }, [dailyAccounts, searchTerm]); + // ===== USD 데이터 존재 여부 ===== + const hasUsdAccounts = useMemo(() => + filteredDailyAccounts.some(item => item.currency === 'USD'), + [filteredDailyAccounts] + ); + return ( {/* 헤더 */} @@ -290,67 +310,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts - {/* 어음 및 외상매출채권현황 */} - - -
-

어음 및 외상매출채권현황

-
-
-
- - - - 내용 - 현재 잔액 - 발행일 - 만기일 - - - - {isLoading ? ( - - -
- - 데이터를 불러오는 중... -
-
-
- ) : filteredNoteReceivables.length === 0 ? ( - - - 데이터가 없습니다. - - - ) : ( - filteredNoteReceivables.map((item) => ( - - {item.content} - {formatAmount(item.currentBalance)} - {item.issueDate} - {item.dueDate} - - )) - )} -
- {filteredNoteReceivables.length > 0 && ( - - - 합계 - {formatAmount(noteReceivableTotal)} - - - - - )} -
-
-
-
-
- - {/* 일자별 상세 */} + {/* 인쇄 영역 */} +
+ {/* 일자별 입출금 합계 */}
@@ -358,10 +320,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts 일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
-
+
- - +
+ 구분 입금 @@ -398,6 +360,35 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts {formatAmount(item.balance)} ))} + {/* KRW 소계 */} + {hasUsdAccounts && ( + + 원화(KRW) 소계 + {formatAmount(accountTotals.krw.income)} + {formatAmount(accountTotals.krw.expense)} + {formatAmount(accountTotals.krw.balance)} + + )} + {/* USD 계좌들 */} + {hasUsdAccounts && filteredDailyAccounts + .filter(item => item.currency === 'USD') + .map((item) => ( + + {item.category} + {formatUsd(item.income)} + {formatUsd(item.expense)} + {formatUsd(item.balance)} + + ))} + {/* USD 소계 */} + {hasUsdAccounts && ( + + 외국환(USD) 소계 + {formatUsd(accountTotals.usd.income)} + {formatUsd(accountTotals.usd.expense)} + {formatUsd(accountTotals.usd.balance)} + + )} )} @@ -412,7 +403,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts )} -
+
@@ -424,11 +415,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts

예금 입출금 내역

+ {/* KRW 입출금 */}
- {/* 입금 */} + {/* KRW 입금 */}
- 입금 + 입금 (KRW)
@@ -474,10 +466,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts - {/* 출금 */} + {/* KRW 출금 */}
- 출금 + 출금 (KRW)
@@ -523,8 +515,165 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts + + {/* USD 입출금 — USD 데이터가 있을 때만 표시 */} + {hasUsdAccounts && ( + <> +
+

외국환(USD) 입출금 내역

+
+
+ {/* USD 입금 */} +
+
+ 입금 (USD) +
+
+
+ + + 입금처/적요 + 금액 + + + + {filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? ( + + + USD 입금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'USD' && item.income > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatUsd(item.income)} +
+ )) + )} +
+ + + 입금 합계 + {formatUsd(accountTotals.usd.income)} + + +
+
+
+ + {/* USD 출금 */} +
+
+ 출금 (USD) +
+
+ + + + 출금처/적요 + 금액 + + + + {filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? ( + + + USD 출금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'USD' && item.expense > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatUsd(item.expense)} +
+ )) + )} +
+ + + 출금 합계 + {formatUsd(accountTotals.usd.expense)} + + +
+
+
+
+ + )} + + {/* 어음 및 외상매출채권현황 */} + + +
+

어음 및 외상매출채권현황

+
+
+
+ + + + 내용 + 현재 잔액 + 발행일 + 만기일 + + + + {isLoading ? ( + + +
+ + 데이터를 불러오는 중... +
+
+
+ ) : filteredNoteReceivables.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + filteredNoteReceivables.map((item) => ( + + {item.content} + {formatAmount(item.currentBalance)} + {item.issueDate} + {item.dueDate} + + )) + )} +
+ {filteredNoteReceivables.length > 0 && ( + + + 합계 + {formatAmount(noteReceivableTotal)} + + + + + )} +
+
+
+
+
+
); } diff --git a/src/components/accounting/GiftCertificateManagement/actions.ts b/src/components/accounting/GiftCertificateManagement/actions.ts index f2304e82..b5e89cee 100644 --- a/src/components/accounting/GiftCertificateManagement/actions.ts +++ b/src/components/accounting/GiftCertificateManagement/actions.ts @@ -1,144 +1,106 @@ /** - * 상품권 관리 서버 액션 (Mock) + * 상품권 관리 서버 액션 * - * API Endpoints (예정): - * - GET /api/v1/gift-certificates - 목록 조회 - * - GET /api/v1/gift-certificates/{id} - 상세 조회 - * - POST /api/v1/gift-certificates - 등록 - * - PUT /api/v1/gift-certificates/{id} - 수정 - * - DELETE /api/v1/gift-certificates/{id} - 삭제 - * - GET /api/v1/gift-certificates/summary - 요약 통계 + * API Endpoints (Loan API 재사용, category='gift_certificate'): + * - GET /api/v1/loans?category=gift_certificate - 목록 조회 + * - GET /api/v1/loans/{id} - 상세 조회 + * - POST /api/v1/loans - 등록 + * - PUT /api/v1/loans/{id} - 수정 + * - DELETE /api/v1/loans/{id} - 삭제 + * - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계 */ 'use server'; -import type { ActionResult } from '@/lib/api/execute-server-action'; -// import { executeServerAction } from '@/lib/api/execute-server-action'; -// import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; -// import { buildApiUrl } from '@/lib/api/query-params'; +import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action'; +import { buildApiUrl } from '@/lib/api/query-params'; import type { GiftCertificateRecord, GiftCertificateFormData, + LoanApiData, +} from './types'; +import { + transformApiToRecord, + transformApiToFormData, + transformFormToApi, } from './types'; -// ===== 상품권 목록 조회 (Mock) ===== -export async function getGiftCertificates(_params?: { +// ===== 상품권 목록 조회 ===== +export async function getGiftCertificates(params?: { page?: number; perPage?: number; startDate?: string; endDate?: string; status?: string; -}): Promise> { - // TODO: 실제 API 연동 시 교체 - // return executePaginatedAction({ - // url: buildApiUrl('/api/v1/gift-certificates', { ... }), - // transform: transformApiToFrontend, - // errorMessage: '상품권 목록 조회에 실패했습니다.', - // }); - return { success: true, data: [] }; + search?: string; +}): Promise> { + return executePaginatedAction({ + url: buildApiUrl('/api/v1/loans', { + category: 'gift_certificate', + page: params?.page, + per_page: params?.perPage, + start_date: params?.startDate, + end_date: params?.endDate, + status: params?.status && params.status !== 'all' ? params.status : undefined, + search: params?.search, + }), + transform: transformApiToRecord, + errorMessage: '상품권 목록 조회에 실패했습니다.', + }); } -// ===== 상품권 상세 조회 (Mock) ===== +// ===== 상품권 상세 조회 ===== export async function getGiftCertificateById( - _id: string + id: string ): Promise> { - // TODO: 실제 API 연동 시 교체 - // return executeServerAction({ - // url: buildApiUrl(`/api/v1/gift-certificates/${id}`), - // transform: transformDetailApiToFrontend, - // errorMessage: '상품권 조회에 실패했습니다.', - // }); - return { - success: true, - data: { - serialNumber: 'GC-2026-001', - name: '신세계 상품권', - faceValue: 500000, - vendorId: '', - vendorName: '신세계백화점', - purchaseDate: '2026-02-10', - purchasePurpose: 'entertainment', - entertainmentExpense: 'applicable', - status: 'used', - usedDate: '2026-02-20', - recipientName: '홍길동', - recipientOrganization: '(주)테크솔루션', - usageDescription: '거래처 접대용', - memo: '2월 접대비 처리 완료', - }, - }; + return executeServerAction({ + url: buildApiUrl(`/api/v1/loans/${id}`), + transform: (data: LoanApiData) => transformApiToFormData(data), + errorMessage: '상품권 조회에 실패했습니다.', + }); } -// ===== 상품권 등록 (Mock) ===== +// ===== 상품권 등록 ===== export async function createGiftCertificate( - _data: GiftCertificateFormData + data: GiftCertificateFormData ): Promise> { - // TODO: 실제 API 연동 시 교체 - // return executeServerAction({ - // url: buildApiUrl('/api/v1/gift-certificates'), - // method: 'POST', - // body: transformFrontendToApi(data), - // transform: transformApiToFrontend, - // errorMessage: '상품권 등록에 실패했습니다.', - // }); - return { - success: true, - data: { - id: crypto.randomUUID(), - serialNumber: _data.serialNumber || `GC-${Date.now()}`, - name: _data.name, - faceValue: _data.faceValue, - purchaseDate: _data.purchaseDate, - usedDate: _data.usedDate || null, - status: _data.status, - entertainmentExpense: _data.entertainmentExpense, - }, - }; + return executeServerAction({ + url: buildApiUrl('/api/v1/loans'), + method: 'POST', + body: transformFormToApi(data), + transform: (d: LoanApiData) => transformApiToRecord(d), + errorMessage: '상품권 등록에 실패했습니다.', + }); } -// ===== 상품권 수정 (Mock) ===== +// ===== 상품권 수정 ===== export async function updateGiftCertificate( - _id: string, - _data: GiftCertificateFormData + id: string, + data: GiftCertificateFormData ): Promise> { - // TODO: 실제 API 연동 시 교체 - // return executeServerAction({ - // url: buildApiUrl(`/api/v1/gift-certificates/${id}`), - // method: 'PUT', - // body: transformFrontendToApi(data), - // transform: transformApiToFrontend, - // errorMessage: '상품권 수정에 실패했습니다.', - // }); - return { - success: true, - data: { - id: _id, - serialNumber: _data.serialNumber, - name: _data.name, - faceValue: _data.faceValue, - purchaseDate: _data.purchaseDate, - usedDate: _data.usedDate || null, - status: _data.status, - entertainmentExpense: _data.entertainmentExpense, - }, - }; + return executeServerAction({ + url: buildApiUrl(`/api/v1/loans/${id}`), + method: 'PUT', + body: transformFormToApi(data), + transform: (d: LoanApiData) => transformApiToRecord(d), + errorMessage: '상품권 수정에 실패했습니다.', + }); } -// ===== 상품권 삭제 (Mock) ===== +// ===== 상품권 삭제 ===== export async function deleteGiftCertificate( - _id: string + id: string ): Promise { - // TODO: 실제 API 연동 시 교체 - // return executeServerAction({ - // url: buildApiUrl(`/api/v1/gift-certificates/${id}`), - // method: 'DELETE', - // errorMessage: '상품권 삭제에 실패했습니다.', - // }); - return { success: true }; + return executeServerAction({ + url: buildApiUrl(`/api/v1/loans/${id}`), + method: 'DELETE', + errorMessage: '상품권 삭제에 실패했습니다.', + }); } -// ===== 상품권 요약 통계 (Mock) ===== -export async function getGiftCertificateSummary(_params?: { +// ===== 상품권 요약 통계 ===== +export async function getGiftCertificateSummary(params?: { startDate?: string; endDate?: string; }): Promise> { - // TODO: 실제 API 연동 시 교체 - // return executeServerAction({ - // url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }), - // transform: transformSummary, - // errorMessage: '상품권 요약 조회에 실패했습니다.', - // }); - return { - success: true, - data: { - totalCount: 0, - totalAmount: 0, - holdingCount: 0, - holdingAmount: 0, - usedCount: 0, - usedAmount: 0, + return executeServerAction({ + url: buildApiUrl('/api/v1/loans/summary', { + category: 'gift_certificate', + start_date: params?.startDate, + end_date: params?.endDate, + }), + transform: (data: { + total_count: number; + total_amount: number; + holding_count?: number; + holding_amount?: number; + used_count?: number; + used_amount?: number; + }) => ({ + totalCount: data.total_count ?? 0, + totalAmount: data.total_amount ?? 0, + holdingCount: data.holding_count ?? 0, + holdingAmount: data.holding_amount ?? 0, + usedCount: data.used_count ?? 0, + usedAmount: data.used_amount ?? 0, entertainmentCount: 0, entertainmentAmount: 0, - }, - }; + }), + errorMessage: '상품권 요약 조회에 실패했습니다.', + }); } diff --git a/src/components/accounting/GiftCertificateManagement/types.ts b/src/components/accounting/GiftCertificateManagement/types.ts index 40506d2f..1cc01feb 100644 --- a/src/components/accounting/GiftCertificateManagement/types.ts +++ b/src/components/accounting/GiftCertificateManagement/types.ts @@ -104,3 +104,94 @@ export function createEmptyFormData(): GiftCertificateFormData { // ===== 액면가 50만원 기준 ===== export const FACE_VALUE_THRESHOLD = 500000; + +// ===== Loan API 응답 타입 ===== +export interface LoanApiData { + id: number; + tenant_id: number; + user_id: number | null; + loan_date: string; + amount: string; + purpose: string | null; + settlement_date: string | null; + settlement_amount: string | null; + status: string; + category: string | null; + metadata: { + serial_number?: string; + cert_name?: string; + vendor_id?: string; + vendor_name?: string; + purchase_purpose?: string; + entertainment_expense?: string; + recipient_name?: string; + recipient_organization?: string; + usage_description?: string; + memo?: string; + } | null; + withdrawal_id: number | null; + created_by: number | null; + updated_by: number | null; + user?: { id: number; name: string; email: string } | null; + creator?: { id: number; name: string } | null; +} + +// ===== API → 프론트 변환 (목록용) ===== +export function transformApiToRecord(api: LoanApiData): GiftCertificateRecord { + const meta = api.metadata ?? {}; + return { + id: String(api.id), + serialNumber: meta.serial_number ?? '', + name: meta.cert_name ?? '', + faceValue: parseFloat(api.amount) || 0, + purchaseDate: api.loan_date ?? '', + usedDate: api.settlement_date ?? null, + status: (api.status as GiftCertificateStatus) ?? 'holding', + entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable', + }; +} + +// ===== API → 프론트 변환 (상세/폼용) ===== +export function transformApiToFormData(api: LoanApiData): GiftCertificateFormData { + const meta = api.metadata ?? {}; + return { + serialNumber: meta.serial_number ?? '', + name: meta.cert_name ?? '', + faceValue: parseFloat(api.amount) || 0, + vendorId: meta.vendor_id ?? '', + vendorName: meta.vendor_name ?? '', + purchaseDate: api.loan_date ?? '', + purchasePurpose: (meta.purchase_purpose as PurchasePurpose) ?? 'promotion', + entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable', + status: (api.status as GiftCertificateStatus) ?? 'holding', + usedDate: api.settlement_date ?? '', + recipientName: meta.recipient_name ?? '', + recipientOrganization: meta.recipient_organization ?? '', + usageDescription: meta.usage_description ?? '', + memo: meta.memo ?? '', + }; +} + +// ===== 프론트 → API 변환 ===== +export function transformFormToApi(data: GiftCertificateFormData): Record { + return { + loan_date: data.purchaseDate, + amount: data.faceValue, + purpose: data.usageDescription || null, + category: 'gift_certificate', + status: data.status, + settlement_date: data.usedDate || null, + metadata: { + serial_number: data.serialNumber || null, + cert_name: data.name || null, + vendor_id: data.vendorId || null, + vendor_name: data.vendorName || null, + purchase_purpose: data.purchasePurpose || null, + entertainment_expense: data.entertainmentExpense || null, + recipient_name: data.recipientName || null, + recipient_organization: data.recipientOrganization || null, + usage_description: data.usageDescription || null, + memo: data.memo || null, + }, + }; +} diff --git a/src/components/accounting/ReceivablesStatus/index.tsx b/src/components/accounting/ReceivablesStatus/index.tsx index 347a2529..a79736cb 100644 --- a/src/components/accounting/ReceivablesStatus/index.tsx +++ b/src/components/accounting/ReceivablesStatus/index.tsx @@ -32,9 +32,10 @@ import { CATEGORY_LABELS, SORT_OPTIONS, } from './types'; -import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions'; +import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions'; import { toast } from 'sonner'; import { filterByText } from '@/lib/utils/search'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; @@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma }); }, []); - // ===== 엑셀 다운로드 핸들러 ===== + // ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) ===== const handleExcelDownload = useCallback(async () => { - const result = await exportReceivablesExcel({ - year: selectedYear, - search: searchQuery || undefined, - }); - - if (result.success && result.data) { - const url = URL.createObjectURL(result.data); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename || '채권현황.xlsx'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success('엑셀 파일이 다운로드되었습니다.'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + try { + toast.info('엑셀 파일 생성 중...'); + // 데이터가 이미 로드되어 있으므로 sortedData 사용 + if (sortedData.length === 0) { + toast.warning('다운로드할 데이터가 없습니다.'); + return; + } + // 동적 월 컬럼 포함 엑셀 컬럼 생성 + const columns: ExcelColumn>[] = [ + { header: '거래처', key: 'vendorName', width: 20 }, + { header: '연체', key: 'isOverdue', width: 8 }, + ...monthLabels.map((label, idx) => ({ + header: label, key: `month_${idx}`, width: 12, + })), + { header: '합계', key: 'total', width: 14 }, + { header: '메모', key: 'memo', width: 20 }, + ]; + // 미수금 카테고리 기준으로 플랫 데이터 생성 + const exportData = sortedData.map(vendor => { + const receivable = vendor.categories.find(c => c.category === 'receivable'); + const row: Record = { + vendorName: vendor.vendorName, + isOverdue: vendor.isOverdue ? '연체' : '', + }; + monthLabels.forEach((_, idx) => { + row[`month_${idx}`] = receivable?.amounts.values[idx] || 0; + }); + row.total = receivable?.amounts.total || 0; + row.memo = vendor.memo || ''; + return row; + }); + await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' }); + toast.success('엑셀 다운로드 완료'); + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } - }, [selectedYear, searchQuery]); + }, [sortedData, monthLabels]); // ===== 변경된 연체 항목 확인 ===== const changedOverdueItems = useMemo(() => { diff --git a/src/components/accounting/TaxInvoiceManagement/index.tsx b/src/components/accounting/TaxInvoiceManagement/index.tsx index ec534423..f64a2ca3 100644 --- a/src/components/accounting/TaxInvoiceManagement/index.tsx +++ b/src/components/accounting/TaxInvoiceManagement/index.tsx @@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard'; import { getTaxInvoices, getTaxInvoiceSummary, - downloadTaxInvoiceExcel, } from './actions'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; const ManualEntryModal = dynamic( () => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })), @@ -58,6 +58,10 @@ import type { TaxInvoiceMgmtRecord, InvoiceTab, TaxInvoiceSummary, + TaxType, + ReceiptType, + InvoiceStatus, + InvoiceSource, } from './types'; import { TAB_OPTIONS, @@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [ { value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 }, ]; +// ===== 엑셀 다운로드 컬럼 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '작성일자', key: 'writeDate', width: 12 }, + { header: '발급일자', key: 'issueDate', width: 12 }, + { header: '거래처', key: 'vendorName', width: 20 }, + { header: '사업자번호', key: 'vendorBusinessNumber', width: 15 }, + { header: '과세형태', key: 'taxType', width: 10, + transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') }, + { header: '품목', key: 'itemName', width: 15 }, + { header: '공급가액', key: 'supplyAmount', width: 14 }, + { header: '세액', key: 'taxAmount', width: 14 }, + { header: '합계', key: 'totalAmount', width: 14 }, + { header: '영수청구', key: 'receiptType', width: 10, + transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') }, + { header: '상태', key: 'status', width: 10, + transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') }, + { header: '발급형태', key: 'source', width: 10, + transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') }, +]; + // ===== 테이블 컬럼 ===== const tableColumns = [ { key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true }, @@ -224,19 +248,46 @@ export function TaxInvoiceManagement() { loadData(); }, [loadData]); - // ===== 엑셀 다운로드 ===== + // ===== 엑셀 다운로드 (프론트 xlsx 생성) ===== const handleExcelDownload = useCallback(async () => { - const result = await downloadTaxInvoiceExcel({ - division: activeTab, - dateType, - startDate, - endDate, - vendorSearch, - }); - if (result.success && result.data) { - window.open(result.data.url, '_blank'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + try { + toast.info('엑셀 파일 생성 중...'); + const allData: TaxInvoiceMgmtRecord[] = []; + let page = 1; + let lastPage = 1; + + do { + const result = await getTaxInvoices({ + division: activeTab, + dateType, + startDate, + endDate, + vendorSearch, + page, + perPage: 100, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel({ + data: allData as (TaxInvoiceMgmtRecord & Record)[], + columns: excelColumns, + filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`, + sheetName: activeTab === 'sales' ? '매출' : '매입', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [activeTab, dateType, startDate, endDate, vendorSearch]); diff --git a/src/components/accounting/VendorLedger/index.tsx b/src/components/accounting/VendorLedger/index.tsx index 629c986e..fcf1ba23 100644 --- a/src/components/accounting/VendorLedger/index.tsx +++ b/src/components/accounting/VendorLedger/index.tsx @@ -26,8 +26,9 @@ import { type StatCard, } from '@/components/templates/UniversalListPage'; import type { VendorLedgerItem, VendorLedgerSummary } from './types'; -import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions'; +import { getVendorLedgerList, getVendorLedgerSummary } from './actions'; import { formatNumber } from '@/lib/utils/amount'; +import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; @@ -43,6 +44,16 @@ const tableColumns = [ { key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true }, ]; +// ===== 엑셀 컬럼 정의 ===== +const excelColumns: ExcelColumn>[] = [ + { header: '거래처명', key: 'vendorName', width: 20 }, + { header: '이월잔액', key: 'carryoverBalance', width: 14 }, + { header: '매출', key: 'sales', width: 14 }, + { header: '수금', key: 'collection', width: 14 }, + { header: '잔액', key: 'balance', width: 14 }, + { header: '결제일', key: 'paymentDate', width: 12 }, +]; + // ===== Props ===== interface VendorLedgerProps { initialData?: VendorLedgerItem[]; @@ -144,24 +155,42 @@ export function VendorLedger({ ); const handleExcelDownload = useCallback(async () => { - const result = await exportVendorLedgerExcel({ - startDate, - endDate, - search: searchQuery || undefined, - }); + try { + toast.info('엑셀 파일 생성 중...'); + const allData: VendorLedgerItem[] = []; + let page = 1; + let lastPage = 1; - if (result.success && result.data) { - const url = URL.createObjectURL(result.data); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename || '거래처원장.xlsx'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success('엑셀 파일이 다운로드되었습니다.'); - } else { - toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); + do { + const result = await getVendorLedgerList({ + startDate, + endDate, + search: searchQuery || undefined, + perPage: 100, + page, + }); + if (result.success && result.data.length > 0) { + allData.push(...result.data); + lastPage = result.pagination?.lastPage ?? 1; + } else { + break; + } + page++; + } while (page <= lastPage); + + if (allData.length > 0) { + await downloadExcel>({ + data: allData as (VendorLedgerItem & Record)[], + columns: excelColumns, + filename: '거래처원장', + sheetName: '거래처원장', + }); + toast.success('엑셀 다운로드 완료'); + } else { + toast.warning('다운로드할 데이터가 없습니다.'); + } + } catch { + toast.error('엑셀 다운로드에 실패했습니다.'); } }, [startDate, endDate, searchQuery]); 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/accounting/VendorManagement/VendorManagementClient.tsx b/src/components/accounting/VendorManagement/VendorManagementClient.tsx index 2bf5f13b..82b1f271 100644 --- a/src/components/accounting/VendorManagement/VendorManagementClient.tsx +++ b/src/components/accounting/VendorManagement/VendorManagementClient.tsx @@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana enumFilter('creditRating', creditRatingFilter), enumFilter('transactionGrade', transactionGradeFilter), enumFilter('badDebtStatus', badDebtFilter), + (items: Vendor[]) => items.filter((item) => { + if (!item.createdAt) return true; + const created = item.createdAt.slice(0, 10); + return created >= startDate && created <= endDate; + }), ]); // 정렬 @@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana } return result; - }, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]); + }, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]); const paginatedData = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; diff --git a/src/components/accounting/VendorManagement/actions.ts b/src/components/accounting/VendorManagement/actions.ts index 2752aa93..39ad783e 100644 --- a/src/components/accounting/VendorManagement/actions.ts +++ b/src/components/accounting/VendorManagement/actions.ts @@ -131,6 +131,8 @@ export async function getClients(params?: { size?: number; q?: string; only_active?: boolean; + start_date?: string; + end_date?: string; }): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/clients', { @@ -138,6 +140,8 @@ export async function getClients(params?: { size: params?.size, q: params?.q, only_active: params?.only_active, + start_date: params?.start_date, + end_date: params?.end_date, }), transform: (data: PaginatedResponse) => ({ items: data.data.map(transformApiToFrontend), diff --git a/src/components/accounting/VendorManagement/index.tsx b/src/components/accounting/VendorManagement/index.tsx index 5ac785a1..657f3e40 100644 --- a/src/components/accounting/VendorManagement/index.tsx +++ b/src/components/accounting/VendorManagement/index.tsx @@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, + dateField: 'createdAt', }, // 데이터 변경 콜백 (Stats 계산용) 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/ShipmentManagement/ShipmentDetail.tsx b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx index eed286f0..ed443ae5 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentDetail.tsx @@ -328,7 +328,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) { 기본 정보 -
+
{renderInfoField('로트번호', detail.lotNo)} {renderInfoField('현장명', detail.siteName)} {renderInfoField('수주처', detail.customerName)} diff --git a/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx b/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx index f36a9de5..dadba78b 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentEdit.tsx @@ -391,7 +391,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) { 기본 정보 -
+
{detail.lotNo}
diff --git a/src/components/outbound/ShipmentManagement/ShipmentList.tsx b/src/components/outbound/ShipmentManagement/ShipmentList.tsx index 64289926..399a66bc 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentList.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentList.tsx @@ -71,7 +71,7 @@ export function ShipmentList() { // ===== 캘린더 상태 ===== const [calendarDate, setCalendarDate] = useState(new Date()); - const [scheduleView, setScheduleView] = useState('day-time'); + const [scheduleView, setScheduleView] = useState('week-time'); const [shipmentData, setShipmentData] = useState([]); // startDate 변경 시 캘린더 월 자동 이동 diff --git a/src/components/outbound/ShipmentManagement/actions.ts b/src/components/outbound/ShipmentManagement/actions.ts index b78d771d..75db8eec 100644 --- a/src/components/outbound/ShipmentManagement/actions.ts +++ b/src/components/outbound/ShipmentManagement/actions.ts @@ -50,6 +50,9 @@ interface OrderInfoApiData { site_name?: string; delivery_address?: string; contact?: string; + delivery_date?: string; + writer_id?: number; + writer_name?: string; } interface ShipmentApiData { @@ -91,6 +94,16 @@ interface ShipmentApiData { created_at?: string; updated_at?: string; items?: ShipmentItemApiData[]; + vehicle_dispatches?: Array<{ + id: number; + seq: number; + logistics_company?: string; + arrival_datetime?: string; + tonnage?: string; + vehicle_no?: string; + driver_contact?: string; + remarks?: string; + }>; status_label?: string; priority_label?: string; delivery_method_label?: string; @@ -146,7 +159,13 @@ function transformApiToListItem(data: ShipmentApiData): ShipmentItem { canShip: data.can_ship, depositConfirmed: data.deposit_confirmed, invoiceIssued: data.invoice_issued, - deliveryTime: data.expected_arrival, + deliveryTime: data.vehicle_dispatches?.[0]?.arrival_datetime || data.expected_arrival, + // 수신/작성자/출고일 매핑 + receiver: data.receiver || '', + receiverAddress: data.order_info?.delivery_address || data.delivery_address || '', + receiverCompany: data.order_info?.customer_name || data.customer_name || '', + writer: data.order_info?.writer_name || data.creator?.name || '', + shipmentDate: data.scheduled_date || '', }; } @@ -193,18 +212,28 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail { zipCode: (data as unknown as Record).zip_code as string | undefined, address: (data as unknown as Record).address as string | undefined, addressDetail: (data as unknown as Record).address_detail as string | undefined, - // 배차 정보 - 기존 단일 필드에서 구성 (다중 행 API 준비 전까지) - vehicleDispatches: data.vehicle_no || data.logistics_company || data.driver_contact - ? [{ - id: `vd-${data.id}`, - logisticsCompany: data.logistics_company || '-', - arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-', - tonnage: data.vehicle_tonnage || '-', - vehicleNo: data.vehicle_no || '-', - driverContact: data.driver_contact || '-', - remarks: '', - }] - : [], + // 배차 정보 - vehicle_dispatches 테이블에서 조회, 없으면 레거시 단일 필드 fallback + vehicleDispatches: data.vehicle_dispatches && data.vehicle_dispatches.length > 0 + ? data.vehicle_dispatches.map((vd) => ({ + id: String(vd.id), + logisticsCompany: vd.logistics_company || '-', + arrivalDateTime: vd.arrival_datetime || '-', + tonnage: vd.tonnage || '-', + vehicleNo: vd.vehicle_no || '-', + driverContact: vd.driver_contact || '-', + remarks: vd.remarks || '', + })) + : (data.vehicle_no || data.logistics_company || data.driver_contact + ? [{ + id: `vd-legacy-${data.id}`, + logisticsCompany: data.logistics_company || '-', + arrivalDateTime: data.confirmed_arrival || data.expected_arrival || '-', + tonnage: data.vehicle_tonnage || '-', + vehicleNo: data.vehicle_no || '-', + driverContact: data.driver_contact || '-', + remarks: '', + }] + : []), // 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리 productGroups: [], otherParts: [], @@ -256,7 +285,7 @@ function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): Sh function transformCreateFormToApi( data: ShipmentCreateFormData ): Record { - return { + const result: Record = { lot_no: data.lotNo, scheduled_date: data.scheduledDate, priority: data.priority, @@ -267,6 +296,20 @@ function transformCreateFormToApi( loading_manager: data.loadingManager, remarks: data.remarks, }; + + if (data.vehicleDispatches && data.vehicleDispatches.length > 0) { + result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({ + seq: idx + 1, + logistics_company: vd.logisticsCompany || null, + arrival_datetime: vd.arrivalDateTime || null, + tonnage: vd.tonnage || null, + vehicle_no: vd.vehicleNo || null, + driver_contact: vd.driverContact || null, + remarks: vd.remarks || null, + })); + } + + return result; } // ===== Frontend → API 변환 (수정용) ===== @@ -278,6 +321,17 @@ function transformEditFormToApi( if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate; if (data.priority !== undefined) result.priority = data.priority; if (data.deliveryMethod !== undefined) result.delivery_method = data.deliveryMethod; + if (data.receiver !== undefined) result.receiver = data.receiver; + if (data.receiverContact !== undefined) result.receiver_contact = data.receiverContact; + // 주소: zipCode + address + addressDetail → delivery_address로 결합 + if (data.address !== undefined || data.zipCode !== undefined || data.addressDetail !== undefined) { + const parts = [ + data.zipCode ? `[${data.zipCode}]` : '', + data.address || '', + data.addressDetail || '', + ].filter(Boolean); + result.delivery_address = parts.join(' '); + } if (data.loadingManager !== undefined) result.loading_manager = data.loadingManager; if (data.logisticsCompany !== undefined) result.logistics_company = data.logisticsCompany; if (data.vehicleTonnage !== undefined) result.vehicle_tonnage = data.vehicleTonnage; @@ -287,8 +341,21 @@ function transformEditFormToApi( if (data.driverContact !== undefined) result.driver_contact = data.driverContact; if (data.expectedArrival !== undefined) result.expected_arrival = data.expectedArrival; if (data.confirmedArrival !== undefined) result.confirmed_arrival = data.confirmedArrival; + if (data.changeReason !== undefined) result.change_reason = data.changeReason; if (data.remarks !== undefined) result.remarks = data.remarks; + if (data.vehicleDispatches) { + result.vehicle_dispatches = data.vehicleDispatches.map((vd, idx) => ({ + seq: idx + 1, + logistics_company: vd.logisticsCompany || null, + arrival_datetime: vd.arrivalDateTime || null, + tonnage: vd.tonnage || null, + vehicle_no: vd.vehicleNo || null, + driver_contact: vd.driverContact || null, + remarks: vd.remarks || null, + })); + } + return result; } 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 dbf87748..7c8737cc 100644 --- a/src/components/outbound/VehicleDispatchManagement/actions.ts +++ b/src/components/outbound/VehicleDispatchManagement/actions.ts @@ -1,8 +1,5 @@ /** * 배차차량관리 서버 액션 - * - * 현재: Mock 데이터 반환 - * 추후: API 연동 시 serverFetch 사용 */ 'use server'; @@ -13,11 +10,9 @@ import type { VehicleDispatchStats, VehicleDispatchEditFormData, } from './types'; -import { - mockVehicleDispatchItems, - mockVehicleDispatchDetail, - mockVehicleDispatchStats, -} from './mockData'; +import { buildApiUrl } from '@/lib/api/query-params'; +import { executeServerAction } from '@/lib/api/execute-server-action'; +import { executePaginatedAction } from '@/lib/api/execute-paginated-action'; // ===== 페이지네이션 타입 ===== interface PaginationMeta { @@ -27,6 +22,59 @@ interface PaginationMeta { total: number; } +// ===== API 응답 → 프론트 타입 변환 ===== +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function transformToListItem(data: any): VehicleDispatchItem { + const options = data.options || {}; + const shipment = data.shipment || {}; + return { + id: String(data.id), + dispatchNo: options.dispatch_no || `DC-${data.id}`, + shipmentNo: shipment.shipment_no || '', + lotNo: shipment.lot_no || '', + siteName: shipment.site_name || '', + orderCustomer: shipment.customer_name || '', + logisticsCompany: data.logistics_company || '', + tonnage: data.tonnage || '', + supplyAmount: options.supply_amount || 0, + vat: options.vat || 0, + totalAmount: options.total_amount || 0, + freightCostType: options.freight_cost_type || 'prepaid', + vehicleNo: data.vehicle_no || '', + driverContact: data.driver_contact || '', + writer: options.writer || '', + arrivalDateTime: data.arrival_datetime || '', + status: options.status || 'draft', + remarks: data.remarks || '', + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function transformToDetail(data: any): VehicleDispatchDetail { + const options = data.options || {}; + const shipment = data.shipment || {}; + return { + id: String(data.id), + dispatchNo: options.dispatch_no || `DC-${data.id}`, + shipmentNo: shipment.shipment_no || '', + lotNo: shipment.lot_no || '', + siteName: shipment.site_name || '', + orderCustomer: shipment.customer_name || '', + freightCostType: options.freight_cost_type || 'prepaid', + status: options.status || 'draft', + writer: options.writer || '', + logisticsCompany: data.logistics_company || '', + arrivalDateTime: data.arrival_datetime || '', + tonnage: data.tonnage || '', + vehicleNo: data.vehicle_no || '', + driverContact: data.driver_contact || '', + remarks: data.remarks || '', + supplyAmount: options.supply_amount || 0, + vat: options.vat || 0, + totalAmount: options.total_amount || 0, + }; +} + // ===== 배차차량 목록 조회 ===== export async function getVehicleDispatches(params?: { page?: number; @@ -41,54 +89,18 @@ export async function getVehicleDispatches(params?: { pagination: PaginationMeta; error?: string; }> { - try { - let items = [...mockVehicleDispatchItems]; - - // 상태 필터 - if (params?.status && params.status !== 'all') { - items = items.filter((item) => item.status === params.status); - } - - // 검색 필터 - if (params?.search) { - const s = params.search.toLowerCase(); - items = items.filter( - (item) => - item.dispatchNo.toLowerCase().includes(s) || - item.shipmentNo.toLowerCase().includes(s) || - item.siteName.toLowerCase().includes(s) || - item.orderCustomer.toLowerCase().includes(s) || - item.vehicleNo.toLowerCase().includes(s) - ); - } - - // 페이지네이션 - const page = params?.page || 1; - const perPage = params?.perPage || 20; - const total = items.length; - const lastPage = Math.ceil(total / perPage); - const startIndex = (page - 1) * perPage; - const paginatedItems = items.slice(startIndex, startIndex + perPage); - - return { - success: true, - data: paginatedItems, - pagination: { - currentPage: page, - lastPage, - perPage, - total, - }, - }; - } catch (error) { - console.error('[VehicleDispatchActions] getVehicleDispatches error:', error); - return { - success: false, - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - error: '서버 오류가 발생했습니다.', - }; - } + return executePaginatedAction({ + url: buildApiUrl('/api/v1/vehicle-dispatches', { + search: params?.search, + status: params?.status !== 'all' ? params?.status : undefined, + start_date: params?.startDate, + end_date: params?.endDate, + page: params?.page, + per_page: params?.perPage, + }), + transform: transformToListItem, + errorMessage: '배차차량 목록 조회에 실패했습니다.', + }); } // ===== 배차차량 통계 조회 ===== @@ -97,12 +109,18 @@ export async function getVehicleDispatchStats(): Promise<{ data?: VehicleDispatchStats; error?: string; }> { - try { - return { success: true, data: mockVehicleDispatchStats }; - } catch (error) { - console.error('[VehicleDispatchActions] getVehicleDispatchStats error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; - } + return executeServerAction< + { prepaid_amount: number; collect_amount: number; total_amount: number }, + VehicleDispatchStats + >({ + url: buildApiUrl('/api/v1/vehicle-dispatches/stats'), + transform: (data) => ({ + prepaidAmount: data.prepaid_amount, + collectAmount: data.collect_amount, + totalAmount: data.total_amount, + }), + errorMessage: '배차차량 통계 조회에 실패했습니다.', + }); } // ===== 배차차량 상세 조회 ===== @@ -111,51 +129,34 @@ export async function getVehicleDispatchById(id: string): Promise<{ data?: VehicleDispatchDetail; error?: string; }> { - try { - // Mock: ID로 목록에서 찾아서 상세 데이터 생성 - const item = mockVehicleDispatchItems.find((i) => i.id === id); - if (!item) { - // fallback으로 기본 상세 데이터 반환 - return { success: true, data: { ...mockVehicleDispatchDetail, id } }; - } - - const detail: VehicleDispatchDetail = { - id: item.id, - dispatchNo: item.dispatchNo, - shipmentNo: item.shipmentNo, - siteName: item.siteName, - orderCustomer: item.orderCustomer, - freightCostType: item.freightCostType, - status: item.status, - writer: item.writer, - logisticsCompany: item.logisticsCompany, - arrivalDateTime: item.arrivalDateTime, - tonnage: item.tonnage, - vehicleNo: item.vehicleNo, - driverContact: item.driverContact, - remarks: item.remarks, - supplyAmount: item.supplyAmount, - vat: item.vat, - totalAmount: item.totalAmount, - }; - - return { success: true, data: detail }; - } catch (error) { - console.error('[VehicleDispatchActions] getVehicleDispatchById error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; - } + return executeServerAction({ + url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`), + transform: transformToDetail, + errorMessage: '배차차량 상세 조회에 실패했습니다.', + }); } // ===== 배차차량 수정 ===== export async function updateVehicleDispatch( id: string, - _data: VehicleDispatchEditFormData + data: VehicleDispatchEditFormData ): Promise<{ success: boolean; error?: string }> { - try { - // Mock: 항상 성공 반환 - return { success: true }; - } catch (error) { - console.error('[VehicleDispatchActions] updateVehicleDispatch error:', error); - return { success: false, error: '서버 오류가 발생했습니다.' }; - } + return executeServerAction({ + url: buildApiUrl(`/api/v1/vehicle-dispatches/${id}`), + method: 'PUT', + body: { + freight_cost_type: data.freightCostType, + logistics_company: data.logisticsCompany, + arrival_datetime: data.arrivalDateTime, + tonnage: data.tonnage, + vehicle_no: data.vehicleNo, + driver_contact: data.driverContact, + remarks: data.remarks, + supply_amount: data.supplyAmount, + vat: data.vat, + total_amount: data.totalAmount, + status: undefined, // 상태는 별도로 관리 + }, + errorMessage: '배차차량 수정에 실패했습니다.', + }); }