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

729 lines
28 KiB
TypeScript

"use client";
/**
* 세금계산서 작성/수정 폼
* 파일 첨부 기능 포함
*/
import { useState, useEffect, useCallback } from "react";
import { format } from "date-fns";
import {
Plus,
Trash2,
Upload,
X,
FileText,
Image,
File,
Paperclip,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
createTaxInvoice,
updateTaxInvoice,
getTaxInvoiceById,
TaxInvoice,
TaxInvoiceAttachment,
CreateTaxInvoiceDto,
CreateTaxInvoiceItemDto,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client";
interface TaxInvoiceFormProps {
open: boolean;
onClose: () => void;
onSave: () => void;
invoice?: TaxInvoice | null;
}
// 품목 초기값
const emptyItem: CreateTaxInvoiceItemDto = {
item_date: format(new Date(), "yyyy-MM-dd"),
item_name: "",
item_spec: "",
quantity: 1,
unit_price: 0,
supply_amount: 0,
tax_amount: 0,
remarks: "",
};
export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFormProps) {
// 폼 상태
const [formData, setFormData] = useState<CreateTaxInvoiceDto>({
invoice_type: "sales",
invoice_date: format(new Date(), "yyyy-MM-dd"),
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [{ ...emptyItem }],
});
// 첨부파일 상태
const [attachments, setAttachments] = useState<TaxInvoiceAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState("basic");
// 수정 모드일 때 데이터 로드
useEffect(() => {
if (invoice) {
loadInvoiceData(invoice.id);
} else {
// 새 세금계산서
setFormData({
invoice_type: "sales",
invoice_date: format(new Date(), "yyyy-MM-dd"),
supply_amount: 0,
tax_amount: 0,
total_amount: 0,
items: [{ ...emptyItem }],
});
setAttachments([]);
}
}, [invoice]);
// 세금계산서 데이터 로드
const loadInvoiceData = async (id: string) => {
try {
const response = await getTaxInvoiceById(id);
if (response.success) {
const { invoice: inv, items } = response.data;
setFormData({
invoice_type: inv.invoice_type,
invoice_date: inv.invoice_date?.split("T")[0] || "",
supplier_business_no: inv.supplier_business_no,
supplier_name: inv.supplier_name,
supplier_ceo_name: inv.supplier_ceo_name,
supplier_address: inv.supplier_address,
supplier_business_type: inv.supplier_business_type,
supplier_business_item: inv.supplier_business_item,
buyer_business_no: inv.buyer_business_no,
buyer_name: inv.buyer_name,
buyer_ceo_name: inv.buyer_ceo_name,
buyer_address: inv.buyer_address,
buyer_email: inv.buyer_email,
supply_amount: inv.supply_amount,
tax_amount: inv.tax_amount,
total_amount: inv.total_amount,
remarks: inv.remarks,
cost_type: inv.cost_type || undefined,
items:
items.length > 0
? items.map((item) => ({
item_date: item.item_date?.split("T")[0] || "",
item_name: item.item_name,
item_spec: item.item_spec,
quantity: item.quantity,
unit_price: item.unit_price,
supply_amount: item.supply_amount,
tax_amount: item.tax_amount,
remarks: item.remarks,
}))
: [{ ...emptyItem }],
});
setAttachments(inv.attachments || []);
}
} catch (error: any) {
toast.error("데이터 로드 실패", { description: error.message });
}
};
// 필드 변경
const handleChange = (field: keyof CreateTaxInvoiceDto, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 품목 변경
const handleItemChange = (index: number, field: keyof CreateTaxInvoiceItemDto, value: any) => {
setFormData((prev) => {
const items = [...(prev.items || [])];
items[index] = { ...items[index], [field]: value };
// 공급가액 자동 계산
if (field === "quantity" || field === "unit_price") {
const qty = field === "quantity" ? value : items[index].quantity;
const price = field === "unit_price" ? value : items[index].unit_price;
items[index].supply_amount = qty * price;
items[index].tax_amount = Math.round(items[index].supply_amount * 0.1);
}
// 총액 재계산
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
return {
...prev,
items,
supply_amount: totalSupply,
tax_amount: totalTax,
total_amount: totalSupply + totalTax,
};
});
};
// 품목 추가
const handleAddItem = () => {
setFormData((prev) => ({
...prev,
items: [...(prev.items || []), { ...emptyItem }],
}));
};
// 품목 삭제
const handleRemoveItem = (index: number) => {
setFormData((prev) => {
const items = (prev.items || []).filter((_, i) => i !== index);
const totalSupply = items.reduce((sum, item) => sum + (item.supply_amount || 0), 0);
const totalTax = items.reduce((sum, item) => sum + (item.tax_amount || 0), 0);
return {
...prev,
items: items.length > 0 ? items : [{ ...emptyItem }],
supply_amount: totalSupply,
tax_amount: totalTax,
total_amount: totalSupply + totalTax,
};
});
};
// 파일 업로드
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const formDataUpload = new FormData();
formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files"
formDataUpload.append("category", "tax-invoice");
const response = await apiClient.post("/files/upload", formDataUpload, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data.success && response.data.files?.length > 0) {
const uploadedFile = response.data.files[0];
const newAttachment: TaxInvoiceAttachment = {
id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file_name: uploadedFile.realFileName || file.name,
file_path: uploadedFile.filePath,
file_size: uploadedFile.fileSize || file.size,
file_type: file.type,
uploaded_at: new Date().toISOString(),
uploaded_by: "",
};
setAttachments((prev) => [...prev, newAttachment]);
toast.success(`'${file.name}' 업로드 완료`);
}
}
} catch (error: any) {
toast.error("파일 업로드 실패", { description: error.message });
} finally {
setUploading(false);
// input 초기화
e.target.value = "";
}
};
// 첨부파일 삭제
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// 파일 아이콘
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 formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// 저장
const handleSave = async () => {
// 유효성 검사
if (!formData.invoice_date) {
toast.error("작성일자를 입력해주세요.");
return;
}
setSaving(true);
try {
const dataToSave = {
...formData,
attachments,
};
let response;
if (invoice) {
response = await updateTaxInvoice(invoice.id, dataToSave);
} else {
response = await createTaxInvoice(dataToSave);
}
if (response.success) {
toast.success(response.message || "저장되었습니다.");
onSave();
}
} catch (error: any) {
toast.error("저장 실패", { description: error.message });
} finally {
setSaving(false);
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return new Intl.NumberFormat("ko-KR").format(amount);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] max-w-[900px] overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{invoice ? "세금계산서 수정" : "세금계산서 작성"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-180px)]">
<div className="space-y-4 p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="supplier"></TabsTrigger>
<TabsTrigger value="buyer"></TabsTrigger>
<TabsTrigger value="attachments">
{attachments.length > 0 && (
<Badge variant="secondary" className="ml-2">
{attachments.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* 기본정보 탭 */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Label className="text-xs"> *</Label>
<Select
value={formData.invoice_type}
onValueChange={(v) => handleChange("invoice_type", v)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sales"></SelectItem>
<SelectItem value="purchase"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={formData.cost_type || "none"}
onValueChange={(v) => handleChange("cost_type", v === "none" ? undefined : v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{Object.entries(costTypeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> *</Label>
<Input
type="date"
value={formData.invoice_date}
onChange={(e) => handleChange("invoice_date", e.target.value)}
className="h-9"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.remarks || ""}
onChange={(e) => handleChange("remarks", e.target.value)}
className="h-9"
placeholder="비고 입력"
/>
</div>
</div>
{/* 품목 테이블 */}
<Card>
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm"> </CardTitle>
<Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(formData.items || []).map((item, index) => (
<TableRow key={index}>
<TableCell className="p-1">
<Input
type="date"
value={item.item_date || ""}
onChange={(e) =>
handleItemChange(index, "item_date", e.target.value)
}
className="h-8 text-xs"
/>
</TableCell>
<TableCell className="p-1">
<Input
value={item.item_name || ""}
onChange={(e) =>
handleItemChange(index, "item_name", e.target.value)
}
className="h-8 text-xs"
placeholder="품목명"
/>
</TableCell>
<TableCell className="p-1">
<Input
value={item.item_spec || ""}
onChange={(e) =>
handleItemChange(index, "item_spec", e.target.value)
}
className="h-8 text-xs"
placeholder="규격"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.quantity || 0}
onChange={(e) =>
handleItemChange(index, "quantity", parseFloat(e.target.value) || 0)
}
className="h-8 text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={item.unit_price || 0}
onChange={(e) =>
handleItemChange(
index,
"unit_price",
parseFloat(e.target.value) || 0
)
}
className="h-8 text-right text-xs"
min={0}
/>
</TableCell>
<TableCell className="p-1 text-right font-mono text-xs">
{formatAmount(item.supply_amount || 0)}
</TableCell>
<TableCell className="p-1 text-right font-mono text-xs">
{formatAmount(item.tax_amount || 0)}
</TableCell>
<TableCell className="p-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleRemoveItem(index)}
disabled={(formData.items?.length || 0) <= 1}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 합계 */}
<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(formData.supply_amount || 0)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{formatAmount(formData.tax_amount || 0)}</span>
</div>
<div className="flex justify-between border-t pt-2 text-lg font-bold">
<span></span>
<span className="font-mono text-primary">
{formatAmount(formData.total_amount || 0)}
</span>
</div>
</div>
</div>
</TabsContent>
{/* 공급자 탭 */}
<TabsContent value="supplier" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_no || ""}
onChange={(e) => handleChange("supplier_business_no", e.target.value)}
className="h-9"
placeholder="000-00-00000"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_name || ""}
onChange={(e) => handleChange("supplier_name", e.target.value)}
className="h-9"
placeholder="상호명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_ceo_name || ""}
onChange={(e) => handleChange("supplier_ceo_name", e.target.value)}
className="h-9"
placeholder="대표자명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_type || ""}
onChange={(e) => handleChange("supplier_business_type", e.target.value)}
className="h-9"
placeholder="업태"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.supplier_business_item || ""}
onChange={(e) => handleChange("supplier_business_item", e.target.value)}
className="h-9"
placeholder="종목"
/>
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input
value={formData.supplier_address || ""}
onChange={(e) => handleChange("supplier_address", e.target.value)}
className="h-9"
placeholder="주소"
/>
</div>
</div>
</TabsContent>
{/* 공급받는자 탭 */}
<TabsContent value="buyer" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_business_no || ""}
onChange={(e) => handleChange("buyer_business_no", e.target.value)}
className="h-9"
placeholder="000-00-00000"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_name || ""}
onChange={(e) => handleChange("buyer_name", e.target.value)}
className="h-9"
placeholder="상호명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.buyer_ceo_name || ""}
onChange={(e) => handleChange("buyer_ceo_name", e.target.value)}
className="h-9"
placeholder="대표자명"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="email"
value={formData.buyer_email || ""}
onChange={(e) => handleChange("buyer_email", e.target.value)}
className="h-9"
placeholder="email@example.com"
/>
</div>
<div className="col-span-2">
<Label className="text-xs"></Label>
<Input
value={formData.buyer_address || ""}
onChange={(e) => handleChange("buyer_address", e.target.value)}
className="h-9"
placeholder="주소"
/>
</div>
</div>
</TabsContent>
{/* 첨부파일 탭 */}
<TabsContent value="attachments" className="space-y-4">
{/* 업로드 영역 */}
<div className="rounded-lg border-2 border-dashed p-6 text-center">
<input
type="file"
id="file-upload"
multiple
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx"
/>
<label
htmlFor="file-upload"
className="flex cursor-pointer flex-col items-center gap-2"
>
<Upload className="text-muted-foreground h-8 w-8" />
<span className="text-muted-foreground text-sm">
{uploading ? "업로드 중..." : "파일을 선택하거나 드래그하세요"}
</span>
<span className="text-muted-foreground text-xs">
PDF, , ( 10MB)
</span>
</label>
</div>
{/* 첨부파일 목록 */}
{attachments.length > 0 && (
<div className="space-y-2">
<Label className="text-xs"> ({attachments.length})</Label>
<div className="space-y-2">
{attachments.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)}
<div>
<p className="text-sm font-medium">{file.file_name}</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(file.file_size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveAttachment(file.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{attachments.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
<Paperclip className="mx-auto mb-2 h-8 w-8 opacity-50" />
.
</div>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<DialogFooter className="border-t px-6 py-4">
<Button variant="outline" onClick={onClose} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}