833 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|