- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
17 KiB
TypeScript
483 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { format, subDays, subMonths } from 'date-fns';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
|
import {
|
|
PageLayout,
|
|
PageHeader,
|
|
StatCards,
|
|
EmptyState,
|
|
SearchableSelectionModal,
|
|
} from '@/components/organisms';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { FileText, DollarSign, Calculator, Hash, Settings, PlusCircle, Search } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { SupplierSettingModal } from './SupplierSettingModal';
|
|
import { TaxInvoiceForm } from './TaxInvoiceForm';
|
|
import { createTaxInvoice, searchVendorsForTaxInvoice } from './actions';
|
|
import {
|
|
DATE_TYPE_OPTIONS,
|
|
STATUS_OPTIONS,
|
|
SORT_BY_OPTIONS,
|
|
SORT_ORDER_OPTIONS,
|
|
TAX_INVOICE_STATUS_MAP,
|
|
createEmptyBusinessEntity,
|
|
} from './types';
|
|
import type {
|
|
TaxInvoiceRecord,
|
|
SupplierSettings,
|
|
VendorSearchItem,
|
|
FilterState,
|
|
TaxInvoiceFormData,
|
|
BusinessEntity,
|
|
} from './types';
|
|
|
|
interface TaxInvoiceIssuancePageProps {
|
|
initialData: TaxInvoiceRecord[];
|
|
initialSupplierSettings: SupplierSettings;
|
|
}
|
|
|
|
export function TaxInvoiceIssuancePage({
|
|
initialData,
|
|
initialSupplierSettings,
|
|
}: TaxInvoiceIssuancePageProps) {
|
|
const router = useRouter();
|
|
|
|
// 데이터
|
|
const [records, setRecords] = useState<TaxInvoiceRecord[]>(initialData);
|
|
const [supplierSettings, setSupplierSettings] = useState<SupplierSettings>(initialSupplierSettings);
|
|
|
|
// UI 상태
|
|
const [showNewForm, setShowNewForm] = useState(false);
|
|
const [showSupplierModal, setShowSupplierModal] = useState(false);
|
|
const [showVendorSearch, setShowVendorSearch] = useState(false);
|
|
const [selectedVendor, setSelectedVendor] = useState<BusinessEntity | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
|
|
// 필터
|
|
const [filters, setFilters] = useState<FilterState>({
|
|
dateType: 'write',
|
|
startDate: format(subMonths(new Date(), 1), 'yyyy-MM-dd'),
|
|
endDate: format(new Date(), 'yyyy-MM-dd'),
|
|
vendorSearch: '',
|
|
status: 'all',
|
|
sortBy: 'writeDate',
|
|
sortOrder: 'desc',
|
|
});
|
|
|
|
const updateFilter = useCallback((field: keyof FilterState, value: string) => {
|
|
setFilters((prev) => ({ ...prev, [field]: value }));
|
|
}, []);
|
|
|
|
// 통계
|
|
const totalSupply = records.reduce((sum, r) => sum + r.supplyAmount, 0);
|
|
const totalTax = records.reduce((sum, r) => sum + r.taxAmount, 0);
|
|
const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0);
|
|
|
|
const issuedCount = records.filter((r) => r.status === 'issued' || r.status === 'nts_sent').length;
|
|
const sentCount = records.filter((r) => r.status === 'nts_sent').length;
|
|
|
|
const stats = [
|
|
{
|
|
label: '발행건수',
|
|
sublabel: `발행 ${issuedCount} / 전송 ${sentCount}`,
|
|
value: `${totalAmount.toLocaleString('ko-KR')}원`,
|
|
icon: Hash,
|
|
iconColor: 'text-blue-600',
|
|
},
|
|
{
|
|
label: '총 합계금액',
|
|
value: `${totalAmount.toLocaleString('ko-KR')}원`,
|
|
icon: Calculator,
|
|
iconColor: 'text-green-600',
|
|
},
|
|
{
|
|
label: '총 공급가액',
|
|
value: `${totalSupply.toLocaleString('ko-KR')}원`,
|
|
icon: DollarSign,
|
|
iconColor: 'text-purple-600',
|
|
},
|
|
{
|
|
label: '총 세액',
|
|
value: `${totalTax.toLocaleString('ko-KR')}원`,
|
|
icon: FileText,
|
|
iconColor: 'text-orange-600',
|
|
},
|
|
];
|
|
|
|
// 세금계산서 발행 처리
|
|
const handleSubmitInvoice = useCallback(
|
|
async (data: TaxInvoiceFormData) => {
|
|
const result = await createTaxInvoice(data);
|
|
if (result.success && result.data) {
|
|
setRecords((prev) => [result.data!, ...prev]);
|
|
setShowNewForm(false);
|
|
setSelectedVendor(null);
|
|
toast.success('세금계산서가 발행되었습니다.');
|
|
} else {
|
|
toast.error(result.error || '발행에 실패했습니다.');
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 거래처 선택 처리
|
|
const handleVendorSelect = useCallback((vendor: VendorSearchItem) => {
|
|
setSelectedVendor({
|
|
businessNumber: vendor.businessNumber,
|
|
companyName: vendor.companyName,
|
|
representativeName: vendor.representativeName,
|
|
address: vendor.address,
|
|
businessType: vendor.businessType,
|
|
businessItem: vendor.businessItem,
|
|
contactName: vendor.contactName,
|
|
contactPhone: vendor.contactPhone,
|
|
contactEmail: vendor.contactEmail,
|
|
});
|
|
setShowVendorSearch(false);
|
|
}, []);
|
|
|
|
// 기간 단축 버튼
|
|
const handleQuickDate = useCallback(
|
|
(type: '1week' | '1month' | '3months') => {
|
|
const today = new Date();
|
|
const endDate = format(today, 'yyyy-MM-dd');
|
|
let startDate: string;
|
|
switch (type) {
|
|
case '1week':
|
|
startDate = format(subDays(today, 7), 'yyyy-MM-dd');
|
|
break;
|
|
case '1month':
|
|
startDate = format(subMonths(today, 1), 'yyyy-MM-dd');
|
|
break;
|
|
case '3months':
|
|
startDate = format(subMonths(today, 3), 'yyyy-MM-dd');
|
|
break;
|
|
}
|
|
setFilters((prev) => ({ ...prev, startDate, endDate }));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSearch = useCallback(() => {
|
|
// TODO: 실제 API 연동 시 필터 조건으로 getTaxInvoices 호출
|
|
toast.info('조회 기능은 API 연동 후 사용 가능합니다.');
|
|
}, []);
|
|
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="세금계산서 발행"
|
|
description="바로빌 API를 통하여 전자세금계산서를 발행하고 관리합니다"
|
|
icon={FileText}
|
|
/>
|
|
|
|
{/* 통계 카드 */}
|
|
<StatCards stats={stats} />
|
|
|
|
{/* 전자세금계산서 발행 섹션 */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
|
<CardTitle className="text-base">전자세금계산서 발행</CardTitle>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSupplierModal(true)}
|
|
>
|
|
<Settings className="h-4 w-4 mr-1" />
|
|
공급자 설정
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setShowNewForm(!showNewForm)}
|
|
>
|
|
<PlusCircle className="h-4 w-4 mr-1" />
|
|
{showNewForm ? '접기' : '새로 발행'}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{showNewForm && (
|
|
<CardContent className="pt-0">
|
|
<TaxInvoiceForm
|
|
supplier={supplierSettings}
|
|
onSubmit={handleSubmitInvoice}
|
|
onCancel={() => {
|
|
setShowNewForm(false);
|
|
setSelectedVendor(null);
|
|
}}
|
|
onVendorSearch={() => setShowVendorSearch(true)}
|
|
selectedVendor={selectedVendor}
|
|
/>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
|
|
{/* 필터 */}
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
{/* Row1: 일자타입 + 날짜범위 + 기간 버튼 + 조회 */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
|
<Select
|
|
value={filters.dateType}
|
|
onValueChange={(v) => updateFilter('dateType', v)}
|
|
>
|
|
<SelectTrigger className="w-full lg:w-[120px] h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DATE_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<DateRangeSelector
|
|
startDate={filters.startDate}
|
|
endDate={filters.endDate}
|
|
onStartDateChange={(v) => updateFilter('startDate', v)}
|
|
onEndDateChange={(v) => updateFilter('endDate', v)}
|
|
hidePresets
|
|
/>
|
|
|
|
<div className="flex gap-1">
|
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('1week')}>
|
|
1주일
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('1month')}>
|
|
1개월
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => handleQuickDate('3months')}>
|
|
3개월
|
|
</Button>
|
|
</div>
|
|
|
|
<Button size="sm" onClick={handleSearch}>
|
|
조회
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Row2: 거래처 검색 */}
|
|
<div className="relative max-w-md">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
value={filters.vendorSearch}
|
|
onChange={(e) => updateFilter('vendorSearch', e.target.value)}
|
|
placeholder="사업자 번호 또는 사업자명"
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Row3: 상태 + 정렬 */}
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<Select
|
|
value={filters.status}
|
|
onValueChange={(v) => updateFilter('status', v)}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.sortBy}
|
|
onValueChange={(v) => updateFilter('sortBy', v)}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SORT_BY_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.sortOrder}
|
|
onValueChange={(v) => updateFilter('sortOrder', v)}
|
|
>
|
|
<SelectTrigger className="w-full sm:w-[130px] h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SORT_ORDER_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 테이블 */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[40px] text-center">
|
|
<Checkbox
|
|
checked={records.length > 0 && selectedIds.size === records.length}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setSelectedIds(new Set(records.map((r) => r.id)));
|
|
} else {
|
|
setSelectedIds(new Set());
|
|
}
|
|
}}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[50px] text-center">번호</TableHead>
|
|
<TableHead className="min-w-[120px]">발행번호</TableHead>
|
|
<TableHead className="min-w-[120px]">공급받는자</TableHead>
|
|
<TableHead className="w-[100px] text-center">작성일자</TableHead>
|
|
<TableHead className="w-[100px] text-center">전송일자</TableHead>
|
|
<TableHead className="w-[110px] text-right">공급가액</TableHead>
|
|
<TableHead className="w-[100px] text-right">세액</TableHead>
|
|
<TableHead className="w-[110px] text-right">합계금액</TableHead>
|
|
<TableHead className="w-[90px] text-center">상태</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{records.length > 0 ? (
|
|
records.map((record, index) => {
|
|
const statusInfo = TAX_INVOICE_STATUS_MAP[record.status];
|
|
return (
|
|
<TableRow
|
|
key={record.id}
|
|
className="cursor-pointer hover:bg-muted/50"
|
|
onClick={() => router.push(`?mode=edit&id=${record.id}`)}
|
|
>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={selectedIds.has(record.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(record.id);
|
|
else next.delete(record.id);
|
|
return next;
|
|
});
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center">{index + 1}</TableCell>
|
|
<TableCell className="font-medium">{record.invoiceNumber}</TableCell>
|
|
<TableCell>{record.vendorName}</TableCell>
|
|
<TableCell className="text-center">{record.writeDate}</TableCell>
|
|
<TableCell className="text-center">{record.sendDate || '-'}</TableCell>
|
|
<TableCell className="text-right">
|
|
{record.supplyAmount.toLocaleString('ko-KR')}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{record.taxAmount.toLocaleString('ko-KR')}
|
|
</TableCell>
|
|
<TableCell className="text-right font-medium">
|
|
{record.totalAmount.toLocaleString('ko-KR')}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${statusInfo.color}`}
|
|
>
|
|
{statusInfo.label}
|
|
</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={10}>
|
|
<EmptyState
|
|
message="해당 조건에 맞는 세금계산서가 없습니다."
|
|
variant="compact"
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 공급자 설정 모달 */}
|
|
<SupplierSettingModal
|
|
open={showSupplierModal}
|
|
onOpenChange={setShowSupplierModal}
|
|
settings={supplierSettings}
|
|
onSaved={setSupplierSettings}
|
|
/>
|
|
|
|
{/* 거래처 검색 모달 */}
|
|
<SearchableSelectionModal<VendorSearchItem>
|
|
open={showVendorSearch}
|
|
onOpenChange={setShowVendorSearch}
|
|
title="거래처 검색"
|
|
searchPlaceholder="거래처명, 사업자번호, 담당자명"
|
|
fetchData={searchVendorsForTaxInvoice}
|
|
keyExtractor={(item) => item.id}
|
|
mode="single"
|
|
onSelect={handleVendorSelect}
|
|
searchMode="enter"
|
|
dialogClassName="sm:max-w-xl"
|
|
listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md"
|
|
noResultMessage="거래처가 없습니다."
|
|
emptyQueryMessage="거래처명 또는 사업자번호를 입력하세요."
|
|
listWrapper={(children) => (
|
|
<div>
|
|
<div className="grid grid-cols-2 border-b border-gray-300 bg-gray-100 font-semibold text-sm text-center">
|
|
<div className="px-4 py-2 border-r border-gray-300">거래처명</div>
|
|
<div className="px-4 py-2">사업자번호</div>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)}
|
|
renderItem={(item) => (
|
|
<div className="grid grid-cols-2 border-b border-gray-200 hover:bg-muted/50 text-sm">
|
|
<div className="px-4 py-2.5 border-r border-gray-200 text-center">{item.companyName}</div>
|
|
<div className="px-4 py-2.5 text-center">{item.businessNumber}</div>
|
|
</div>
|
|
)}
|
|
/>
|
|
</PageLayout>
|
|
);
|
|
}
|