feat: 어음관리 리팩토링 및 CEO 대시보드 SummaryNavBar 추가
- BillManagement: BillDetail 리팩토링, sections/hooks 분리, constants 추가 - BillManagement types 대폭 확장, actions 개선 - GiftCertificateManagement: actions/types 확장 - CEO 대시보드: SummaryNavBar 컴포넌트 추가, useSectionSummary 훅 - bill-prototype 개발 페이지 업데이트
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
// V8 폼 훅
|
||||
const {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
} = useBillForm();
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
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<BillRecord>(
|
||||
} = useDetailData<BillApiData>(
|
||||
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<BillRecord> = {
|
||||
...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 = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 1. 기본 정보 */}
|
||||
<BasicInfoSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
clients={clients}
|
||||
conditions={conditions}
|
||||
onInstrumentTypeChange={handleInstrumentTypeChange}
|
||||
onDirectionChange={handleDirectionChange}
|
||||
/>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 2. 전자어음 정보 */}
|
||||
{conditions.showElectronic && (
|
||||
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 3. 환어음 정보 */}
|
||||
{conditions.showExchangeBill && (
|
||||
<ExchangeBillSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 4. 할인 정보 */}
|
||||
{conditions.showDiscount && (
|
||||
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 5. 배서양도 정보 */}
|
||||
{conditions.showEndorsement && (
|
||||
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 6. 추심 정보 */}
|
||||
{conditions.showCollection && (
|
||||
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 7. 이력 관리 (받을어음만) */}
|
||||
{conditions.isReceived && (
|
||||
<HistorySection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
isElectronic={conditions.isElectronic}
|
||||
maxSplitCount={conditions.maxSplitCount}
|
||||
onAddInstallment={addInstallment}
|
||||
onRemoveInstallment={removeInstallment}
|
||||
onUpdateInstallment={updateInstallment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 8. 개서 정보 */}
|
||||
{conditions.showRenewal && (
|
||||
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<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-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<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>
|
||||
{/* 9. 소구 정보 */}
|
||||
{conditions.showRecourse && (
|
||||
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 10. 환매 정보 */}
|
||||
{conditions.showBuyback && (
|
||||
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 11. 부도 정보 */}
|
||||
{conditions.showDishonored && (
|
||||
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// 템플릿 설정
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
title: isViewMode ? '어음/수표 상세' : '어음/수표',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
|
||||
@@ -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<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
errorMessage: '어음 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 등록 (raw payload) =====
|
||||
export async function createBillRaw(data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bills'),
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '어음 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== V8: 어음 수정 (raw payload) =====
|
||||
export async function updateBillRaw(id: string, data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bills/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
errorMessage: '어음 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
|
||||
return executeServerAction({
|
||||
|
||||
@@ -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: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
178
src/components/accounting/BillManagement/constants.ts
Normal file
178
src/components/accounting/BillManagement/constants.ts
Normal file
@@ -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'];
|
||||
@@ -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]);
|
||||
}
|
||||
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
@@ -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<BillFormData>) {
|
||||
const [formData, setFormData] = useState<BillFormData>({
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
...initialData,
|
||||
});
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<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="자동생성 또는 직접입력" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span>
|
||||
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 전자 가능)</span>}
|
||||
</Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={isReceived ? formData.vendor : formData.payee}
|
||||
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 (수표는 일람출급이므로 없음) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
|
||||
<Input
|
||||
value={isReceived ? formData.issuerBank : formData.settlementBank}
|
||||
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
|
||||
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 <span className="text-red-500">*</span>
|
||||
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
|
||||
</Label>
|
||||
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 상세 */}
|
||||
{formData.paymentPlace === 'other' && (
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 상세</Label>
|
||||
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 어음구분 (어음만) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>어음구분</Label>
|
||||
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 받을어음 전용 필드 ===== */}
|
||||
{isReceived && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부</Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>배서차수</Label>
|
||||
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>보관장소</Label>
|
||||
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>결제상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 할인여부 (수표 제외) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>할인여부</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
|
||||
updateField('isDiscounted', c);
|
||||
if (c) updateField('receivedStatus', 'discounted');
|
||||
}} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 지급어음 전용 필드 ===== */}
|
||||
{isIssued && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>결제방법</Label>
|
||||
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>지급상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>실제결제일</Label>
|
||||
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 입출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌</Label>
|
||||
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">환매 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>환매일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매요청 은행</Label>
|
||||
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">추심 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 추심 의뢰 */}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">추심 의뢰</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료</Label>
|
||||
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 추심 결과 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">추심 결과</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심결과</Label>
|
||||
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심완료일</Label>
|
||||
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금일</Label>
|
||||
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금액 (수수료 차감후)</Label>
|
||||
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<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>
|
||||
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)</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" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액</Label>
|
||||
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
|
||||
updateField('dishonoredDate', d);
|
||||
if (d) {
|
||||
const dt = new Date(d);
|
||||
dt.setDate(dt.getDate() + 6);
|
||||
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
|
||||
}
|
||||
}} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 법적 프로세스 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">법적 프로세스 (어음법 제44조·제45조)</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{formData.hasProtest && (
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성일</Label>
|
||||
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>소구 통지일</Label>
|
||||
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>통지 기한 (자동: 부도일+4영업일)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
|
||||
{formData.recourseNoticeDeadline ? (
|
||||
<span className={
|
||||
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
|
||||
? 'text-green-700' : 'text-red-600 font-medium'
|
||||
}>
|
||||
{formData.recourseNoticeDeadline}
|
||||
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
|
||||
</span>
|
||||
) : <span className="text-gray-400">부도일자 입력 시 자동계산</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">전자어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호</Label>
|
||||
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관</Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">배서양도 정보</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></Label>
|
||||
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유</Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">환어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<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></Label>
|
||||
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부</Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
|
||||
<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>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
|
||||
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showAcceptanceRefusal && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>인수거절 사유</Label>
|
||||
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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: <K extends keyof BillFormData>(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 (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">이력 관리</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 분할배서 토글 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
|
||||
<Label>분할배서 허용</Label>
|
||||
{formData.isSplit && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
최대 {maxSplitCount}회
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{formData.isSplit && isElectronic && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조)</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.isSplit && splitEndorsementStats.count > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
|
||||
<span className="text-muted-foreground">원금액:</span>
|
||||
<span className="font-semibold">₩ {formData.amount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 분할배서 합계:</span>
|
||||
<span className="font-semibold text-blue-600">₩ {splitEndorsementStats.totalAmount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 잔액:</span>
|
||||
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
₩ {splitEndorsementStats.remaining.toLocaleString()}
|
||||
</span>
|
||||
{splitEndorsementStats.remaining < 0 && (
|
||||
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" />금액 초과</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<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]">처리구분</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8">등록된 이력이 없습니다</TableCell>
|
||||
</TableRow>
|
||||
) : formData.installments.map((inst, idx) => (
|
||||
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HISTORY_TYPE_OPTIONS
|
||||
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
|
||||
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">소구 (상환) 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>소구일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구대상 (청구인)</Label>
|
||||
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구사유</Label>
|
||||
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
|
||||
개서 정보
|
||||
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50">만기연장</Badge>
|
||||
</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></Label>
|
||||
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>신어음번호</Label>
|
||||
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>개서 사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
@@ -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';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BillFormData } from '../types';
|
||||
|
||||
export interface SectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
}
|
||||
@@ -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<BillRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
if (data.vendorName !== undefined) result.client_name = data.vendorName || null;
|
||||
if (data.amount !== undefined) result.amount = data.amount;
|
||||
if (data.issueDate !== undefined) result.issue_date = data.issueDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
|
||||
if (data.status !== undefined) result.status = data.status;
|
||||
if (data.reason !== undefined) result.reason = data.reason || null;
|
||||
if (data.note !== undefined) result.note = data.note || null;
|
||||
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
|
||||
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<ActionResult<GiftCertificateRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: [] };
|
||||
search?: string;
|
||||
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
|
||||
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
|
||||
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<ActionResult<GiftCertificateFormData>> {
|
||||
// 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<ActionResult<GiftCertificateRecord>> {
|
||||
// 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<ActionResult<GiftCertificateRecord>> {
|
||||
// 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<ActionResult> {
|
||||
// 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<ActionResult<{
|
||||
@@ -151,23 +113,29 @@ export async function getGiftCertificateSummary(_params?: {
|
||||
entertainmentCount: number;
|
||||
entertainmentAmount: number;
|
||||
}>> {
|
||||
// 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: '상품권 요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement> =>
|
||||
(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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<SummaryNavBar
|
||||
summaries={summaries}
|
||||
activeSectionKey={activeSectionKey}
|
||||
onChipClick={scrollToSection}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sectionOrder.map(renderDashboardSection)}
|
||||
{sectionOrder.map((key) => {
|
||||
const node = renderDashboardSection(key);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<div key={key} ref={setSectionRef(key)} data-section-key={key}>
|
||||
{node}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
|
||||
|
||||
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
255
src/components/business/CEODashboard/SummaryNavBar.tsx
Normal file
@@ -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<SummaryStatus, string> = {
|
||||
normal: 'bg-green-500',
|
||||
warning: 'bg-yellow-500',
|
||||
danger: 'bg-red-500',
|
||||
};
|
||||
|
||||
/** 상태별 칩 배경색 (비활성) */
|
||||
const STATUS_BG: Record<SummaryStatus, string> = {
|
||||
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<SummaryStatus, string> = {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-medium whitespace-nowrap',
|
||||
'border transition-all duration-200 shrink-0',
|
||||
'hover:brightness-95 active:scale-[0.97]',
|
||||
isActive
|
||||
? cn(STATUS_BG_ACTIVE[summary.status], 'text-foreground shadow-sm')
|
||||
: cn(STATUS_BG[summary.status], 'text-muted-foreground'),
|
||||
)}
|
||||
>
|
||||
{/* 상태 점 (확대) */}
|
||||
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[summary.status])} />
|
||||
{/* 라벨 */}
|
||||
<span className="truncate max-w-[6rem]">{summary.label}</span>
|
||||
{/* 값 */}
|
||||
<span className={cn(
|
||||
'font-bold',
|
||||
summary.status === 'danger' && 'text-red-600 dark:text-red-400',
|
||||
summary.status === 'warning' && 'text-yellow-600 dark:text-yellow-400',
|
||||
)}>
|
||||
{summary.value}
|
||||
</span>
|
||||
{/* 활성 하단 바 */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-[3px] rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(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 = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 좌측 화살표 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 항목"
|
||||
className={cn(arrowBtnClass, !canScrollLeft && 'opacity-0 pointer-events-none')}
|
||||
onClick={() => scrollBy('left')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* 칩 목록 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex items-center gap-2 overflow-x-auto flex-1"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{summaries.map((s) => (
|
||||
<div key={s.key} data-chip-key={s.key}>
|
||||
<SummaryChip
|
||||
summary={s}
|
||||
isActive={activeSectionKey === s.key}
|
||||
onClick={() => handleChipClick(s.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 우측 화살표 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 항목"
|
||||
className={cn(arrowBtnClass, !canScrollRight && 'opacity-0 pointer-events-none')}
|
||||
onClick={() => scrollBy('right')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* sentinel: 이 div가 헤더 뒤로 사라지면 fixed 모드 활성화 */}
|
||||
<div ref={sentinelRef} className="h-0 w-full" />
|
||||
|
||||
{/* fixed일 때 레이아웃 공간 유지용 spacer */}
|
||||
{isFixed && <div style={{ height: BAR_HEIGHT }} />}
|
||||
|
||||
{/* 실제 바 */}
|
||||
<div
|
||||
className="z-40 py-2.5 backdrop-blur-md bg-background/90 border-b border-border/50"
|
||||
style={
|
||||
isFixed
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: HEADER_BOTTOM,
|
||||
left: barRect.left,
|
||||
width: barRect.width,
|
||||
}
|
||||
: { position: 'relative' }
|
||||
}
|
||||
>
|
||||
{barContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
293
src/components/business/CEODashboard/useSectionSummary.ts
Normal file
@@ -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<Map<SectionKey, HTMLElement>>;
|
||||
scrollToSection: (key: SectionKey) => void;
|
||||
}
|
||||
|
||||
export function useSectionSummary({
|
||||
data,
|
||||
sectionOrder,
|
||||
dashboardSettings,
|
||||
}: UseSectionSummaryParams): UseSectionSummaryReturn {
|
||||
const sectionRefs = useRef<Map<SectionKey, HTMLElement>>(new Map());
|
||||
const [activeSectionKey, setActiveSectionKey] = useState<SectionKey | null>(null);
|
||||
// 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지
|
||||
const pinnedKey = useRef<SectionKey | null>(null);
|
||||
|
||||
// 활성화된 섹션만 필터
|
||||
const enabledSections = useMemo(
|
||||
() => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)),
|
||||
[sectionOrder, dashboardSettings],
|
||||
);
|
||||
|
||||
// 요약 데이터 계산
|
||||
const summaries = useMemo<SectionSummary[]>(
|
||||
() =>
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user