Files
sam-react-prod/src/components/accounting/TaxInvoiceManagement/index.tsx
유병철 13d27553b9 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화
- 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)
- 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:28:23 +09:00

559 lines
20 KiB
TypeScript

'use client';
/**
* 세금계산서 관리 - 메인 리스트 페이지
*
* - 매출/매입 탭 (카운트 표시)
* - 일자유형 Select + 날짜범위 + 분기버튼 + 거래처 검색
* - 요약 카드 (매출/매입 공급가액/세액/합계)
* - 테이블 + 범례 + 기간 요약
* - 분개 버튼 → JournalEntryModal
* - 수기 입력 버튼 → ManualEntryModal
*/
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import {
FileText,
Download,
PenLine,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { StatCards } from '@/components/organisms/StatCards';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { MobileCard } from '@/components/organisms/MobileCard';
import {
getTaxInvoices,
getTaxInvoiceSummary,
downloadTaxInvoiceExcel,
} from './actions';
const ManualEntryModal = dynamic(
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
);
const JournalEntryModal = dynamic(
() => import('./JournalEntryModal').then(mod => ({ default: mod.JournalEntryModal })),
);
import type {
TaxInvoiceMgmtRecord,
InvoiceTab,
TaxInvoiceSummary,
} from './types';
import {
TAB_OPTIONS,
DATE_TYPE_OPTIONS,
TAX_TYPE_LABELS,
RECEIPT_TYPE_LABELS,
INVOICE_STATUS_MAP,
INVOICE_SOURCE_LABELS,
} from './types';
import { formatNumber } from '@/lib/utils/amount';
// ===== 분기 옵션 =====
const QUARTER_BUTTONS = [
{ value: 'Q1', label: '1분기', startMonth: 1, endMonth: 3 },
{ value: 'Q2', label: '2분기', startMonth: 4, endMonth: 6 },
{ value: 'Q3', label: '3분기', startMonth: 7, endMonth: 9 },
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
];
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
{ key: 'issueDate', label: '발급일자', className: 'text-center', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center', sortable: true },
{ key: 'taxType', label: '과세형태', className: 'text-center', sortable: true },
{ key: 'itemName', label: '품목', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'taxAmount', label: '세액', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계', className: 'text-right', sortable: true },
{ key: 'receiptType', label: '영수청구', className: 'text-center', sortable: true },
{ key: 'documentType', label: '문서형태', className: 'text-center', sortable: true },
{ key: 'issueType', label: '발급형태', className: 'text-center', sortable: true },
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
{ key: 'journal', label: '분개', className: 'text-center w-[80px]' },
];
// ===== 날짜 헬퍼 =====
function getQuarterDates(year: number, quarter: string) {
const q = QUARTER_BUTTONS.find((b) => b.value === quarter);
if (!q) return { start: '', end: '' };
const start = `${year}-${String(q.startMonth).padStart(2, '0')}-01`;
const lastDay = new Date(year, q.endMonth, 0).getDate();
const end = `${year}-${String(q.endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
function getCurrentQuarter(): string {
const month = new Date().getMonth() + 1;
if (month <= 3) return 'Q1';
if (month <= 6) return 'Q2';
if (month <= 9) return 'Q3';
return 'Q4';
}
export function TaxInvoiceManagement() {
// ===== 필터 상태 =====
const currentYear = new Date().getFullYear();
const [activeTab, setActiveTab] = useState<InvoiceTab>('sales');
const [dateType, setDateType] = useState('write_date');
const [selectedQuarter, setSelectedQuarter] = useState(getCurrentQuarter());
const [startDate, setStartDate] = useState(() => {
const q = getQuarterDates(currentYear, getCurrentQuarter());
return q.start;
});
const [endDate, setEndDate] = useState(() => {
const q = getQuarterDates(currentYear, getCurrentQuarter());
return q.end;
});
const [vendorSearch, setVendorSearch] = useState('');
// ===== 데이터 상태 =====
const [invoiceData, setInvoiceData] = useState<TaxInvoiceMgmtRecord[]>([]);
const [isLoading, setIsLoading] = useState(false);
const isInitialLoadDone = useRef(false);
const [currentPage, setCurrentPage] = useState(1);
const [pagination, setPagination] = useState({
currentPage: 1,
lastPage: 1,
perPage: 20,
total: 0,
});
// ===== 요약 상태 =====
const [summary, setSummary] = useState<TaxInvoiceSummary>({
salesSupplyAmount: 0,
salesTaxAmount: 0,
salesTotalAmount: 0,
salesCount: 0,
purchaseSupplyAmount: 0,
purchaseTaxAmount: 0,
purchaseTotalAmount: 0,
purchaseCount: 0,
});
// ===== 모달 상태 =====
const [showManualEntry, setShowManualEntry] = useState(false);
const [journalTarget, setJournalTarget] = useState<TaxInvoiceMgmtRecord | null>(null);
// ===== 분기 버튼 클릭 =====
const handleQuarterClick = useCallback((quarter: string) => {
setSelectedQuarter(quarter);
const dates = getQuarterDates(currentYear, quarter);
setStartDate(dates.start);
setEndDate(dates.end);
setCurrentPage(1);
}, [currentYear]);
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
try {
const [listResult, summaryResult] = await Promise.all([
getTaxInvoices({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
page: currentPage,
perPage: 20,
}),
getTaxInvoiceSummary({
dateType,
startDate,
endDate,
vendorSearch,
}),
]);
if (listResult.success) {
setInvoiceData(listResult.data);
setPagination(listResult.pagination);
} else {
toast.error(listResult.error || '목록 조회에 실패했습니다.');
}
if (summaryResult.success && summaryResult.data) {
setSummary(summaryResult.data);
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [activeTab, dateType, startDate, endDate, vendorSearch, currentPage]);
useEffect(() => {
loadData();
}, [loadData]);
// ===== 탭 변경 =====
const handleTabChange = useCallback((tab: InvoiceTab) => {
setActiveTab(tab);
setCurrentPage(1);
}, []);
// ===== 조회 버튼 =====
const handleSearch = useCallback(() => {
setCurrentPage(1);
loadData();
}, [loadData]);
// ===== 엑셀 다운로드 =====
const handleExcelDownload = useCallback(async () => {
const result = await downloadTaxInvoiceExcel({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
});
if (result.success && result.data) {
window.open(result.data.url, '_blank');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
}
}, [activeTab, dateType, startDate, endDate, vendorSearch]);
// ===== 수기 등록 완료 =====
const handleManualEntrySuccess = useCallback(() => {
setShowManualEntry(false);
loadData();
toast.success('세금계산서가 등록되었습니다.');
}, [loadData]);
// ===== 분개 완료 =====
const handleJournalSuccess = useCallback(() => {
setJournalTarget(null);
loadData();
}, [loadData]);
// ===== 기간 요약 계산 =====
const periodDifference = useMemo(() => {
return summary.salesTotalAmount - summary.purchaseTotalAmount;
}, [summary]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<TaxInvoiceMgmtRecord> = useMemo(
() => ({
title: '세금계산서 관리',
description: '홈택스에 신고된 세금계산서 매입/매출 내역을 조회하고 관리합니다',
icon: FileText,
basePath: '/accounting/tax-invoices',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: invoiceData,
totalCount: pagination.total,
}),
},
columns: tableColumns,
clientSideFiltering: false,
itemsPerPage: 20,
hideSearch: true,
showCheckbox: false,
// ===== 검색 영역 (beforeTableContent) =====
beforeTableContent: (
<div className="space-y-3">
{/* 검색 필터 카드 */}
<Card>
<CardContent className="p-4 space-y-3">
{/* Row1: 일자타입 + 날짜범위 + 분기 버튼 + 조회 */}
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
<Select value={dateType} onValueChange={setDateType}>
<SelectTrigger className="w-full lg:min-w-[120px] lg:w-auto h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
hidePresets
/>
<div className="flex gap-1">
{QUARTER_BUTTONS.map((q) => (
<Button
key={q.value}
size="sm"
variant={selectedQuarter === q.value ? 'default' : 'outline'}
onClick={() => handleQuarterClick(q.value)}
>
{q.label}
</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={vendorSearch}
onChange={(e) => setVendorSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="사업자 번호 또는 사업자명"
className="pl-9 h-9"
/>
</div>
</CardContent>
</Card>
{/* 요약 카드 5개 */}
<StatCards
stats={[
{ label: '매출 공급가액', value: `${formatNumber(summary.salesSupplyAmount)}` },
{ label: '매출 세액', value: `${formatNumber(summary.salesTaxAmount)}` },
{ label: '매입 과세 공급가액', value: `${formatNumber(summary.purchaseSupplyAmount)}` },
{ label: '매입 면세 공급가액', value: '0원' },
{ label: '매입 세액', value: `${formatNumber(summary.purchaseTaxAmount)}` },
]}
/>
{/* 매출/매입 탭 + 액션 버튼 */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<Tabs value={activeTab} onValueChange={(v) => handleTabChange(v as InvoiceTab)}>
<TabsList>
{TAB_OPTIONS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label} {t.value === 'sales' ? summary.salesCount : summary.purchaseCount}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowManualEntry(true)}>
<PenLine className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</div>
),
// 탭은 beforeTableContent에서 수동 렌더링 (카드와 테이블 사이)
// ===== 테이블 행 렌더링 =====
renderTableRow: (
item: TaxInvoiceMgmtRecord,
_index: number,
_globalIndex: number,
_handlers: SelectionHandlers & RowClickHandlers<TaxInvoiceMgmtRecord>
) => (
<TableRow
key={item.id}
className={`hover:bg-muted/50 ${item.source === 'manual' ? 'bg-yellow-50/50' : ''}`}
>
<TableCell className="text-center text-sm">{item.writeDate}</TableCell>
<TableCell className="text-center text-sm">{item.issueDate || '-'}</TableCell>
<TableCell className="text-sm">{item.vendorName}</TableCell>
<TableCell className="text-center text-sm">{item.vendorBusinessNumber}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{TAX_TYPE_LABELS[item.taxType]}
</Badge>
</TableCell>
<TableCell className="text-sm">{item.itemName || '-'}</TableCell>
<TableCell className="text-right text-sm">{formatNumber(item.supplyAmount)}</TableCell>
<TableCell className="text-right text-sm">{formatNumber(item.taxAmount)}</TableCell>
<TableCell className="text-right text-sm font-medium">{formatNumber(item.totalAmount)}</TableCell>
<TableCell className="text-center text-sm">
<span className="inline-flex items-center gap-1">
<span className={`inline-block w-2 h-2 rounded-full ${item.source === 'manual' ? 'bg-purple-500' : 'bg-blue-500'}`} />
{RECEIPT_TYPE_LABELS[item.receiptType]}
</span>
</TableCell>
<TableCell className="text-center text-sm">{item.documentNumber || '-'}</TableCell>
<TableCell className="text-center text-sm">{INVOICE_SOURCE_LABELS[item.source]}</TableCell>
<TableCell className="text-center">
<Badge className={`text-xs ${INVOICE_STATUS_MAP[item.status].color}`}>
{INVOICE_STATUS_MAP[item.status].label}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
setJournalTarget(item);
}}
>
</Button>
</TableCell>
</TableRow>
),
// ===== 모바일 카드 렌더링 =====
renderMobileCard: (
item: TaxInvoiceMgmtRecord,
_index: number,
_globalIndex: number,
_handlers: SelectionHandlers & RowClickHandlers<TaxInvoiceMgmtRecord>
) => (
<MobileCard
key={item.id}
title={item.vendorName}
subtitle={item.documentNumber || item.writeDate}
badge={INVOICE_STATUS_MAP[item.status].label}
badgeVariant="outline"
onClick={() => setJournalTarget(item)}
details={[
{ label: '작성일자', value: item.writeDate },
{ label: '공급가액', value: `${formatNumber(item.supplyAmount)}` },
{ label: '세액', value: `${formatNumber(item.taxAmount)}` },
{ label: '합계', value: `${formatNumber(item.totalAmount)}` },
{ label: '과세여부', value: TAX_TYPE_LABELS[item.taxType] },
{ label: '소스', value: INVOICE_SOURCE_LABELS[item.source] },
]}
/>
),
// ===== 범례 (테이블 안) =====
tableFooter: (
<TableRow>
<TableCell colSpan={14} className="py-2">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 bg-purple-500 rounded-full" />
<span> </span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 bg-blue-500 rounded-full" />
<span> </span>
</div>
</div>
</TableCell>
</TableRow>
),
// ===== 기간 요약 (테이블 뒤) =====
afterTableContent: () => (
<Card>
<CardContent className="p-4">
<div className="text-sm font-semibold mb-3"> </div>
{/* 모바일: 세로 스택, sm 이상: 가로 배치 */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-2 sm:gap-4 text-center">
<div className="w-full sm:w-auto">
<div className="text-xs text-muted-foreground mb-1"> ( + )</div>
<div className="text-lg sm:text-xl font-bold">{formatNumber(summary.salesTotalAmount)}</div>
</div>
<div className="text-xl sm:text-2xl font-bold text-muted-foreground"></div>
<div className="w-full sm:w-auto">
<div className="text-xs text-muted-foreground mb-1"> ( + )</div>
<div className="text-lg sm:text-xl font-bold">{formatNumber(summary.purchaseTotalAmount)}</div>
</div>
<div className="text-xl sm:text-2xl font-bold text-muted-foreground">=</div>
<div className="w-full sm:w-auto">
<div className="text-xs text-muted-foreground mb-1"> </div>
<div className={`text-lg sm:text-xl font-bold ${periodDifference >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
{formatNumber(periodDifference)}
</div>
</div>
</div>
</CardContent>
</Card>
),
}),
[
invoiceData,
pagination,
summary,
dateType,
startDate,
endDate,
selectedQuarter,
vendorSearch,
periodDifference,
handleQuarterClick,
handleSearch,
handleExcelDownload,
]
);
return (
<>
<UniversalListPage
config={config}
initialData={invoiceData}
externalPagination={{
currentPage: pagination.currentPage,
totalPages: pagination.lastPage,
totalItems: pagination.total,
itemsPerPage: pagination.perPage,
onPageChange: setCurrentPage,
}}
externalIsLoading={isLoading}
/>
{/* 수기 입력 팝업 */}
<ManualEntryModal
open={showManualEntry}
onOpenChange={setShowManualEntry}
onSuccess={handleManualEntrySuccess}
defaultDivision={activeTab}
/>
{/* 분개 수정 팝업 */}
{journalTarget && (
<JournalEntryModal
open={!!journalTarget}
onOpenChange={(open: boolean) => !open && setJournalTarget(null)}
invoice={journalTarget}
onSuccess={handleJournalSuccess}
/>
)}
</>
);
}