feat: 어음관리 리팩토링 및 CEO 대시보드 SummaryNavBar 추가

- BillManagement: BillDetail 리팩토링, sections/hooks 분리, constants 추가
- BillManagement types 대폭 확장, actions 개선
- GiftCertificateManagement: actions/types 확장
- CEO 대시보드: SummaryNavBar 컴포넌트 추가, useSectionSummary 훅
- bill-prototype 개발 페이지 업데이트
This commit is contained in:
유병철
2026-03-05 20:47:43 +09:00
parent 2fe47c86d3
commit 1675f3edcf
26 changed files with 3520 additions and 1055 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 ? '등록' : '저장',

View File

@@ -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({

View File

@@ -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: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};

View 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'];

View File

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

View 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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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';

View File

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

View File

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

View File

@@ -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: '상품권 요약 조회에 실패했습니다.',
});
}

View File

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

View File

@@ -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_ 접두사만 수정/삭제 가능 */}

View 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>
</>
);
}

View 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 };
}