Files
sam-react-prod/src/components/accounting/TaxInvoiceIssuance/index.tsx
유병철 7f39f3066f feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가
- 바로빌 연동 설정 페이지 추가
- 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환
- 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장)
- 계좌 상세 폼(AccountDetailForm) 신규 구현
- 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용
- DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선
- 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:18:45 +09:00

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