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

622 lines
26 KiB
TypeScript

"use client";
/**
* 세금계산서 상세 보기 컴포넌트
* PDF 출력 및 첨부파일 다운로드 기능 포함
*/
import { useState, useEffect, useRef } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
Printer,
Download,
FileText,
Image,
File,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import {
getTaxInvoiceById,
TaxInvoice,
TaxInvoiceItem,
TaxInvoiceAttachment,
} from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client";
interface TaxInvoiceDetailProps {
open: boolean;
onClose: () => void;
invoiceId: string;
}
// 상태 라벨
const statusLabels: Record<string, string> = {
draft: "임시저장",
issued: "발행완료",
sent: "전송완료",
cancelled: "취소됨",
};
// 상태 색상
const statusColors: Record<string, string> = {
draft: "bg-gray-100 text-gray-800",
issued: "bg-green-100 text-green-800",
sent: "bg-blue-100 text-blue-800",
cancelled: "bg-red-100 text-red-800",
};
export function TaxInvoiceDetail({ open, onClose, invoiceId }: TaxInvoiceDetailProps) {
const [invoice, setInvoice] = useState<TaxInvoice | null>(null);
const [items, setItems] = useState<TaxInvoiceItem[]>([]);
const [loading, setLoading] = useState(true);
const [pdfLoading, setPdfLoading] = useState(false);
const printRef = useRef<HTMLDivElement>(null);
// 데이터 로드
useEffect(() => {
if (open && invoiceId) {
loadData();
}
}, [open, invoiceId]);
const loadData = async () => {
setLoading(true);
try {
const response = await getTaxInvoiceById(invoiceId);
if (response.success) {
setInvoice(response.data.invoice);
setItems(response.data.items);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 날짜 포맷
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy년 MM월 dd일", { locale: ko });
} catch {
return dateString;
}
};
// 파일 미리보기 URL 생성 (objid 기반) - 이미지용
const getFilePreviewUrl = (attachment: TaxInvoiceAttachment) => {
// objid가 숫자형이면 API를 통해 미리보기
if (attachment.id && !attachment.id.includes("-")) {
// apiClient의 baseURL 사용
const baseURL = apiClient.defaults.baseURL || "";
return `${baseURL}/files/preview/${attachment.id}`;
}
return attachment.file_path;
};
// 공통 인쇄용 HTML 생성 함수
const generatePrintHtml = (autoPrint: boolean = false) => {
if (!invoice) return "";
const invoiceTypeText = invoice.invoice_type === "sales" ? "매출" : "매입";
const itemsHtml = items.map((item, index) => `
<tr>
<td style="text-align:center">${index + 1}</td>
<td style="text-align:center">${item.item_date?.split("T")[0] || "-"}</td>
<td>${item.item_name}</td>
<td>${item.item_spec || "-"}</td>
<td style="text-align:right">${item.quantity}</td>
<td style="text-align:right">${formatAmount(item.unit_price)}</td>
<td style="text-align:right">${formatAmount(item.supply_amount)}</td>
<td style="text-align:right">${formatAmount(item.tax_amount)}</td>
</tr>
`).join("");
return `
<!DOCTYPE html>
<html>
<head>
<title>세금계산서_${invoice.invoice_number}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 30px; background: #fff; color: #333; }
.container { max-width: 800px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 3px solid #333; }
.header h1 { font-size: 28px; margin-bottom: 10px; }
.header .invoice-number { font-size: 14px; color: #666; }
.header .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; margin-top: 10px; }
.status-draft { background: #f3f4f6; color: #374151; }
.status-issued { background: #d1fae5; color: #065f46; }
.status-sent { background: #dbeafe; color: #1e40af; }
.status-cancelled { background: #fee2e2; color: #991b1b; }
.parties { display: flex; gap: 20px; margin-bottom: 30px; }
.party { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.party h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.party-row { display: flex; margin-bottom: 6px; font-size: 13px; }
.party-label { width: 80px; color: #666; }
.party-value { flex: 1; }
.items-section { margin-bottom: 30px; }
.items-section h3 { font-size: 14px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #333; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f9fafb; font-weight: 600; }
.total-section { display: flex; justify-content: flex-end; }
.total-box { width: 280px; border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.total-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 13px; }
.total-row.grand { font-size: 16px; font-weight: bold; padding-top: 8px; border-top: 1px solid #ddd; margin-top: 8px; }
.total-row.grand .value { color: #1d4ed8; }
.remarks { margin-top: 20px; padding: 12px; background: #f9fafb; border-radius: 8px; font-size: 13px; }
.footer { margin-top: 20px; font-size: 11px; color: #666; display: flex; justify-content: space-between; }
.attachments { margin-top: 20px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; }
.attachments h3 { font-size: 14px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #eee; }
.attachments ul { list-style: none; font-size: 12px; }
.attachments li { padding: 4px 0; }
@media print {
body { padding: 15px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>세금계산서 (${invoiceTypeText})</h1>
<div class="invoice-number">계산서번호: ${invoice.invoice_number}</div>
<span class="status status-${invoice.invoice_status}">${statusLabels[invoice.invoice_status]}</span>
</div>
<div class="parties">
<div class="party">
<h3>공급자</h3>
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.supplier_business_no || "-"}</span></div>
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.supplier_name || "-"}</span></div>
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.supplier_ceo_name || "-"}</span></div>
<div class="party-row"><span class="party-label">업태/종목</span><span class="party-value">${invoice.supplier_business_type || "-"} / ${invoice.supplier_business_item || "-"}</span></div>
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.supplier_address || "-"}</span></div>
</div>
<div class="party">
<h3>공급받는자</h3>
<div class="party-row"><span class="party-label">사업자번호</span><span class="party-value">${invoice.buyer_business_no || "-"}</span></div>
<div class="party-row"><span class="party-label">상호</span><span class="party-value">${invoice.buyer_name || "-"}</span></div>
<div class="party-row"><span class="party-label">대표자</span><span class="party-value">${invoice.buyer_ceo_name || "-"}</span></div>
<div class="party-row"><span class="party-label">이메일</span><span class="party-value">${invoice.buyer_email || "-"}</span></div>
<div class="party-row"><span class="party-label">주소</span><span class="party-value">${invoice.buyer_address || "-"}</span></div>
</div>
</div>
<div class="items-section">
<h3>품목 내역</h3>
<table>
<thead>
<tr>
<th style="width:40px">No</th>
<th style="width:80px">일자</th>
<th>품목명</th>
<th style="width:70px">규격</th>
<th style="width:50px">수량</th>
<th style="width:80px">단가</th>
<th style="width:90px">공급가액</th>
<th style="width:70px">세액</th>
</tr>
</thead>
<tbody>
${itemsHtml || '<tr><td colspan="8" style="text-align:center;color:#999">품목 내역이 없습니다.</td></tr>'}
</tbody>
</table>
</div>
<div class="total-section">
<div class="total-box">
<div class="total-row"><span>공급가액</span><span>${formatAmount(invoice.supply_amount)}원</span></div>
<div class="total-row"><span>세액</span><span>${formatAmount(invoice.tax_amount)}원</span></div>
<div class="total-row grand"><span>합계금액</span><span class="value">${formatAmount(invoice.total_amount)}원</span></div>
</div>
</div>
${invoice.remarks ? `<div class="remarks"><strong>비고:</strong> ${invoice.remarks}</div>` : ""}
${invoice.attachments && invoice.attachments.length > 0 ? `
<div class="attachments">
<h3>첨부파일 (${invoice.attachments.length}개)</h3>
<ul>
${invoice.attachments.map(file => `<li>📄 ${file.file_name}</li>`).join("")}
</ul>
</div>
` : ""}
<div class="footer">
<span>작성일: ${formatDate(invoice.invoice_date)}</span>
${invoice.issue_date ? `<span>발행일: ${formatDate(invoice.issue_date)}</span>` : ""}
</div>
</div>
${autoPrint ? `<script>window.onload = function() { window.print(); };</script>` : ""}
</body>
</html>
`;
};
// 인쇄
const handlePrint = () => {
if (!invoice) return;
const printWindow = window.open("", "_blank");
if (!printWindow) {
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
return;
}
printWindow.document.write(generatePrintHtml(true));
printWindow.document.close();
};
// PDF 다운로드 (인쇄 다이얼로그 사용)
const handleDownloadPdf = async () => {
if (!invoice) return;
setPdfLoading(true);
try {
const printWindow = window.open("", "_blank");
if (!printWindow) {
toast.error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
return;
}
printWindow.document.write(generatePrintHtml(true));
printWindow.document.close();
toast.success("PDF 인쇄 창이 열렸습니다. 'PDF로 저장'을 선택하세요.");
} catch (error: any) {
console.error("PDF 생성 오류:", error);
toast.error("PDF 생성 실패", { description: error.message });
} finally {
setPdfLoading(false);
}
};
// 파일 아이콘
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return <Image className="h-4 w-4" />;
if (fileType.includes("pdf")) return <FileText className="h-4 w-4" />;
return <File className="h-4 w-4" />;
};
// 파일 다운로드 (인증 토큰 포함)
const handleDownload = async (attachment: TaxInvoiceAttachment) => {
try {
// objid가 숫자형이면 API를 통해 다운로드
if (attachment.id && !attachment.id.includes("-")) {
const response = await apiClient.get(`/files/download/${attachment.id}`, {
responseType: "blob",
});
// Blob으로 다운로드
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = attachment.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} else {
// 직접 경로로 다운로드
window.open(attachment.file_path, "_blank");
}
} catch (error: any) {
toast.error("파일 다운로드 실패", { description: error.message });
}
};
if (loading) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[800px]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="flex h-[400px] items-center justify-center">
<span className="text-muted-foreground"> ...</span>
</div>
</DialogContent>
</Dialog>
);
}
if (!invoice) {
return null;
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] max-w-[800px] overflow-hidden p-0" aria-describedby={undefined}>
<DialogHeader className="flex flex-row items-center justify-between border-b px-6 py-4">
<DialogTitle> </DialogTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-1 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={pdfLoading}
>
{pdfLoading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Download className="mr-1 h-4 w-4" />
)}
PDF
</Button>
</div>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-120px)]">
<div className="p-6" ref={printRef}>
<div className="invoice-container">
{/* 헤더 */}
<div className="mb-6 text-center">
<h1 className="mb-2 text-2xl font-bold">
{invoice.invoice_type === "sales" ? "세금계산서 (매출)" : "세금계산서 (매입)"}
</h1>
<p className="text-muted-foreground text-sm">
: {invoice.invoice_number}
</p>
<Badge className={statusColors[invoice.invoice_status]}>
{statusLabels[invoice.invoice_status]}
</Badge>
</div>
{/* 공급자 / 공급받는자 정보 */}
<div className="mb-6 grid grid-cols-2 gap-6">
{/* 공급자 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 border-b pb-2 font-semibold"></h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_business_no || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.supplier_ceo_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24">/</span>
<span>
{invoice.supplier_business_type || "-"} / {invoice.supplier_business_item || "-"}
</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span className="flex-1">{invoice.supplier_address || "-"}</span>
</div>
</div>
</div>
{/* 공급받는자 */}
<div className="rounded-lg border p-4">
<h3 className="mb-3 border-b pb-2 font-semibold"></h3>
<div className="space-y-2 text-sm">
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_business_no || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_ceo_name || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span>{invoice.buyer_email || "-"}</span>
</div>
<div className="flex">
<span className="text-muted-foreground w-24"></span>
<span className="flex-1">{invoice.buyer_address || "-"}</span>
</div>
</div>
</div>
</div>
{/* 품목 내역 */}
<div className="mb-6">
<h3 className="mb-3 border-b-2 border-gray-800 pb-2 font-semibold"> </h3>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{item.item_date?.split("T")[0] || "-"}</TableCell>
<TableCell>{item.item_name}</TableCell>
<TableCell>{item.item_spec || "-"}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.unit_price)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.supply_amount)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(item.tax_amount)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground py-4 text-center">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 합계 */}
<div className="flex justify-end">
<div className="w-[300px] space-y-2 rounded-lg border p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(invoice.supply_amount)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(invoice.tax_amount)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span></span>
<span className="font-mono text-primary">
{formatAmount(invoice.total_amount)}
</span>
</div>
</div>
</div>
{/* 비고 */}
{invoice.remarks && (
<div className="mt-6">
<h3 className="mb-2 font-semibold"></h3>
<p className="text-muted-foreground rounded-lg border p-3 text-sm">
{invoice.remarks}
</p>
</div>
)}
{/* 날짜 정보 */}
<div className="text-muted-foreground mt-6 flex justify-between text-xs">
<span>: {formatDate(invoice.invoice_date)}</span>
{invoice.issue_date && <span>: {formatDate(invoice.issue_date)}</span>}
</div>
</div>
{/* 첨부파일 */}
{invoice.attachments && invoice.attachments.length > 0 && (
<div className="mt-6">
<Separator className="mb-4" />
<h3 className="mb-3 font-semibold"> ({invoice.attachments.length})</h3>
{/* 이미지 미리보기 */}
{invoice.attachments.some((f) => f.file_type?.startsWith("image/")) && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{invoice.attachments
.filter((f) => f.file_type?.startsWith("image/"))
.map((file) => (
<div
key={file.id}
className="group relative aspect-square overflow-hidden rounded-lg border bg-gray-50"
>
<img
src={getFilePreviewUrl(file)}
alt={file.file_name}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
<div className="w-full p-2">
<p className="truncate text-xs text-white">{file.file_name}</p>
<Button
variant="secondary"
size="sm"
className="mt-1 h-7 w-full text-xs"
onClick={() => handleDownload(file)}
>
<Download className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
{/* 기타 파일 목록 */}
{invoice.attachments.some((f) => !f.file_type?.startsWith("image/")) && (
<div className="space-y-2">
{invoice.attachments
.filter((f) => !f.file_type?.startsWith("image/"))
.map((file) => (
<div
key={file.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
{getFileIcon(file.file_type)}
<span className="text-sm">{file.file_name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(file)}
>
<Download className="mr-1 h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}