diff --git a/backend-node/src/controllers/taxInvoiceController.ts b/backend-node/src/controllers/taxInvoiceController.ts index 588a856c..5b7f4436 100644 --- a/backend-node/src/controllers/taxInvoiceController.ts +++ b/backend-node/src/controllers/taxInvoiceController.ts @@ -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 { + 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 || "통계 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/routes/taxInvoiceRoutes.ts b/backend-node/src/routes/taxInvoiceRoutes.ts index aa663faf..1a4bc6f0 100644 --- a/backend-node/src/routes/taxInvoiceRoutes.ts +++ b/backend-node/src/routes/taxInvoiceRoutes.ts @@ -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); diff --git a/backend-node/src/services/taxInvoiceService.ts b/backend-node/src/services/taxInvoiceService.ts index 63e94d5e..73577bb0 100644 --- a/backend-node/src/services/taxInvoiceService.ts +++ b/backend-node/src/services/taxInvoiceService.ts @@ -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), + }, + }; + } } diff --git a/frontend/components/tax-invoice/CostTypeStats.tsx b/frontend/components/tax-invoice/CostTypeStats.tsx new file mode 100644 index 00000000..786c093a --- /dev/null +++ b/frontend/components/tax-invoice/CostTypeStats.tsx @@ -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 = { + purchase: , + installation: , + repair: , + maintenance: , + disposal: , + other: , +}; + +// 비용 유형별 색상 +const costTypeColors: Record = { + 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(null); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedMonth, setSelectedMonth] = useState(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 ( +
+ {/* 헤더 */} +
+
+

비용 정산 현황

+

구매/설치/수리/유지보수/폐기 비용 통계

+
+
+ + + +
+
+ + {/* 요약 카드 */} +
+ + + 총 비용 + + + +
+ {formatAmount(stats?.summary.total_amount || 0)}원 +
+

+ {stats?.summary.total_count || 0}건 +

+
+
+ + + + 구매 비용 + + + +
+ {formatAmount(stats?.summary.purchase_amount || 0)}원 +
+ +
+
+ + + + 수리/유지보수 + + + +
+ {formatAmount((stats?.summary.repair_amount || 0) + (stats?.summary.maintenance_amount || 0))}원 +
+ +
+
+ + + + 설치/폐기 + + + +
+ {formatAmount((stats?.summary.installation_amount || 0) + (stats?.summary.disposal_amount || 0))}원 +
+ +
+
+
+ + {/* 비용 유형별 상세 */} + + + 비용 유형별 상세 + 각 비용 유형별 금액 및 비율 + + +
+ {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 ( +
+
+ {costType && costTypeIcons[costType]} + + {costType ? costTypeLabels[costType] : "미분류"} + +
+
+
+
+
+
+ + {percentage.toFixed(1)}% + +
+
+
+
+ {formatAmount(item.total_amount)}원 +
+
{item.count}건
+
+
+ ); + }) + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+ + + + {/* 월별 추이 */} + {!selectedMonth && stats?.by_month && stats.by_month.length > 0 && ( + + + 월별 비용 추이 + {selectedYear}년 월별 비용 현황 + + +
+ {/* 월별 그룹핑 */} + {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 ( +
+
+ {month}월 +
+
+ {monthData.map((item) => { + const costType = item.cost_type as CostType; + const width = monthTotal > 0 ? (item.total_amount / monthTotal) * 100 : 0; + return ( +
+ ); + })} +
+
+ {formatAmount(monthTotal)}원 +
+
+ ); + })} +
+ + {/* 범례 */} +
+ {Object.entries(costTypeLabels).map(([key, label]) => ( +
+
+ {label} +
+ ))} +
+ + + )} +
+ ); +} + diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx index 08c3fb37..9112ad33 100644 --- a/frontend/components/tax-invoice/TaxInvoiceForm.tsx +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -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 {/* 기본정보 탭 */} -
+
+
+ + +
({ 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() { {loading ? ( - + 로딩 중... ) : invoices.length === 0 ? ( - + 세금계산서가 없습니다. @@ -634,6 +639,15 @@ export function TaxInvoiceList() { {typeLabels[invoice.invoice_type]} + + {invoice.cost_type ? ( + + {costTypeLabels[invoice.cost_type as CostType]} + + ) : ( + - + )} + {statusLabels[invoice.invoice_status]} diff --git a/frontend/lib/api/taxInvoice.ts b/frontend/lib/api/taxInvoice.ts index be41f24c..493f99a1 100644 --- a/frontend/lib/api/taxInvoice.ts +++ b/frontend/lib/api/taxInvoice.ts @@ -4,6 +4,19 @@ import { apiClient } from "./client"; +// 비용 유형 +export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other"; + +// 비용 유형 라벨 +export const costTypeLabels: Record = { + 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 { + const params: Record = {}; + if (year) params.year = year; + if (month) params.month = month; + const response = await apiClient.get("/tax-invoice/stats/cost-type", { params }); + return response.data; +} +