diff --git a/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx b/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx index f7139955..10e637b1 100644 --- a/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx +++ b/src/app/[locale]/(protected)/dev/bill-prototype/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useCallback, useMemo } from 'react'; -import { Plus, Trash2, AlertTriangle, Info, ChevronDown, ChevronUp } from 'lucide-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'; @@ -12,22 +12,16 @@ 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, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; -// ===== 증권 종류 ===== +// ============================================= +// 옵션 정의 +// ============================================= + const INSTRUMENT_TYPE_OPTIONS = [ { value: 'promissory', label: '약속어음' }, { value: 'exchange', label: '환어음' }, @@ -35,25 +29,28 @@ const INSTRUMENT_TYPE_OPTIONS = [ { value: 'currentCheck', label: '당좌수표' }, ]; -// ===== 거래 방향 ===== -const BILL_DIRECTION_OPTIONS = [ +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: '배서양도' }, @@ -62,31 +59,48 @@ const RECEIVED_STATUS_OPTIONS = [ { 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: 'collectionRequest', label: '추심의뢰' }, - { value: 'collectionComplete', label: '추심완료' }, - { value: 'suing', label: '추소중' }, + { value: 'paid', label: '결제완료' }, + { value: 'renewed', label: '개서 (만기연장)' }, { value: 'dishonored', label: '부도' }, ]; -// ===== 차수 처리구분 ===== -const INSTALLMENT_TYPE_OPTIONS = [ - { value: 'endorsement', label: '배서양도' }, - { value: 'collection', label: '추심' }, - { value: 'discount', label: '할인' }, - { value: 'payment', label: '결제' }, - { value: 'split', 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호 부도)' }, @@ -96,7 +110,92 @@ const DISHONOR_REASON_OPTIONS = [ { 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; @@ -106,212 +205,233 @@ interface InstallmentRecord { note: string; } -// ===== 폼 데이터 ===== interface BillFormData { - // 기본 정보 + // === 공통 === billNumber: string; instrumentType: string; - direction: string; - medium: string; - endorsement: string; - vendorId: string; + direction: string; // received | issued + medium: string; // electronic | paper amount: number; issueDate: string; maturityDate: string; - status: string; note: string; - issuerBank: string; - paymentPlace: string; - bankAccountInfo: string; - // 전자어음 추가 (조건: medium = electronic) + // === 전자어음 (조건: medium=electronic) === electronicBillNo: string; registrationOrg: string; - // 환어음 추가 (조건: instrumentType = exchange) + // === 환어음 (조건: instrumentType=exchange) === drawee: string; acceptanceStatus: string; acceptanceDate: string; - // 할인 정보 (조건: status = discounted) + // === 받을어음 전용 === + 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; - netReceivedAmount: number; - // 배서양도 정보 (조건: status = endorsed) + // 배서양도 (조건: receivedStatus=endorsed) endorsementDate: string; endorsee: string; endorsementReason: string; - // 추심 정보 (조건: status = collected/collectionRequest) + // 추심 (조건: 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; - // 부도 정보 (조건: status = dishonored) 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', - endorsement: 'endorsable', - vendorId: '', - amount: 0, - issueDate: '', - maturityDate: '', - status: 'stored', - note: '', - issuerBank: '', - paymentPlace: '', - bankAccountInfo: '', - electronicBillNo: '', - registrationOrg: '', - drawee: '', - acceptanceStatus: '', - acceptanceDate: '', - discountDate: '', - discountBank: '', - discountRate: 0, - discountAmount: 0, - netReceivedAmount: 0, - endorsementDate: '', - endorsee: '', - endorsementReason: '', - collectionBank: '', - collectionRequestDate: '', - collectionFee: 0, - isSplit: false, - splitCount: 0, - splitAmount: 0, - dishonoredDate: '', - dishonoredReason: '', - installments: [], + 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: '', }; -// ===== NEW 뱃지 ===== +// ============================================= +// 뱃지 컴포넌트 +// ============================================= + function NewBadge() { - return ( - - NEW - - ); + return NEW; +} +function CondBadge({ label }: { label: string }) { + return {label}; +} +function RecvBadge() { + return 받을어음; +} +function IssuBadge() { + return 지급어음; } -// ===== 조건부 뱃지 ===== -function CondBadge({ label }: { label: string }) { - return ( - - {label} - - ); -} +// ============================================= +// 메인 컴포넌트 +// ============================================= export default function BillPrototypePage() { const [formData, setFormData] = useState(INITIAL_FORM); - const updateField = useCallback(( - field: K, - value: BillFormData[K] - ) => { + const updateField = useCallback((field: K, value: BillFormData[K]) => { setFormData(prev => ({ ...prev, [field]: value })); }, []); - // 상태 옵션 (방향에 따라) - const statusOptions = formData.direction === 'received' - ? RECEIVED_STATUS_OPTIONS - : ISSUED_STATUS_OPTIONS; + const 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 = formData.status === 'discounted'; - const showEndorsement = formData.status === 'endorsed'; - const showCollection = ['collected', 'collectionRequest', 'collectionComplete'].includes(formData.status); - const showDishonored = formData.status === 'dishonored'; + 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; - } + if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount; return 0; }, [formData.amount, formData.discountAmount]); - // 분할 합계 - const splitTotal = formData.splitCount * formData.splitAmount; + // 전자어음 여부 + const 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: '', - }], + 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), - })); + setFormData(prev => ({ ...prev, installments: prev.installments.filter(inst => inst.id !== id) })); }, []); - const handleUpdateInstallment = useCallback(( - id: string, - field: keyof InstallmentRecord, - value: string | number - ) => { + 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 - ), + installments: prev.installments.map(inst => inst.id === id ? { ...inst, [field]: value } : inst), })); }, []); return ( - {/* 페이지 헤더 */} + {/* 헤더 */}
-

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

