ERP-node/frontend/components/tax-invoice/TaxInvoiceList.tsx

833 lines
30 KiB
TypeScript

"use client";
/**
* 세금계산서 목록 컴포넌트
* 세금계산서 목록 조회, 검색, 필터링 기능
*/
import { useState, useEffect, useCallback } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
Plus,
Search,
Filter,
FileText,
Eye,
Edit,
Trash2,
Send,
CheckCircle,
XCircle,
Clock,
RefreshCw,
Paperclip,
Image,
File,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import {
getTaxInvoiceList,
deleteTaxInvoice,
issueTaxInvoice,
cancelTaxInvoice,
TaxInvoice,
TaxInvoiceListParams,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { TaxInvoiceForm } from "./TaxInvoiceForm";
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
// 상태 뱃지 색상
const statusBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
issued: "default",
sent: "secondary",
cancelled: "destructive",
};
// 상태 라벨
const statusLabels: Record<string, string> = {
draft: "임시저장",
issued: "발행완료",
sent: "전송완료",
cancelled: "취소됨",
};
// 유형 라벨
const typeLabels: Record<string, string> = {
sales: "매출",
purchase: "매입",
};
// 컬럼 정의
interface ColumnDef {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
filterType?: "text" | "select";
filterOptions?: { value: string; label: string }[];
width?: string;
align?: "left" | "center" | "right";
}
const columns: ColumnDef[] = [
{ key: "invoice_number", label: "계산서번호", sortable: true, filterable: true, filterType: "text", width: "120px" },
{ key: "invoice_type", label: "유형", sortable: true, filterable: true, filterType: "select",
filterOptions: [{ value: "sales", label: "매출" }, { value: "purchase", label: "매입" }], width: "70px" },
{ key: "cost_type", label: "비용유형", sortable: true, filterable: true, filterType: "select",
filterOptions: Object.entries(costTypeLabels).map(([value, label]) => ({ value, label })), width: "90px" },
{ key: "invoice_status", label: "상태", sortable: true, filterable: true, filterType: "select",
filterOptions: [
{ value: "draft", label: "임시저장" }, { value: "issued", label: "발행완료" },
{ value: "sent", label: "전송완료" }, { value: "cancelled", label: "취소됨" }
], width: "90px" },
{ key: "invoice_date", label: "작성일", sortable: true, filterable: true, filterType: "text", width: "100px" },
{ key: "buyer_name", label: "공급받는자", sortable: true, filterable: true, filterType: "text" },
{ key: "attachments", label: "첨부", sortable: false, filterable: false, width: "50px", align: "center" },
{ key: "supply_amount", label: "공급가액", sortable: true, filterable: false, align: "right" },
{ key: "tax_amount", label: "세액", sortable: true, filterable: false, align: "right" },
{ key: "total_amount", label: "합계", sortable: true, filterable: false, align: "right" },
];
export function TaxInvoiceList() {
// 상태
const [invoices, setInvoices] = useState<TaxInvoice[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
});
// 필터 상태
const [filters, setFilters] = useState<TaxInvoiceListParams>({
page: 1,
pageSize: 20,
});
const [searchText, setSearchText] = useState("");
// 정렬 상태
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 컬럼별 필터 상태
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
const [activeFilterColumn, setActiveFilterColumn] = useState<string | null>(null);
// 모달 상태
const [showForm, setShowForm] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState<TaxInvoice | null>(null);
const [editMode, setEditMode] = useState(false);
// 확인 다이얼로그 상태
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
type: "delete" | "issue" | "cancel";
invoice: TaxInvoice | null;
}>({
open: false,
type: "delete",
invoice: null,
});
// 데이터 로드
const loadData = useCallback(async () => {
setLoading(true);
try {
// 컬럼 필터를 API 파라미터에 추가
const apiFilters: TaxInvoiceListParams = {
...filters,
invoice_type: columnFilters.invoice_type as "sales" | "purchase" | undefined,
invoice_status: columnFilters.invoice_status,
cost_type: columnFilters.cost_type as CostType | undefined,
search: columnFilters.invoice_number || columnFilters.buyer_name || searchText || undefined,
};
const response = await getTaxInvoiceList(apiFilters);
if (response.success) {
let data = response.data;
// 클라이언트 사이드 정렬 적용
if (sortConfig) {
data = [...data].sort((a, b) => {
const aVal = a[sortConfig.key as keyof TaxInvoice];
const bVal = b[sortConfig.key as keyof TaxInvoice];
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
// 숫자 비교
if (typeof aVal === "number" && typeof bVal === "number") {
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
}
// 문자열 비교
const strA = String(aVal).toLowerCase();
const strB = String(bVal).toLowerCase();
if (sortConfig.direction === "asc") {
return strA.localeCompare(strB, "ko");
}
return strB.localeCompare(strA, "ko");
});
}
// 클라이언트 사이드 필터 적용 (날짜 필터)
if (columnFilters.invoice_date) {
data = data.filter((item) =>
item.invoice_date?.includes(columnFilters.invoice_date)
);
}
setInvoices(data);
setPagination(response.pagination);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
}, [filters, sortConfig, columnFilters, searchText]);
useEffect(() => {
loadData();
}, [loadData]);
// 정렬 핸들러
const handleSort = (columnKey: string) => {
setSortConfig((prev) => {
if (prev?.key === columnKey) {
// 같은 컬럼 클릭: asc -> desc -> null 순환
if (prev.direction === "asc") return { key: columnKey, direction: "desc" };
return null;
}
// 새 컬럼: asc로 시작
return { key: columnKey, direction: "asc" };
});
};
// 컬럼 필터 핸들러
const handleColumnFilter = (columnKey: string, value: string) => {
setColumnFilters((prev) => {
if (!value) {
const { [columnKey]: _, ...rest } = prev;
return rest;
}
return { ...prev, [columnKey]: value };
});
setFilters((prev) => ({ ...prev, page: 1 })); // 필터 변경 시 첫 페이지로
};
// 필터 초기화
const clearColumnFilter = (columnKey: string) => {
setColumnFilters((prev) => {
const { [columnKey]: _, ...rest } = prev;
return rest;
});
setActiveFilterColumn(null);
};
// 모든 필터 초기화
const clearAllFilters = () => {
setColumnFilters({});
setSortConfig(null);
setSearchText("");
setFilters({ page: 1, pageSize: 20 });
};
// 정렬 아이콘 렌더링
const renderSortIcon = (columnKey: string) => {
if (sortConfig?.key !== columnKey) {
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-30" />;
}
return sortConfig.direction === "asc"
? <ArrowUp className="ml-1 h-3 w-3 text-primary" />
: <ArrowDown className="ml-1 h-3 w-3 text-primary" />;
};
// 검색
const handleSearch = () => {
setFilters((prev) => ({ ...prev, search: searchText, page: 1 }));
};
// 필터 변경
const handleFilterChange = (key: keyof TaxInvoiceListParams, value: string | undefined) => {
setFilters((prev) => ({
...prev,
[key]: value === "all" ? undefined : value,
page: 1,
}));
};
// 새 세금계산서
const handleNew = () => {
setSelectedInvoice(null);
setEditMode(false);
setShowForm(true);
};
// 상세 보기
const handleView = (invoice: TaxInvoice) => {
setSelectedInvoice(invoice);
setShowDetail(true);
};
// 수정
const handleEdit = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 수정할 수 있습니다.");
return;
}
setSelectedInvoice(invoice);
setEditMode(true);
setShowForm(true);
};
// 삭제 확인
const handleDeleteConfirm = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 삭제할 수 있습니다.");
return;
}
setConfirmDialog({ open: true, type: "delete", invoice });
};
// 발행 확인
const handleIssueConfirm = (invoice: TaxInvoice) => {
if (invoice.invoice_status !== "draft") {
toast.warning("임시저장 상태의 세금계산서만 발행할 수 있습니다.");
return;
}
setConfirmDialog({ open: true, type: "issue", invoice });
};
// 취소 확인
const handleCancelConfirm = (invoice: TaxInvoice) => {
if (!["draft", "issued"].includes(invoice.invoice_status)) {
toast.warning("취소할 수 없는 상태입니다.");
return;
}
setConfirmDialog({ open: true, type: "cancel", invoice });
};
// 확인 다이얼로그 실행
const handleConfirmAction = async () => {
const { type, invoice } = confirmDialog;
if (!invoice) return;
try {
if (type === "delete") {
const response = await deleteTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 삭제되었습니다.");
loadData();
}
} else if (type === "issue") {
const response = await issueTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 발행되었습니다.");
loadData();
}
} else if (type === "cancel") {
const response = await cancelTaxInvoice(invoice.id);
if (response.success) {
toast.success("세금계산서가 취소되었습니다.");
loadData();
}
}
} catch (error: any) {
toast.error("작업 실패", { description: error.message });
} finally {
setConfirmDialog({ open: false, type: "delete", invoice: null });
}
};
// 폼 저장 완료
const handleFormSave = () => {
setShowForm(false);
setSelectedInvoice(null);
loadData();
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 날짜 포맷
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), "yyyy-MM-dd", { locale: ko });
} catch {
return dateString;
}
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"> </h1>
<Button onClick={handleNew}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 필터 영역 */}
<Card>
<CardContent className="pt-4">
<div className="flex flex-wrap items-end gap-4">
{/* 검색 */}
<div className="min-w-[200px] flex-1">
<Label className="text-xs"></Label>
<div className="flex gap-2">
<Input
placeholder="계산서번호, 거래처명 검색"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
className="h-9"
/>
<Button variant="outline" size="sm" onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* 유형 필터 */}
<div className="w-[120px]">
<Label className="text-xs"></Label>
<Select
value={filters.invoice_type || "all"}
onValueChange={(v) => handleFilterChange("invoice_type", v as "sales" | "purchase")}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="sales"></SelectItem>
<SelectItem value="purchase"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 상태 필터 */}
<div className="w-[120px]">
<Label className="text-xs"></Label>
<Select
value={filters.invoice_status || "all"}
onValueChange={(v) => handleFilterChange("invoice_status", v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft"></SelectItem>
<SelectItem value="issued"></SelectItem>
<SelectItem value="sent"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 새로고침 */}
<Button variant="outline" size="sm" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
{/* 필터 초기화 */}
{(Object.keys(columnFilters).length > 0 || sortConfig) && (
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="text-muted-foreground">
<X className="mr-1 h-4 w-4" />
</Button>
)}
</div>
{/* 활성 필터 표시 */}
{Object.keys(columnFilters).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(columnFilters).map(([key, value]) => {
const column = columns.find((c) => c.key === key);
let displayValue = value;
if (column?.filterOptions) {
displayValue = column.filterOptions.find((o) => o.value === value)?.label || value;
}
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{column?.label}: {displayValue}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => clearColumnFilter(key)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
);
})}
</div>
)}
</CardContent>
</Card>
{/* 테이블 */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.key}
className={`
${column.width ? `w-[${column.width}]` : ""}
${column.align === "center" ? "text-center" : ""}
${column.align === "right" ? "text-right" : ""}
`}
style={{ width: column.width }}
>
<div className={`flex items-center gap-1 ${column.align === "right" ? "justify-end" : column.align === "center" ? "justify-center" : ""}`}>
{/* 컬럼 필터 (filterable인 경우) */}
{column.filterable && (
<Popover
open={activeFilterColumn === column.key}
onOpenChange={(open) => setActiveFilterColumn(open ? column.key : null)}
>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 p-0 ${columnFilters[column.key] ? "text-primary" : "text-muted-foreground opacity-50 hover:opacity-100"}`}
>
<Filter className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{column.label} </div>
{column.filterType === "select" ? (
<Select
value={columnFilters[column.key] || ""}
onValueChange={(v) => {
handleColumnFilter(column.key, v);
setActiveFilterColumn(null);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{column.filterOptions?.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder={`${column.label} 검색...`}
value={columnFilters[column.key] || ""}
onChange={(e) => handleColumnFilter(column.key, e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setActiveFilterColumn(null)}
className="h-8 text-xs"
autoFocus
/>
)}
{columnFilters[column.key] && (
<Button
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
onClick={() => clearColumnFilter(column.key)}
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
{/* 컬럼 라벨 + 정렬 */}
{column.sortable ? (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 font-medium hover:bg-transparent"
onClick={() => handleSort(column.key)}
>
{column.label}
{renderSortIcon(column.key)}
</Button>
) : (
<span>{column.label}</span>
)}
</div>
</TableHead>
))}
<TableHead className="w-[150px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
...
</TableCell>
</TableRow>
) : invoices.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
.
</TableCell>
</TableRow>
) : (
invoices.map((invoice) => (
<TableRow key={invoice.id} className="cursor-pointer hover:bg-muted/50">
<TableCell className="font-mono text-sm">{invoice.invoice_number}</TableCell>
<TableCell>
<Badge variant={invoice.invoice_type === "sales" ? "default" : "secondary"}>
{typeLabels[invoice.invoice_type]}
</Badge>
</TableCell>
<TableCell>
{invoice.cost_type ? (
<Badge variant="outline" className="text-xs">
{costTypeLabels[invoice.cost_type as CostType]}
</Badge>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant[invoice.invoice_status]}>
{statusLabels[invoice.invoice_status]}
</Badge>
</TableCell>
<TableCell>{formatDate(invoice.invoice_date)}</TableCell>
<TableCell className="max-w-[200px] truncate">
{invoice.buyer_name || "-"}
</TableCell>
<TableCell className="text-center">
{invoice.attachments && invoice.attachments.length > 0 ? (
<div className="flex items-center justify-center gap-1">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{invoice.attachments.length}
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(invoice.supply_amount)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(invoice.tax_amount)}
</TableCell>
<TableCell className="text-right font-mono font-semibold">
{formatAmount(invoice.total_amount)}
</TableCell>
<TableCell>
<div className="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleView(invoice)}
title="상세보기"
>
<Eye className="h-4 w-4" />
</Button>
{invoice.invoice_status === "draft" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(invoice)}
title="수정"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleIssueConfirm(invoice)}
title="발행"
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleDeleteConfirm(invoice)}
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
{invoice.invoice_status === "issued" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-orange-600"
onClick={() => handleCancelConfirm(invoice)}
title="취소"
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 페이지네이션 */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{pagination.total} {(pagination.page - 1) * pagination.pageSize + 1}-
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! - 1 }))}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page! + 1 }))}
>
</Button>
</div>
</div>
)}
{/* 세금계산서 작성/수정 폼 */}
{showForm && (
<TaxInvoiceForm
open={showForm}
onClose={() => setShowForm(false)}
onSave={handleFormSave}
invoice={editMode ? selectedInvoice : null}
/>
)}
{/* 세금계산서 상세 */}
{showDetail && selectedInvoice && (
<TaxInvoiceDetail
open={showDetail}
onClose={() => setShowDetail(false)}
invoiceId={selectedInvoice.id}
/>
)}
{/* 확인 다이얼로그 */}
<Dialog
open={confirmDialog.open}
onOpenChange={(open) => !open && setConfirmDialog({ ...confirmDialog, open: false })}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{confirmDialog.type === "delete" && "세금계산서 삭제"}
{confirmDialog.type === "issue" && "세금계산서 발행"}
{confirmDialog.type === "cancel" && "세금계산서 취소"}
</DialogTitle>
<DialogDescription>
{confirmDialog.type === "delete" &&
"이 세금계산서를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."}
{confirmDialog.type === "issue" &&
"이 세금계산서를 발행하시겠습니까? 발행 후에는 수정할 수 없습니다."}
{confirmDialog.type === "cancel" && "이 세금계산서를 취소하시겠습니까?"}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setConfirmDialog({ ...confirmDialog, open: false })}
>
</Button>
<Button
variant={confirmDialog.type === "delete" ? "destructive" : "default"}
onClick={handleConfirmAction}
>
{confirmDialog.type === "delete" && "삭제"}
{confirmDialog.type === "issue" && "발행"}
{confirmDialog.type === "cancel" && "취소 처리"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}