세금계산서 업그레이드
This commit is contained in:
parent
0ffec7f443
commit
ed1626d391
|
|
@ -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 || "통계 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue