feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선 - Sidebar/AuthenticatedLayout 소폭 수정 - ShipmentCreate, VehicleDispatch 출하 관련 개선 - WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선 - InspectionCreate 자재 입고검사 개선 - DailyReport, VendorDetail 회계 수정 - CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선 - useCEODashboard, expense transformer 정비 - DocumentViewer, PDF generate route 소폭 수정 - bill-prototype 개발 페이지 추가 - mockData 불필요 데이터 제거
This commit is contained in:
955
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
955
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
@@ -0,0 +1,955 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Plus, Trash2, AlertTriangle, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
// ===== 증권 종류 =====
|
||||
const INSTRUMENT_TYPE_OPTIONS = [
|
||||
{ value: 'promissory', label: '약속어음' },
|
||||
{ value: 'exchange', label: '환어음' },
|
||||
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
|
||||
{ value: 'currentCheck', label: '당좌수표' },
|
||||
];
|
||||
|
||||
// ===== 거래 방향 =====
|
||||
const BILL_DIRECTION_OPTIONS = [
|
||||
{ value: 'received', label: '수취 (받을어음)' },
|
||||
{ value: 'issued', label: '발행 (지급어음)' },
|
||||
];
|
||||
|
||||
// ===== 전자/지류 =====
|
||||
const MEDIUM_OPTIONS = [
|
||||
{ value: 'electronic', label: '전자' },
|
||||
{ value: 'paper', label: '지류 (종이)' },
|
||||
];
|
||||
|
||||
// ===== 배서 가능 여부 =====
|
||||
const ENDORSEMENT_OPTIONS = [
|
||||
{ value: 'endorsable', label: '배서 가능' },
|
||||
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
|
||||
];
|
||||
|
||||
// ===== 상태 (수취) =====
|
||||
const RECEIVED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'discounted', label: '할인' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
];
|
||||
|
||||
// ===== 상태 (발행) =====
|
||||
const ISSUED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityPayment', label: '만기결제' },
|
||||
{ value: 'collectionRequest', label: '추심의뢰' },
|
||||
{ value: 'collectionComplete', label: '추심완료' },
|
||||
{ value: 'suing', label: '추소중' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
];
|
||||
|
||||
// ===== 차수 처리구분 =====
|
||||
const INSTALLMENT_TYPE_OPTIONS = [
|
||||
{ value: 'endorsement', label: '배서양도' },
|
||||
{ value: 'collection', label: '추심' },
|
||||
{ value: 'discount', label: '할인' },
|
||||
{ value: 'payment', label: '결제' },
|
||||
{ value: 'split', label: '분할' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== 부도사유 =====
|
||||
const DISHONOR_REASON_OPTIONS = [
|
||||
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
|
||||
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
|
||||
{ value: 'formal_defect', label: '형식불비' },
|
||||
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
|
||||
{ value: 'expired', label: '제시기간 경과' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// ===== 차수 레코드 (확장) =====
|
||||
interface InstallmentRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
counterparty: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
interface BillFormData {
|
||||
// 기본 정보
|
||||
billNumber: string;
|
||||
instrumentType: string;
|
||||
direction: string;
|
||||
medium: string;
|
||||
endorsement: string;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: string;
|
||||
note: string;
|
||||
issuerBank: string;
|
||||
paymentPlace: string;
|
||||
bankAccountInfo: string;
|
||||
// 전자어음 추가 (조건: medium = electronic)
|
||||
electronicBillNo: string;
|
||||
registrationOrg: string;
|
||||
// 환어음 추가 (조건: instrumentType = exchange)
|
||||
drawee: string;
|
||||
acceptanceStatus: string;
|
||||
acceptanceDate: string;
|
||||
// 할인 정보 (조건: status = discounted)
|
||||
discountDate: string;
|
||||
discountBank: string;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
netReceivedAmount: number;
|
||||
// 배서양도 정보 (조건: status = endorsed)
|
||||
endorsementDate: string;
|
||||
endorsee: string;
|
||||
endorsementReason: string;
|
||||
// 추심 정보 (조건: status = collected/collectionRequest)
|
||||
collectionBank: string;
|
||||
collectionRequestDate: string;
|
||||
collectionFee: number;
|
||||
// 분할 정보
|
||||
isSplit: boolean;
|
||||
splitCount: number;
|
||||
splitAmount: number;
|
||||
// 부도 정보 (조건: status = dishonored)
|
||||
dishonoredDate: string;
|
||||
dishonoredReason: string;
|
||||
// 차수 관리
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM: BillFormData = {
|
||||
billNumber: '',
|
||||
instrumentType: 'promissory',
|
||||
direction: 'received',
|
||||
medium: 'paper',
|
||||
endorsement: 'endorsable',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
issuerBank: '',
|
||||
paymentPlace: '',
|
||||
bankAccountInfo: '',
|
||||
electronicBillNo: '',
|
||||
registrationOrg: '',
|
||||
drawee: '',
|
||||
acceptanceStatus: '',
|
||||
acceptanceDate: '',
|
||||
discountDate: '',
|
||||
discountBank: '',
|
||||
discountRate: 0,
|
||||
discountAmount: 0,
|
||||
netReceivedAmount: 0,
|
||||
endorsementDate: '',
|
||||
endorsee: '',
|
||||
endorsementReason: '',
|
||||
collectionBank: '',
|
||||
collectionRequestDate: '',
|
||||
collectionFee: 0,
|
||||
isSplit: false,
|
||||
splitCount: 0,
|
||||
splitAmount: 0,
|
||||
dishonoredDate: '',
|
||||
dishonoredReason: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
// ===== NEW 뱃지 =====
|
||||
function NewBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="ml-2 text-[10px] px-1.5 py-0 bg-orange-50 text-orange-600 border-orange-200">
|
||||
NEW
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 조건부 뱃지 =====
|
||||
function CondBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="ml-2 text-[10px] px-1.5 py-0 bg-purple-50 text-purple-600 border-purple-200">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BillPrototypePage() {
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM);
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 상태 옵션 (방향에 따라)
|
||||
const statusOptions = formData.direction === 'received'
|
||||
? RECEIVED_STATUS_OPTIONS
|
||||
: ISSUED_STATUS_OPTIONS;
|
||||
|
||||
// 조건부 표시 플래그
|
||||
const showElectronic = formData.medium === 'electronic';
|
||||
const showExchangeBill = formData.instrumentType === 'exchange';
|
||||
const showDiscount = formData.status === 'discounted';
|
||||
const showEndorsement = formData.status === 'endorsed';
|
||||
const showCollection = ['collected', 'collectionRequest', 'collectionComplete'].includes(formData.status);
|
||||
const showDishonored = formData.status === 'dishonored';
|
||||
|
||||
// 할인 실수령액 자동계산
|
||||
const calcNetReceived = useMemo(() => {
|
||||
if (formData.amount > 0 && formData.discountAmount > 0) {
|
||||
return formData.amount - formData.discountAmount;
|
||||
}
|
||||
return 0;
|
||||
}, [formData.amount, formData.discountAmount]);
|
||||
|
||||
// 분할 합계
|
||||
const splitTotal = formData.splitCount * formData.splitAmount;
|
||||
|
||||
// 차수 관리
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
type: 'payment',
|
||||
amount: 0,
|
||||
counterparty: '',
|
||||
note: '',
|
||||
}],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">어음 등록 (개선안 프로토타입 v2)</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">실무자 확인용 - 조건부 필드 포함</p>
|
||||
</div>
|
||||
|
||||
{/* 안내 배너 */}
|
||||
<Card className="mb-6 border-blue-200 bg-blue-50">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
<div className="flex items-center gap-2 text-blue-700">
|
||||
<Info className="h-4 w-4 flex-shrink-0" />
|
||||
<span>이 페이지는 <strong>프로토타입</strong>입니다. 실제 데이터 저장되지 않습니다.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-6 text-xs text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-orange-50 text-orange-600 border-orange-200">NEW</Badge>
|
||||
신규 필드
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 bg-purple-50 text-purple-600 border-purple-200">조건부</Badge>
|
||||
특정 조건에서만 표시
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 기본 정보 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>어음번호 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="자동생성 또는 직접입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={(v) => updateField('instrumentType', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={formData.direction}
|
||||
onValueChange={(v) => { updateField('direction', v); updateField('status', 'stored'); }}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배서 여부 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부<NewBadge /></Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.vendorId} onValueChange={(v) => updateField('vendorId', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">삼성전자</SelectItem>
|
||||
<SelectItem value="2">LG전자</SelectItem>
|
||||
<SelectItem value="3">현대건설</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(value) => updateField('amount', value ?? 0)} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(date) => updateField('issueDate', date)} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(date) => updateField('maturityDate', date)} />
|
||||
</div>
|
||||
|
||||
{/* 발행은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행은행<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.issuerBank}
|
||||
onChange={(e) => updateField('issuerBank', e.target.value)}
|
||||
placeholder="예: 국민은행"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급지<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.paymentPlace}
|
||||
onChange={(e) => updateField('paymentPlace', e.target.value)}
|
||||
placeholder="예: 국민은행 강남지점"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태 <span className="text-red-500">*</span></Label>
|
||||
<Select key={`status-${formData.direction}`} value={formData.status} onValueChange={(v) => updateField('status', v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 입금/출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌<NewBadge /></Label>
|
||||
<Select value={formData.bankAccountInfo} onValueChange={(v) => updateField('bankAccountInfo', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="계좌 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">국민은행 123-456-789 (보통예금)</SelectItem>
|
||||
<SelectItem value="2">신한은행 987-654-321 (당좌예금)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 전자어음 추가 정보 (조건: 전자/지류 = 전자) ===== */}
|
||||
{showElectronic && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
전자어음 정보
|
||||
<CondBadge label="전자 선택 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호<NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.electronicBillNo}
|
||||
onChange={(e) => updateField('electronicBillNo', e.target.value)}
|
||||
placeholder="전자어음시스템 발급번호"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관<NewBadge /></Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 환어음 추가 정보 (조건: 증권종류 = 환어음) ===== */}
|
||||
{showExchangeBill && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
환어음 정보
|
||||
<CondBadge label="환어음 선택 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>지급인 (Drawee) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.drawee}
|
||||
onChange={(e) => updateField('drawee', e.target.value)}
|
||||
placeholder="지급 의무자"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부<NewBadge /></Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accepted">인수 완료</SelectItem>
|
||||
<SelectItem value="pending">인수 대기</SelectItem>
|
||||
<SelectItem value="refused">인수 거절</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수일자<NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceDate}
|
||||
onChange={(date) => updateField('acceptanceDate', date)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 할인 정보 (조건: 상태 = 할인) ===== */}
|
||||
{showDiscount && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
할인 정보
|
||||
<CondBadge label="상태=할인 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>할인일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.discountDate}
|
||||
onChange={(date) => updateField('discountDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.discountBank}
|
||||
onChange={(e) => updateField('discountBank', e.target.value)}
|
||||
placeholder="예: 국민은행 강남지점"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)<NewBadge /></Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
max={100}
|
||||
value={formData.discountRate || ''}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액<NewBadge /></Label>
|
||||
<CurrencyInput
|
||||
value={formData.discountAmount}
|
||||
onChange={(value) => updateField('discountAmount', value ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>실수령액 (자동계산)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
|
||||
{calcNetReceived > 0
|
||||
? <span className="text-green-700">₩ {calcNetReceived.toLocaleString()}</span>
|
||||
: <span className="text-gray-400">어음금액 - 할인금액</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 배서양도 정보 (조건: 상태 = 배서양도) ===== */}
|
||||
{showEndorsement && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
배서양도 정보
|
||||
<CondBadge label="상태=배서양도 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>배서일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.endorsementDate}
|
||||
onChange={(date) => updateField('endorsementDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.endorsee}
|
||||
onChange={(e) => updateField('endorsee', e.target.value)}
|
||||
placeholder="어음을 넘겨받는 자"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유<NewBadge /></Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment">대금결제</SelectItem>
|
||||
<SelectItem value="guarantee">담보제공</SelectItem>
|
||||
<SelectItem value="collection">추심위임</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 추심 정보 (조건: 상태 = 추심/추심의뢰/추심완료) ===== */}
|
||||
{showCollection && (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
추심 정보
|
||||
<CondBadge label="상태=추심 시" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Input
|
||||
value={formData.collectionBank}
|
||||
onChange={(e) => updateField('collectionBank', e.target.value)}
|
||||
placeholder="추심 의뢰 은행"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.collectionRequestDate}
|
||||
onChange={(date) => updateField('collectionRequestDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료<NewBadge /></Label>
|
||||
<CurrencyInput
|
||||
value={formData.collectionFee}
|
||||
onChange={(value) => updateField('collectionFee', value ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 분할 정보 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
분할 정보<NewBadge />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.isSplit}
|
||||
onCheckedChange={(checked) => {
|
||||
updateField('isSplit', checked);
|
||||
if (!checked) { updateField('splitCount', 0); updateField('splitAmount', 0); }
|
||||
}}
|
||||
/>
|
||||
<Label>분할 어음</Label>
|
||||
</div>
|
||||
{formData.isSplit && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>분할 장수</Label>
|
||||
<Input
|
||||
type="number" min={1}
|
||||
value={formData.splitCount || ''}
|
||||
onChange={(e) => updateField('splitCount', parseInt(e.target.value) || 0)}
|
||||
placeholder="장수 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>장당 금액</Label>
|
||||
<CurrencyInput value={formData.splitAmount} onChange={(value) => updateField('splitAmount', value ?? 0)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>분할 합계</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-medium">
|
||||
{splitTotal > 0 ? `₩ ${splitTotal.toLocaleString()}` : '-'}
|
||||
{splitTotal > 0 && formData.amount > 0 && splitTotal !== formData.amount && (
|
||||
<span className="ml-2 text-red-500 text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
어음 금액과 불일치
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 부도 정보 (조건: 상태 = 부도) ===== */}
|
||||
{showDishonored && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<CondBadge label="상태=부도 시" />
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<DatePicker
|
||||
value={formData.dishonoredDate}
|
||||
onChange={(date) => updateField('dishonoredDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span><NewBadge /></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 차수 관리 ===== */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
차수 관리<Badge variant="secondary" className="text-xs">확장</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[130px]">처리구분<NewBadge /></TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처<NewBadge /></TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
<TableHead className="w-[60px]">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)} size="sm" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => handleUpdateInstallment(inst.id, 'type', v)}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTALLMENT_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.counterparty}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'counterparty', e.target.value)}
|
||||
placeholder="양수인/추심처"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ===== 조건부 필드 가이드 (실무자 확인용) ===== */}
|
||||
<Card className="border-gray-300 bg-gray-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">조건부 필드 가이드 (실무자 확인용)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-gray-600 mb-4">아래 조건을 변경하면 해당 섹션이 자동으로 나타납니다. 직접 선택해서 확인해보세요.</p>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">조건</TableHead>
|
||||
<TableHead className="w-[200px]">나타나는 섹션</TableHead>
|
||||
<TableHead>포함 필드</TableHead>
|
||||
<TableHead className="w-[80px]">현재</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className={showElectronic ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">전자/지류 = 전자</TableCell>
|
||||
<TableCell>전자어음 정보</TableCell>
|
||||
<TableCell>전자어음관리번호, 등록기관</TableCell>
|
||||
<TableCell>{showElectronic ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showExchangeBill ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">증권종류 = 환어음</TableCell>
|
||||
<TableCell>환어음 정보</TableCell>
|
||||
<TableCell>지급인(drawee), 인수여부, 인수일자</TableCell>
|
||||
<TableCell>{showExchangeBill ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showDiscount ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 할인</TableCell>
|
||||
<TableCell>할인 정보</TableCell>
|
||||
<TableCell>할인일자, 할인처, 할인율, 할인금액, 실수령액(자동)</TableCell>
|
||||
<TableCell>{showDiscount ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showEndorsement ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 배서양도</TableCell>
|
||||
<TableCell>배서양도 정보</TableCell>
|
||||
<TableCell>배서일자, 피배서인(양수인), 배서사유</TableCell>
|
||||
<TableCell>{showEndorsement ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showCollection ? 'bg-purple-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 추심/추심의뢰</TableCell>
|
||||
<TableCell>추심 정보</TableCell>
|
||||
<TableCell>추심은행, 추심의뢰일, 추심수수료</TableCell>
|
||||
<TableCell>{showCollection ? <Badge className="bg-purple-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={showDishonored ? 'bg-red-50' : ''}>
|
||||
<TableCell className="font-medium">상태 = 부도</TableCell>
|
||||
<TableCell>부도 정보</TableCell>
|
||||
<TableCell>부도일자, 부도사유 (1호/2호/형식불비 등)</TableCell>
|
||||
<TableCell>{showDishonored ? <Badge variant="destructive" className="text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={formData.isSplit ? 'bg-orange-50' : ''}>
|
||||
<TableCell className="font-medium">분할 토글 ON</TableCell>
|
||||
<TableCell>분할 상세</TableCell>
|
||||
<TableCell>장수, 장당금액, 합계(자동/불일치 경고)</TableCell>
|
||||
<TableCell>{formData.isSplit ? <Badge className="bg-orange-600 text-xs">표시중</Badge> : <span className="text-gray-400">숨김</span>}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
<Button variant="outline">취소</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => {
|
||||
alert('프로토타입입니다. 실제 저장되지 않습니다.');
|
||||
}}>
|
||||
등록 (데모)
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
// HTML 설정
|
||||
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const resourceType = req.resourceType();
|
||||
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
|
||||
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
|
||||
req.abort();
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
|
||||
await page.setContent(fullHtml, {
|
||||
waitUntil: 'networkidle0',
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
Reference in New Issue
Block a user