세금계산서 업그레이드

This commit is contained in:
leeheejin 2025-12-08 16:18:44 +09:00
parent 0ffec7f443
commit ed1626d391
7 changed files with 643 additions and 8 deletions

View File

@ -36,6 +36,7 @@ export class TaxInvoiceController {
end_date,
search,
buyer_name,
cost_type,
} = req.query;
const result = await TaxInvoiceService.getList(companyCode, {
@ -47,6 +48,7 @@ export class TaxInvoiceController {
end_date: end_date as string | undefined,
search: search as string | undefined,
buyer_name: buyer_name as string | undefined,
cost_type: cost_type as any,
});
res.json({
@ -327,5 +329,37 @@ export class TaxInvoiceController {
});
}
}
/**
*
* GET /api/tax-invoice/stats/cost-type
*/
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
return;
}
const { year, month } = req.query;
const targetYear = year ? parseInt(year as string, 10) : undefined;
const targetMonth = month ? parseInt(month as string, 10) : undefined;
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
res.json({
success: true,
data: result,
period: { year: targetYear, month: targetMonth },
});
} catch (error: any) {
logger.error("비용 유형별 통계 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "통계 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -18,6 +18,9 @@ router.get("/", TaxInvoiceController.getList);
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
// 비용 유형별 통계
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
// 상세 조회
router.get("/:id", TaxInvoiceController.getById);

View File

@ -6,6 +6,9 @@
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
// 비용 유형 타입
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
// 세금계산서 타입 정의
export interface TaxInvoice {
id: string;
@ -46,6 +49,9 @@ export interface TaxInvoice {
// 첨부파일 (JSON 배열로 저장)
attachments: TaxInvoiceAttachment[] | null;
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
cost_type: CostType | null;
created_date: string;
updated_date: string;
writer: string;
@ -99,6 +105,7 @@ export interface CreateTaxInvoiceDto {
customer_id?: string;
items?: CreateTaxInvoiceItemDto[];
attachments?: TaxInvoiceAttachment[]; // 첨부파일
cost_type?: CostType; // 비용 유형
}
export interface CreateTaxInvoiceItemDto {
@ -121,6 +128,7 @@ export interface TaxInvoiceListParams {
end_date?: string;
search?: string;
buyer_name?: string;
cost_type?: CostType; // 비용 유형 필터
}
export class TaxInvoiceService {
@ -169,6 +177,7 @@ export class TaxInvoiceService {
end_date,
search,
buyer_name,
cost_type,
} = params;
const offset = (page - 1) * pageSize;
@ -214,6 +223,12 @@ export class TaxInvoiceService {
paramIndex++;
}
if (cost_type) {
conditions.push(`cost_type = $${paramIndex}`);
values.push(cost_type);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 전체 개수 조회
@ -282,13 +297,13 @@ export class TaxInvoiceService {
supplier_business_type, supplier_business_item,
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
supply_amount, tax_amount, total_amount, invoice_date,
remarks, order_id, customer_id, attachments, writer
remarks, order_id, customer_id, attachments, cost_type, writer
) VALUES (
$1, $2, $3, 'draft',
$4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22, $23
$19, $20, $21, $22, $23, $24
) RETURNING *`,
[
companyCode,
@ -313,6 +328,7 @@ export class TaxInvoiceService {
data.order_id || null,
data.customer_id || null,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type || null,
userId,
]
);
@ -402,6 +418,7 @@ export class TaxInvoiceService {
invoice_date = COALESCE($17, invoice_date),
remarks = COALESCE($18, remarks),
attachments = $19,
cost_type = COALESCE($20, cost_type),
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING *`,
@ -425,6 +442,7 @@ export class TaxInvoiceService {
data.invoice_date,
data.remarks,
data.attachments ? JSON.stringify(data.attachments) : null,
data.cost_type,
]
);
@ -608,5 +626,159 @@ export class TaxInvoiceService {
return stats;
}
/**
*
*/
static async getCostTypeStats(
companyCode: string,
year?: number,
month?: number
): Promise<{
by_cost_type: Array<{
cost_type: CostType | null;
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}>;
by_month: Array<{
year_month: string;
cost_type: CostType | null;
count: number;
total_amount: number;
}>;
summary: {
total_count: number;
total_amount: number;
purchase_amount: number;
installation_amount: number;
repair_amount: number;
maintenance_amount: number;
disposal_amount: number;
other_amount: number;
};
}> {
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
const values: any[] = [companyCode];
let paramIndex = 2;
// 연도/월 필터
if (year && month) {
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
values.push(startDate, endDate);
paramIndex += 2;
} else if (year) {
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
values.push(year);
paramIndex++;
}
const whereClause = conditions.join(" AND ");
// 비용 유형별 집계
const byCostType = await query<{
cost_type: CostType | null;
count: string;
supply_amount: string;
tax_amount: string;
total_amount: string;
}>(
`SELECT
cost_type,
COUNT(*) as count,
COALESCE(SUM(supply_amount), 0) as supply_amount,
COALESCE(SUM(tax_amount), 0) as tax_amount,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY cost_type
ORDER BY total_amount DESC`,
values
);
// 월별 비용 유형 집계
const byMonth = await query<{
year_month: string;
cost_type: CostType | null;
count: string;
total_amount: string;
}>(
`SELECT
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
cost_type,
COUNT(*) as count,
COALESCE(SUM(total_amount), 0) as total_amount
FROM tax_invoice
WHERE ${whereClause}
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
ORDER BY year_month DESC, cost_type`,
values
);
// 전체 요약
const summaryResult = await query<{
total_count: string;
total_amount: string;
purchase_amount: string;
installation_amount: string;
repair_amount: string;
maintenance_amount: string;
disposal_amount: string;
other_amount: string;
}>(
`SELECT
COUNT(*) as total_count,
COALESCE(SUM(total_amount), 0) as total_amount,
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
FROM tax_invoice
WHERE ${whereClause}`,
values
);
const summary = summaryResult[0] || {
total_count: "0",
total_amount: "0",
purchase_amount: "0",
installation_amount: "0",
repair_amount: "0",
maintenance_amount: "0",
disposal_amount: "0",
other_amount: "0",
};
return {
by_cost_type: byCostType.map((row) => ({
cost_type: row.cost_type,
count: parseInt(row.count, 10),
supply_amount: parseFloat(row.supply_amount),
tax_amount: parseFloat(row.tax_amount),
total_amount: parseFloat(row.total_amount),
})),
by_month: byMonth.map((row) => ({
year_month: row.year_month,
cost_type: row.cost_type,
count: parseInt(row.count, 10),
total_amount: parseFloat(row.total_amount),
})),
summary: {
total_count: parseInt(summary.total_count, 10),
total_amount: parseFloat(summary.total_amount),
purchase_amount: parseFloat(summary.purchase_amount),
installation_amount: parseFloat(summary.installation_amount),
repair_amount: parseFloat(summary.repair_amount),
maintenance_amount: parseFloat(summary.maintenance_amount),
disposal_amount: parseFloat(summary.disposal_amount),
other_amount: parseFloat(summary.other_amount),
},
};
}
}

View File

@ -0,0 +1,329 @@
"use client";
/**
*
* ////
*/
import { useState, useEffect, useCallback } from "react";
import {
BarChart3,
TrendingUp,
TrendingDown,
Package,
Wrench,
Settings,
Trash2,
DollarSign,
Calendar,
RefreshCw,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
import { getCostTypeStats, CostTypeStatsResponse, CostType, costTypeLabels } from "@/lib/api/taxInvoice";
// 비용 유형별 아이콘
const costTypeIcons: Record<CostType, React.ReactNode> = {
purchase: <Package className="h-4 w-4" />,
installation: <Settings className="h-4 w-4" />,
repair: <Wrench className="h-4 w-4" />,
maintenance: <Settings className="h-4 w-4" />,
disposal: <Trash2 className="h-4 w-4" />,
other: <DollarSign className="h-4 w-4" />,
};
// 비용 유형별 색상
const costTypeColors: Record<CostType, string> = {
purchase: "bg-blue-500",
installation: "bg-green-500",
repair: "bg-orange-500",
maintenance: "bg-purple-500",
disposal: "bg-red-500",
other: "bg-gray-500",
};
export function CostTypeStats() {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<CostTypeStatsResponse["data"] | null>(null);
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(undefined);
// 연도 옵션 생성 (최근 5년)
const yearOptions = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
// 월 옵션 생성
const monthOptions = Array.from({ length: 12 }, (_, i) => i + 1);
// 데이터 로드
const loadStats = useCallback(async () => {
setLoading(true);
try {
const response = await getCostTypeStats(selectedYear, selectedMonth);
if (response.success) {
setStats(response.data);
}
} catch (error: any) {
toast.error("통계 로드 실패", { description: error.message });
} finally {
setLoading(false);
}
}, [selectedYear, selectedMonth]);
useEffect(() => {
loadStats();
}, [loadStats]);
// 금액 포맷
const formatAmount = (amount: number) => {
if (amount >= 100000000) {
return `${(amount / 100000000).toFixed(1)}`;
}
if (amount >= 10000) {
return `${(amount / 10000).toFixed(0)}`;
}
return new Intl.NumberFormat("ko-KR").format(amount);
};
// 전체 금액 대비 비율 계산
const getPercentage = (amount: number) => {
if (!stats?.summary.total_amount || stats.summary.total_amount === 0) return 0;
return (amount / stats.summary.total_amount) * 100;
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold"> </h2>
<p className="text-muted-foreground text-sm">//// </p>
</div>
<div className="flex items-center gap-2">
<Select
value={String(selectedYear)}
onValueChange={(v) => setSelectedYear(parseInt(v, 10))}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth ? String(selectedMonth) : "all"}
onValueChange={(v) => setSelectedMonth(v === "all" ? undefined : parseInt(v, 10))}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{monthOptions.map((month) => (
<SelectItem key={month} value={String(month)}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={loadStats} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 요약 카드 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<DollarSign className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount(stats?.summary.total_amount || 0)}
</div>
<p className="text-muted-foreground text-xs">
{stats?.summary.total_count || 0}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle>
<Package className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount(stats?.summary.purchase_amount || 0)}
</div>
<Progress
value={getPercentage(stats?.summary.purchase_amount || 0)}
className="mt-2 h-1"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">/</CardTitle>
<Wrench className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
</div>
<Progress
value={getPercentage((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}
className="mt-2 h-1"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">/</CardTitle>
<Settings className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
</div>
<Progress
value={getPercentage((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}
className="mt-2 h-1"
/>
</CardContent>
</Card>
</div>
{/* 비용 유형별 상세 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stats?.by_cost_type && stats.by_cost_type.length > 0 ? (
stats.by_cost_type.map((item) => {
const costType = item.cost_type as CostType;
const percentage = getPercentage(item.total_amount);
return (
<div key={costType || "none"} className="flex items-center gap-4">
<div className="flex w-[120px] items-center gap-2">
{costType && costTypeIcons[costType]}
<span className="text-sm font-medium">
{costType ? costTypeLabels[costType] : "미분류"}
</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${costType ? costTypeColors[costType] : "bg-gray-400"}`}
style={{ width: `${Math.max(percentage, 2)}%` }}
/>
</div>
<span className="w-[50px] text-right text-sm text-muted-foreground">
{percentage.toFixed(1)}%
</span>
</div>
</div>
<div className="w-[120px] text-right">
<div className="font-mono text-sm font-semibold">
{formatAmount(item.total_amount)}
</div>
<div className="text-xs text-muted-foreground">{item.count}</div>
</div>
</div>
);
})
) : (
<div className="text-center py-8 text-muted-foreground">
.
</div>
)}
</div>
</CardContent>
</Card>
{/* 월별 추이 */}
{!selectedMonth && stats?.by_month && stats.by_month.length > 0 && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>{selectedYear} </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 월별 그룹핑 */}
{Array.from(new Set(stats.by_month.map((item) => item.year_month)))
.sort()
.reverse()
.slice(0, 6)
.map((yearMonth) => {
const monthData = stats.by_month.filter((item) => item.year_month === yearMonth);
const monthTotal = monthData.reduce((sum, item) => sum + item.total_amount, 0);
const [year, month] = yearMonth.split("-");
return (
<div key={yearMonth} className="flex items-center gap-4 py-2 border-b last:border-0">
<div className="w-[80px] text-sm font-medium">
{month}
</div>
<div className="flex-1 flex gap-1">
{monthData.map((item) => {
const costType = item.cost_type as CostType;
const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0;
return (
<div
key={costType || "none"}
className={`h-6 ${costType ? costTypeColors[costType] : "bg-gray-400"} rounded`}
style={{ width: `${Math.max(width, 5)}%` }}
title={`${costType ? costTypeLabels[costType] : "미분류"}: ${formatAmount(item.total_amount)}`}
/>
);
})}
</div>
<div className="w-[100px] text-right font-mono text-sm">
{formatAmount(monthTotal)}
</div>
</div>
);
})}
</div>
{/* 범례 */}
<div className="mt-4 flex flex-wrap gap-3 pt-4 border-t">
{Object.entries(costTypeLabels).map(([key, label]) => (
<div key={key} className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${costTypeColors[key as CostType]}`} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -59,6 +59,8 @@ import {
TaxInvoiceAttachment,
CreateTaxInvoiceDto,
CreateTaxInvoiceItemDto,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { apiClient } from "@/lib/api/client";
@ -141,6 +143,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
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) => ({
@ -344,7 +347,7 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
{/* 기본정보 탭 */}
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Label className="text-xs"> *</Label>
<Select
@ -360,6 +363,25 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor
</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

View File

@ -72,6 +72,8 @@ import {
cancelTaxInvoice,
TaxInvoice,
TaxInvoiceListParams,
CostType,
costTypeLabels,
} from "@/lib/api/taxInvoice";
import { TaxInvoiceForm } from "./TaxInvoiceForm";
import { TaxInvoiceDetail } from "./TaxInvoiceDetail";
@ -113,15 +115,17 @@ interface ColumnDef {
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: "80px" },
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: "100px" },
], 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: "60px", align: "center" },
{ 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" },
@ -178,6 +182,7 @@ export function TaxInvoiceList() {
...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,
};
@ -614,13 +619,13 @@ export function TaxInvoiceList() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground py-8 text-center">
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
...
</TableCell>
</TableRow>
) : invoices.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground py-8 text-center">
<TableCell colSpan={11} className="text-muted-foreground py-8 text-center">
<FileText className="mx-auto mb-2 h-8 w-8 opacity-50" />
.
</TableCell>
@ -634,6 +639,15 @@ export function TaxInvoiceList() {
{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]}

View File

@ -4,6 +4,19 @@
import { apiClient } from "./client";
// 비용 유형
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
// 비용 유형 라벨
export const costTypeLabels: Record<CostType, string> = {
purchase: "구매",
installation: "설치",
repair: "수리",
maintenance: "유지보수",
disposal: "폐기",
other: "기타",
};
// 세금계산서 타입
export interface TaxInvoice {
id: string;
@ -31,6 +44,7 @@ export interface TaxInvoice {
order_id: string | null;
customer_id: string | null;
attachments: TaxInvoiceAttachment[] | null;
cost_type: CostType | null; // 비용 유형
created_date: string;
updated_date: string;
writer: string;
@ -86,6 +100,7 @@ export interface CreateTaxInvoiceDto {
customer_id?: string;
items?: CreateTaxInvoiceItemDto[];
attachments?: TaxInvoiceAttachment[];
cost_type?: CostType; // 비용 유형
}
// 품목 생성 DTO
@ -110,6 +125,7 @@ export interface TaxInvoiceListParams {
end_date?: string;
search?: string;
buyer_name?: string;
cost_type?: CostType; // 비용 유형 필터
}
// 목록 응답
@ -227,3 +243,48 @@ export async function getTaxInvoiceMonthlyStats(
return response.data;
}
// 비용 유형별 통계 응답
export interface CostTypeStatsResponse {
success: boolean;
data: {
by_cost_type: Array<{
cost_type: CostType | null;
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}>;
by_month: Array<{
year_month: string;
cost_type: CostType | null;
count: number;
total_amount: number;
}>;
summary: {
total_count: number;
total_amount: number;
purchase_amount: number;
installation_amount: number;
repair_amount: number;
maintenance_amount: number;
disposal_amount: number;
other_amount: number;
};
};
period: { year?: number; month?: number };
}
/**
*
*/
export async function getCostTypeStats(
year?: number,
month?: number
): Promise<CostTypeStatsResponse> {
const params: Record<string, number> = {};
if (year) params.year = year;
if (month) params.month = month;
const response = await apiClient.get("/tax-invoice/stats/cost-type", { params });
return response.data;
}