-

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

+

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

+

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

- {/* 안내 배너 */} + {/* 안내 */}
- 이 페이지는 프로토타입입니다. 실제 데이터 저장되지 않습니다. + 프로토타입입니다. 실제 저장되지 않습니다.
-
- - NEW - 신규 필드 - - - 조건부 - 특정 조건에서만 표시 - +
+ 신규 필드 + 특정 조건 시 표시 + 받을어음 전용 + 지급어음 전용
- {/* ===== 기본 정보 ===== */} + {/* ===== 1. 공통 기본 정보 ===== */} 기본 정보 @@ -321,22 +441,35 @@ export default function BillPrototypePage() { {/* 어음번호 */}
- updateField('billNumber', e.target.value)} - placeholder="자동생성 또는 직접입력" - /> + updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" />
{/* 증권종류 */}
- { + updateField('instrumentType', v); + const isCheckType = v === 'cashierCheck' || v === 'currentCheck'; + // 약속어음 외에는 전자 불가 → 지류로 리셋 + if (v !== 'promissory' && formData.medium === 'electronic') { + updateField('medium', 'paper'); + } + // 수표 전환 시: 만기일, 할인, 개서 관련 필드 리셋 + if (isCheckType) { + updateField('maturityDate', ''); + updateField('isDiscounted', false); + // 수표에 없는 상태가 선택되어 있으면 리셋 + const validCheckRecvStatuses = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored']; + const validCheckIssuStatuses = ['stored', 'paid', 'dishonored']; + if (!validCheckRecvStatuses.includes(formData.receivedStatus)) updateField('receivedStatus', 'stored'); + if (!validCheckIssuStatuses.includes(formData.issuedStatus)) updateField('issuedStatus', 'stored'); + // 수표 지급장소: 주소지 선택되어 있으면 리셋 + if (formData.paymentPlace === 'payerAddress' || formData.paymentPlace === 'other') updateField('paymentPlace', ''); + } + }}> - {INSTRUMENT_TYPE_OPTIONS.map((opt) => ( - {opt.label} - ))} + {INSTRUMENT_TYPE_OPTIONS.map(o => {o.label})}
@@ -344,49 +477,42 @@ export default function BillPrototypePage() { {/* 거래방향 */}
- { + updateField('direction', v); + updateField('receivedStatus', 'stored'); + updateField('issuedStatus', 'stored'); + }}> - {BILL_DIRECTION_OPTIONS.map((opt) => ( - {opt.label} - ))} + {DIRECTION_OPTIONS.map(o => {o.label})}
{/* 전자/지류 */}
- - { + updateField('medium', v); + }} disabled={!canBeElectronic}> - {MEDIUM_OPTIONS.map((opt) => ( - {opt.label} - ))} + {MEDIUM_OPTIONS.map(o => {o.label})}
- {/* 배서 여부 */} + {/* 거래처 - 라벨 분기 */}
- - -
- - {/* 거래처 */} -
- - updateField(isReceived ? 'vendor' : 'payee', v)} + > 삼성전자 @@ -399,55 +525,168 @@ export default function BillPrototypePage() { {/* 금액 */}
- updateField('amount', value ?? 0)} /> + updateField('amount', v ?? 0)} />
{/* 발행일 */}
- updateField('issueDate', date)} /> + updateField('issueDate', d)} />
- {/* 만기일 */} -
- - updateField('maturityDate', date)} /> -
+ {/* 만기일 (수표는 일람출급이므로 만기일 없음 - 수표법 제28조) */} + {isBill && ( +
+ + updateField('maturityDate', d)} /> +
+ )} - {/* 발행은행 */} + {/* 은행 - 라벨 분기 */}
- + updateField('issuerBank', e.target.value)} - placeholder="예: 국민은행" + value={isReceived ? formData.issuerBank : formData.settlementBank} + onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)} + placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'} />
- {/* 지급지 */} + {/* 지급장소 (어음법 제75조 / 수표법 제2조 필수 기재사항) */}
- - updateField('paymentPlace', e.target.value)} - placeholder="예: 국민은행 강남지점" - /> -
- - {/* 상태 */} -
- - updateField('paymentPlace', v)}> + - {statusOptions.map((opt) => ( - {opt.label} - ))} + {paymentPlaceOptions.map(o => {o.label})}
- {/* 입금/출금 계좌 */} + {/* 지급장소 상세 (기타 선택 시) */} + {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="비고를 입력해주세요" - /> + updateField('note', e.target.value)} placeholder="비고를 입력해주세요" />
- {/* ===== 전자어음 추가 정보 (조건: 전자/지류 = 전자) ===== */} + {/* ===== 2. 전자어음 정보 (조건: 전자) ===== */} {showElectronic && ( - - 전자어음 정보 - - + 전자어음 정보
- updateField('electronicBillNo', e.target.value)} - placeholder="전자어음시스템 발급번호" - /> + updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" />
@@ -506,107 +734,94 @@ export default function BillPrototypePage() { )} - {/* ===== 환어음 추가 정보 (조건: 증권종류 = 환어음) ===== */} + {/* ===== 3. 환어음 정보 (조건: 환어음) ===== */} {showExchangeBill && ( - - 환어음 정보 - - + 환어음 정보 -
-
- - updateField('drawee', e.target.value)} - placeholder="지급 의무자" - /> -
-
- - -
-
- - updateField('acceptanceDate', date)} - /> +
+
+
+ + updateField('drawee', e.target.value)} placeholder="지급 의무자" /> +
+
+ + +
+
+ + updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)} /> +
+ {/* 인수거절 시 추가 필드 */} + {showAcceptanceRefusal && ( +
+
+ + 인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다. +
+
+
+ + +
+
+
+ )}
)} - {/* ===== 할인 정보 (조건: 상태 = 할인) ===== */} + {/* ===== 4. 할인 정보 (받을어음 + 할인여부 ON) ===== */} {showDiscount && ( - - 할인 정보 - - + 할인 정보
- - updateField('discountDate', date)} - /> + + updateField('discountDate', d)} />
- - updateField('discountBank', e.target.value)} - placeholder="예: 국민은행 강남지점" - /> + + 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" - /> + + { + const rate = parseFloat(e.target.value) || 0; + updateField('discountRate', rate); + if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100)); + }} placeholder="예: 3.5" />
- - updateField('discountAmount', value ?? 0)} - /> + + updateField('discountAmount', v ?? 0)} />
{calcNetReceived > 0 ? ₩ {calcNetReceived.toLocaleString()} - : 어음금액 - 할인금액 - } + : 어음금액 - 할인금액}
@@ -614,34 +829,24 @@ export default function BillPrototypePage() {
)} - {/* ===== 배서양도 정보 (조건: 상태 = 배서양도) ===== */} + {/* ===== 5. 배서양도 정보 (받을어음 + 결제상태=배서양도) ===== */} {showEndorsement && ( - - 배서양도 정보 - - + 배서양도 정보
- - updateField('endorsementDate', date)} - /> + + updateField('endorsementDate', d)} />
- - updateField('endorsee', e.target.value)} - placeholder="어음을 넘겨받는 자" - /> + + updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" />
- + updateField('collectionBank', e.target.value)} - placeholder="추심 의뢰 은행" - /> +
+ {/* 추심 의뢰 */} +

추심 의뢰

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

추심 결과

+
+
+ + +
+
+ + updateField('collectionCompleteDate', d)} /> +
+
+ + updateField('collectionDepositDate', d)} /> +
+
+ + updateField('collectionDepositAmount', v ?? 0)} /> +
+
)} - {/* ===== 분할 정보 ===== */} - - - - 분할 정보 - - - -
-
- { - updateField('isSplit', checked); - if (!checked) { updateField('splitCount', 0); updateField('splitAmount', 0); } - }} - /> - -
- {formData.isSplit && ( -
-
- - updateField('splitCount', parseInt(e.target.value) || 0)} - placeholder="장수 입력" + {/* ===== 7. 이력 관리 (받을어음 전용) ===== */} + {isReceived && ( + + + 이력 관리 + + + +
+ {/* 분할배서 토글 + 요약 */} +
+
+ updateField('isSplit', c)} /> + + {formData.isSplit && ( + + 최대 {maxSplitCount}회 + + )}
-
- - updateField('splitAmount', value ?? 0)} /> -
-
- -
- {splitTotal > 0 ? `₩ ${splitTotal.toLocaleString()}` : '-'} - {splitTotal > 0 && formData.amount > 0 && splitTotal !== formData.amount && ( - - - 어음 금액과 불일치 - + {formData.isSplit && isElectronic && ( +
+ + 전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조) +
+ )} + {/* 분할배서 잔액 요약 */} + {formData.isSplit && splitEndorsementStats.count > 0 && ( +
+ 원금액: + ₩ {formData.amount.toLocaleString()} + | 분할배서 합계: + ₩ {splitEndorsementStats.totalAmount.toLocaleString()} + | 잔액: + + ₩ {splitEndorsementStats.remaining.toLocaleString()} + + {splitEndorsementStats.remaining < 0 && ( + 금액 초과 )}
-
+ )}
- )} -
- - - {/* ===== 부도 정보 (조건: 상태 = 부도) ===== */} - {showDishonored && ( - + {/* 이력 테이블 */} +
+ + + + 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('dishonoredDate', date)} - /> + + updateField('renewalDate', d)} />
- - updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" /> +
+
+ +
@@ -782,119 +1055,185 @@ export default function BillPrototypePage() { )} - {/* ===== 차수 관리 ===== */} - - - - 차수 관리확장 - - - - -
- - - - No - 일자 - 처리구분 - 금액 - 상대처 - 비고 - 삭제 - - - - {formData.installments.length === 0 ? ( - - - 등록된 차수가 없습니다 - - - ) : ( - formData.installments.map((inst, index) => ( - - {index + 1} - - handleUpdateInstallment(inst.id, 'date', date)} size="sm" /> - - - - - - handleUpdateInstallment(inst.id, 'amount', value ?? 0)} - className="h-8 text-sm" - /> - - - handleUpdateInstallment(inst.id, 'counterparty', e.target.value)} - placeholder="양수인/추심처" - className="h-8 text-sm" - /> - - - handleUpdateInstallment(inst.id, 'note', e.target.value)} - className="h-8 text-sm" - /> - - - - - - )) - )} - -
-
-
-
+ {/* ===== 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 ? 표시중 : 숨김} @@ -902,38 +1241,68 @@ export default function BillPrototypePage() { 증권종류 = 환어음 환어음 정보 - 지급인(drawee), 인수여부, 인수일자 + 지급인, 인수여부, 인수일자 {showExchangeBill ? 표시중 : 숨김} - 상태 = 할인 + 할인여부 ON (받을어음) 할인 정보 할인일자, 할인처, 할인율, 할인금액, 실수령액(자동) {showDiscount ? 표시중 : 숨김} - 상태 = 배서양도 + 결제상태 = 배서양도 배서양도 정보 - 배서일자, 피배서인(양수인), 배서사유 + 배서일자, 피배서인, 배서사유 {showEndorsement ? 표시중 : 숨김} - 상태 = 추심/추심의뢰 + 결제상태 = 추심 추심 정보 - 추심은행, 추심의뢰일, 추심수수료 + 추심의뢰(은행/의뢰일/수수료) + 추심결과(결과/완료일/입금일/입금액) {showCollection ? 표시중 : 숨김} + + 결제상태 = 소구 (받을어음) + 소구(상환) 정보 + 소구일자, 소구금액, 소구대상, 소구사유 + {showRecourse ? 표시중 : 숨김} + + + 결제상태 = 환매 (받을어음) + 환매 정보 + 환매일자, 환매금액, 환매요청 은행 + {showBuyback ? 표시중 : 숨김} + + + 상태 = 개서 (공통) + 개서 정보 + 개서일자, 신어음번호, 개서사유 + {showRenewal ? 표시중 : 숨김} + - 상태 = 부도 - 부도 정보 - 부도일자, 부도사유 (1호/2호/형식불비 등) + 상태 = 부도 (공통) + 부도 정보 + 법적 프로세스 + 부도일자, 부도사유, 거절증서 작성, 소구통지일, 통지기한(자동) {showDishonored ? 표시중 : 숨김} - - 분할 토글 ON - 분할 상세 - 장수, 장당금액, 합계(자동/불일치 경고) - {formData.isSplit ? 표시중 : 숨김} + + 환어음 인수 = 거절 + 인수거절 상세 + 인수거절일, 인수거절사유, 소구권 안내 + {showAcceptanceRefusal ? 표시중 : 숨김} + + + 분할배서 토글 ON (받을어음) + 분할배서 허용 + 이력에서 "분할배서" 처리구분 선택 가능 + 잔액 자동계산 — 전자: 최대4회, 지류: 실무10회 + {formData.isSplit && isReceived ? 표시중 : 숨김} + + + 지급장소 = 기타 + 지급장소 상세 + 지급장소 직접 입력 필드 + {formData.paymentPlace === 'other' ? 표시중 : 숨김}
@@ -944,9 +1313,7 @@ export default function BillPrototypePage() { {/* 하단 버튼 */}
-
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/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/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/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 40fd0109..bfd12c2a 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, type RefCallback } from 'react'; import { useRouter } from 'next/navigation'; import { LayoutDashboard, Settings } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -34,6 +34,8 @@ import { ScheduleDetailModal, DetailModal } from './modals'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; import { LazySection } from './LazySection'; import { EmptySection } from './components'; +import { SummaryNavBar } from './SummaryNavBar'; +import { useSectionSummary } from './useSectionSummary'; import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; import { useCardManagementModals } from '@/hooks/useCardManagementModals'; import { @@ -547,6 +549,26 @@ export function CEODashboard() { // 섹션 순서 const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER; + // 요약 네비게이션 바 훅 + const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({ + data, + sectionOrder, + dashboardSettings, + }); + + // 섹션 ref 수집 콜백 + const setSectionRef = useCallback( + (key: SectionKey): RefCallback => + (el) => { + if (el) { + sectionRefs.current.set(key, el); + } else { + sectionRefs.current.delete(key); + } + }, + [sectionRefs], + ); + // 섹션 렌더링 함수 const renderDashboardSection = (key: SectionKey): React.ReactNode => { switch (key) { @@ -761,8 +783,22 @@ export function CEODashboard() { } /> + +
- {sectionOrder.map(renderDashboardSection)} + {sectionOrder.map((key) => { + const node = renderDashboardSection(key); + if (!node) return null; + return ( +
+ {node} +
+ ); + })}
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */} diff --git a/src/components/business/CEODashboard/SummaryNavBar.tsx b/src/components/business/CEODashboard/SummaryNavBar.tsx new file mode 100644 index 00000000..cd9b70d7 --- /dev/null +++ b/src/components/business/CEODashboard/SummaryNavBar.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useRef, useEffect, useCallback, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SectionSummary, SummaryStatus } from './useSectionSummary'; +import type { SectionKey } from './types'; + +/** 상태별 점(dot) 색상 */ +const STATUS_DOT: Record = { + normal: 'bg-green-500', + warning: 'bg-yellow-500', + danger: 'bg-red-500', +}; + +/** 상태별 칩 배경색 (비활성) */ +const STATUS_BG: Record = { + normal: 'bg-background border-border', + warning: 'bg-yellow-50 border-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-700', + danger: 'bg-red-50 border-red-300 dark:bg-red-950/30 dark:border-red-700', +}; + +/** 상태별 칩 배경색 (활성) */ +const STATUS_BG_ACTIVE: Record = { + normal: 'bg-accent border-primary/40', + warning: 'bg-yellow-100 border-yellow-400 dark:bg-yellow-900/40 dark:border-yellow-600', + danger: 'bg-red-100 border-red-400 dark:bg-red-900/40 dark:border-red-600', +}; + +interface SummaryChipProps { + summary: SectionSummary; + isActive: boolean; + onClick: () => void; +} + +function SummaryChip({ summary, isActive, onClick }: SummaryChipProps) { + return ( + + ); +} + +const HEADER_BOTTOM = 100; // 헤더 하단 고정 위치 (px) +const BAR_HEIGHT = 56; // 요약바 높이 (px) — 고령 친화 확대 +const SCROLL_STEP = 200; // 화살표 버튼 클릭 시 스크롤 이동량 (px) + +interface SummaryNavBarProps { + summaries: SectionSummary[]; + activeSectionKey: SectionKey | null; + onChipClick: (key: SectionKey) => void; +} + +export function SummaryNavBar({ summaries, activeSectionKey, onChipClick }: SummaryNavBarProps) { + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + const [isFixed, setIsFixed] = useState(false); + const [barRect, setBarRect] = useState({ left: 0, width: 0 }); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + // 스크롤 가능 여부 체크 + const updateScrollButtons = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 4); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); + }, []); + + // sentinel 위치 감시: sentinel이 헤더 뒤로 지나가면 fixed 모드 + useEffect(() => { + const handleScroll = () => { + if (!sentinelRef.current) return; + const rect = sentinelRef.current.getBoundingClientRect(); + const shouldFix = rect.top < HEADER_BOTTOM; + setIsFixed(shouldFix); + + if (shouldFix) { + const main = document.querySelector('main'); + if (main) { + const mainRect = main.getBoundingClientRect(); + const mainStyle = getComputedStyle(main); + const pl = parseFloat(mainStyle.paddingLeft) || 0; + const pr = parseFloat(mainStyle.paddingRight) || 0; + setBarRect({ + left: mainRect.left + pl, + width: mainRect.width - pl - pr, + }); + } + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleScroll, { passive: true }); + handleScroll(); + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleScroll); + }; + }, []); + + // 칩 영역 스크롤 상태 감시 + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + updateScrollButtons(); + el.addEventListener('scroll', updateScrollButtons, { passive: true }); + const ro = new ResizeObserver(updateScrollButtons); + ro.observe(el); + return () => { + el.removeEventListener('scroll', updateScrollButtons); + ro.disconnect(); + }; + }, [updateScrollButtons, summaries]); + + // 활성 칩 자동 스크롤 into view + useEffect(() => { + if (!activeSectionKey || !scrollRef.current) return; + const chipEl = scrollRef.current.querySelector(`[data-chip-key="${activeSectionKey}"]`) as HTMLElement | null; + if (!chipEl) return; + + const container = scrollRef.current; + const chipLeft = chipEl.offsetLeft; + const chipWidth = chipEl.offsetWidth; + const containerWidth = container.offsetWidth; + const scrollLeft = container.scrollLeft; + + if (chipLeft < scrollLeft + 50 || chipLeft + chipWidth > scrollLeft + containerWidth - 50) { + container.scrollTo({ + left: chipLeft - containerWidth / 2 + chipWidth / 2, + behavior: 'smooth', + }); + } + }, [activeSectionKey]); + + const handleChipClick = useCallback( + (key: SectionKey) => { + onChipClick(key); + }, + [onChipClick], + ); + + // 화살표 버튼 핸들러 + const scrollBy = useCallback((direction: 'left' | 'right') => { + const el = scrollRef.current; + if (!el) return; + el.scrollBy({ + left: direction === 'left' ? -SCROLL_STEP : SCROLL_STEP, + behavior: 'smooth', + }); + }, []); + + if (summaries.length === 0) return null; + + const arrowBtnClass = cn( + 'flex items-center justify-center w-8 h-8 rounded-full shrink-0', + 'bg-muted/80 hover:bg-muted text-foreground', + 'border border-border shadow-sm', + 'transition-opacity duration-150', + ); + + const barContent = ( +
+ {/* 좌측 화살표 */} + + + {/* 칩 목록 */} +
+ {summaries.map((s) => ( +
+ handleChipClick(s.key)} + /> +
+ ))} +
+ + {/* 우측 화살표 */} + +
+ ); + + return ( + <> + {/* sentinel: 이 div가 헤더 뒤로 사라지면 fixed 모드 활성화 */} +
+ + {/* fixed일 때 레이아웃 공간 유지용 spacer */} + {isFixed &&
} + + {/* 실제 바 */} +
+ {barContent} +
+ + ); +} diff --git a/src/components/business/CEODashboard/useSectionSummary.ts b/src/components/business/CEODashboard/useSectionSummary.ts new file mode 100644 index 00000000..bfe8f1a2 --- /dev/null +++ b/src/components/business/CEODashboard/useSectionSummary.ts @@ -0,0 +1,293 @@ +'use client'; + +import { useMemo, useEffect, useState, useRef, useCallback } from 'react'; +import type { CEODashboardData, DashboardSettings, SectionKey } from './types'; +import { SECTION_LABELS } from './types'; + +export type SummaryStatus = 'normal' | 'warning' | 'danger'; + +export interface SectionSummary { + key: SectionKey; + label: string; + value: string; + status: SummaryStatus; +} + +/** 숫자를 간략하게 포맷 (억/만) — 칩 표시용 (간결 + 반올림) */ +function formatCompact(n: number): string { + if (n === 0) return '0원'; + const abs = Math.abs(n); + const sign = n < 0 ? '-' : ''; + if (abs >= 100_000_000) { + const v = Math.round(abs / 100_000_000 * 10) / 10; + return `${sign}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}억`; + } + if (abs >= 10_000) { + const v = Math.round(abs / 10_000); + return `${sign}${v.toLocaleString()}만`; + } + if (abs > 0) return `${sign}${abs.toLocaleString()}원`; + return '0원'; +} + +/** 카드 배열에서 합계 카드(마지막) 금액 추출 — "합계" 라벨이 있으면 그것, 없으면 첫 번째 */ +function getTotalCardAmount(cards?: { label: string; amount: number }[]): number { + if (!cards?.length) return 0; + const totalCard = cards.find((c) => c.label.includes('합계')); + return totalCard ? totalCard.amount : cards[0].amount; +} + +/** 체크포인트 배열에서 가장 심각한 상태 추출 */ +function checkPointStatus( + checkPoints?: { type: string }[], +): SummaryStatus { + if (!checkPoints?.length) return 'normal'; + if (checkPoints.some((c) => c.type === 'error')) return 'danger'; + if (checkPoints.some((c) => c.type === 'warning')) return 'warning'; + return 'normal'; +} + +/** 섹션 활성화 여부 확인 */ +function isSectionEnabled(key: SectionKey, settings: DashboardSettings): boolean { + switch (key) { + case 'todayIssueList': return !!settings.todayIssueList; + case 'dailyReport': return !!settings.dailyReport; + case 'statusBoard': return !!(settings.statusBoard?.enabled ?? settings.todayIssue.enabled); + case 'monthlyExpense': return !!settings.monthlyExpense; + case 'cardManagement': return !!settings.cardManagement; + case 'entertainment': return !!settings.entertainment.enabled; + case 'welfare': return !!settings.welfare.enabled; + case 'receivable': return !!settings.receivable; + case 'debtCollection': return !!settings.debtCollection; + case 'vat': return !!settings.vat; + case 'calendar': return !!settings.calendar; + case 'salesStatus': return !!(settings.salesStatus ?? true); + case 'purchaseStatus': return !!(settings.purchaseStatus ?? true); + case 'production': return !!(settings.production ?? true); + case 'shipment': return !!(settings.shipment ?? true); + case 'unshipped': return !!(settings.unshipped ?? true); + case 'construction': return !!(settings.construction ?? true); + case 'attendance': return !!(settings.attendance ?? true); + default: return false; + } +} + +/** 섹션별 요약값 + 상태 추출 */ +function extractSummary( + key: SectionKey, + data: CEODashboardData, +): { value: string; status: SummaryStatus } { + switch (key) { + case 'todayIssueList': { + const count = data.todayIssueList?.length ?? 0; + return { value: `${count}건`, status: count > 0 ? 'warning' : 'normal' }; + } + case 'dailyReport': { + const firstCard = data.dailyReport?.cards?.[0]; + return { + value: firstCard ? formatCompact(firstCard.amount) : '-', + status: 'normal', + }; + } + case 'statusBoard': { + const count = data.todayIssue?.length ?? 0; + const hasHighlight = data.todayIssue?.some((i) => i.isHighlighted); + return { + value: `${count}항목`, + status: hasHighlight ? 'danger' : 'normal', + }; + } + case 'monthlyExpense': { + const total = getTotalCardAmount(data.monthlyExpense?.cards); + return { + value: formatCompact(total), + status: checkPointStatus(data.monthlyExpense?.checkPoints), + }; + } + case 'cardManagement': { + const total = getTotalCardAmount(data.cardManagement?.cards); + const hasHighlight = data.cardManagement?.cards?.some((c) => c.isHighlighted); + const hasWarning = !!data.cardManagement?.warningBanner; + return { + value: formatCompact(total), + status: hasHighlight ? 'danger' : hasWarning ? 'warning' : 'normal', + }; + } + case 'entertainment': { + const total = data.entertainment?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0; + return { + value: formatCompact(total), + status: checkPointStatus(data.entertainment?.checkPoints), + }; + } + case 'welfare': { + const total = data.welfare?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0; + return { + value: formatCompact(total), + status: checkPointStatus(data.welfare?.checkPoints), + }; + } + case 'receivable': { + // 누적 미수금 = 첫 번째 카드 + const first = data.receivable?.cards?.[0]; + return { + value: first ? formatCompact(first.amount) : '-', + status: checkPointStatus(data.receivable?.checkPoints), + }; + } + case 'debtCollection': { + const first = data.debtCollection?.cards?.[0]; + return { + value: first ? formatCompact(first.amount) : '-', + status: checkPointStatus(data.debtCollection?.checkPoints), + }; + } + case 'vat': { + const first = data.vat?.cards?.[0]; + return { + value: first ? formatCompact(first.amount) : '-', + status: 'normal', + }; + } + case 'calendar': { + const count = data.calendarSchedules?.length ?? 0; + return { value: `${count}일정`, status: 'normal' }; + } + case 'salesStatus': { + return { + value: formatCompact(data.salesStatus?.cumulativeSales ?? 0), + status: 'normal', + }; + } + case 'purchaseStatus': { + return { + value: formatCompact(data.purchaseStatus?.cumulativePurchase ?? 0), + status: 'normal', + }; + } + case 'production': { + const count = data.dailyProduction?.processes?.length ?? 0; + return { value: `${count}공정`, status: 'normal' }; + } + case 'shipment': { + const count = data.dailyProduction?.shipment?.actualCount ?? 0; + return { value: `${count}건`, status: 'normal' }; + } + case 'unshipped': { + const count = data.unshipped?.items?.length ?? 0; + return { + value: `${count}건`, + status: count > 0 ? 'danger' : 'normal', + }; + } + case 'construction': { + return { + value: `${data.constructionData?.thisMonth ?? 0}건`, + status: 'normal', + }; + } + case 'attendance': { + return { + value: `${data.dailyAttendance?.present ?? 0}명`, + status: 'normal', + }; + } + default: + return { value: '-', status: 'normal' }; + } +} + +interface UseSectionSummaryParams { + data: CEODashboardData; + sectionOrder: SectionKey[]; + dashboardSettings: DashboardSettings; +} + +interface UseSectionSummaryReturn { + summaries: SectionSummary[]; + activeSectionKey: SectionKey | null; + sectionRefs: React.MutableRefObject>; + scrollToSection: (key: SectionKey) => void; +} + +export function useSectionSummary({ + data, + sectionOrder, + dashboardSettings, +}: UseSectionSummaryParams): UseSectionSummaryReturn { + const sectionRefs = useRef>(new Map()); + const [activeSectionKey, setActiveSectionKey] = useState(null); + // 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지 + const pinnedKey = useRef(null); + + // 활성화된 섹션만 필터 + const enabledSections = useMemo( + () => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)), + [sectionOrder, dashboardSettings], + ); + + // 요약 데이터 계산 + const summaries = useMemo( + () => + enabledSections.map((key) => { + const { value, status } = extractSummary(key, data); + return { key, label: SECTION_LABELS[key], value, status }; + }), + [enabledSections, data], + ); + + // 스크롤 기반 현재 섹션 감지 + useEffect(() => { + const handleScroll = () => { + // pin이 걸려 있으면 스크롤 감지 무시 (칩 클릭 후 programmatic scroll 중) + if (pinnedKey.current) return; + + const headerBottom = 156; // 헤더(~100px) + 요약바(~56px) + let bestKey: SectionKey | null = null; + let bestDistance = Infinity; + + for (const [key, el] of sectionRefs.current.entries()) { + const rect = el.getBoundingClientRect(); + const distance = Math.abs(rect.top - headerBottom); + if (rect.top < window.innerHeight * 0.6 && rect.bottom > headerBottom) { + if (distance < bestDistance) { + bestDistance = distance; + bestKey = key; + } + } + } + if (bestKey) { + setActiveSectionKey(bestKey); + } + }; + + // 사용자가 직접 스크롤(마우스 휠/터치)하면 pin 해제 + const handleUserScroll = () => { pinnedKey.current = null; }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('wheel', handleUserScroll, { passive: true }); + window.addEventListener('touchstart', handleUserScroll, { passive: true }); + handleScroll(); // 초기 호출 + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('wheel', handleUserScroll); + window.removeEventListener('touchstart', handleUserScroll); + }; + }, [enabledSections, summaries]); + + // 칩 클릭 → 즉시 활성 표시 + 섹션으로 스크롤 + const scrollToSection = useCallback((key: SectionKey) => { + setActiveSectionKey(key); + pinnedKey.current = key; // 해당 섹션이 화면에 보이는 한 유지 + + const el = sectionRefs.current.get(key); + if (!el) return; + + const elRect = el.getBoundingClientRect(); + const offset = window.scrollY + elRect.top - 160; // 헤더(~100) + 요약바(~56) + 여유 + + window.scrollTo({ top: offset, behavior: 'smooth' }); + }, []); + + return { summaries, activeSectionKey, sectionRefs, scrollToSection }; +}