feat: 레이아웃/출하/생산/회계/대시보드 전반 개선

- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
This commit is contained in:
유병철
2026-03-05 13:35:48 +09:00
parent c18c68b6b7
commit 00a6209347
23 changed files with 1689 additions and 517 deletions

View File

@@ -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>
);
}

View File

@@ -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',
});
// 헤더 템플릿 (문서번호, 생성일)