Files
ga-commission-system/ga-frontend/src/pages/kpi/KpiMonthlySummary.tsx
T
2026-05-14 01:21:27 +09:00

319 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { Alert, Button, Col, Row, Space, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
AreaChart, Area, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import {
IconTrendingUp, IconTrendingDown, IconMinus, IconDownload,
} from '@tabler/icons-react';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { kpiApi, MonthlySummaryRow, KpiSearchParam } from '@/api/kpi';
import {
COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS,
GRAY, SHADOW, RADIUS,
} from '@/theme/tokens';
const { Text } = Typography;
// ────────────────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────────────────
const fmt = (v: number) => v.toLocaleString();
const fmtM = (v: number) => `${(v / 1_000_000).toFixed(1)}M`;
function calcPct(curr: number, prev: number): number | null {
if (prev === 0) return null;
return Math.round(((curr - prev) / Math.abs(prev)) * 1000) / 10;
}
// ────────────────────────────────────────────────────────
// 서브 컴포넌트 — KPI 카드
// ────────────────────────────────────────────────────────
interface KpiCardProps {
label: string;
value: number;
unit?: string;
delta?: number | null;
deltaLabel?: string;
color?: string;
bg?: string;
}
function KpiCard({ label, value, unit = '원', delta, deltaLabel, color = COLOR_PRIMARY, bg = '#EBF3FF' }: KpiCardProps) {
const renderDelta = () => {
if (delta === null || delta === undefined) return null;
if (delta > 0) return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_SUCCESS }}>
<IconTrendingUp size={12} />+{delta.toFixed(1)}%
</span>
);
if (delta < 0) return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_ERROR }}>
<IconTrendingDown size={12} />{delta.toFixed(1)}%
</span>
);
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, color: GRAY[400] }}>
<IconMinus size={12} />
</span>
);
};
return (
<div style={{
background: '#ffffff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{label}</Text>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: bg, color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconTrendingUp size={18} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{
fontSize: 34, fontWeight: 700, color: GRAY[800], lineHeight: 1,
fontFeatureSettings: "'tnum'", fontVariantNumeric: 'tabular-nums',
}}>
{fmtM(value)}
</span>
<Text style={{ fontSize: 14, color: GRAY[500] }}>{unit}</Text>
</div>
{(delta !== null && delta !== undefined) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{renderDelta()}
{deltaLabel && <Text style={{ fontSize: 12, color: GRAY[400] }}>{deltaLabel}</Text>}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────
// AG Grid 컬럼 정의
// ────────────────────────────────────────────────────────
const COLUMNS: ColDef<MonthlySummaryRow>[] = [
{ field: 'yyyymm', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentCount', headerName: '설계사수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalGross', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalTax', headerName: '총공제(세)', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalNet', headerName: '총지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalChargeback', headerName: '환수', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalIncentive', headerName: '시책', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
];
// ────────────────────────────────────────────────────────
// 클라이언트 사이드 엑셀 내보내기 (xlsx 없음 — CSV 대체)
// ────────────────────────────────────────────────────────
function exportCsv(rows: MonthlySummaryRow[]) {
const headers = ['정산월', '설계사수', '총수수료', '총공제', '총지급', '환수', '시책'];
const lines = [
headers.join(','),
...rows.map((r) =>
[r.yyyymm, r.agentCount, r.totalGross, r.totalTax, r.totalNet, r.totalChargeback, r.totalIncentive].join(','),
),
];
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `월별정산요약_${dayjs().format('YYYYMMDD')}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
// ────────────────────────────────────────────────────────
// 메인 컴포넌트
// ────────────────────────────────────────────────────────
export default function KpiMonthlySummary() {
const [params, setParams] = useState<KpiSearchParam>({
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
toMonth: dayjs().format('YYYYMM'),
pageSize: 100,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['kpi', 'monthly', params],
queryFn: () => kpiApi.monthlySummary(params),
retry: false,
});
const rows = data?.list ?? [];
// KPI 카드: 최신월 / 전월 / YTD 합산
const thisMonth = rows[rows.length - 1];
const prevMonth = rows.length >= 2 ? rows[rows.length - 2] : undefined;
const ytdGross = rows.reduce((s, r) => s + (r.totalGross ?? 0), 0);
const grossDelta = thisMonth && prevMonth
? calcPct(thisMonth.totalGross, prevMonth.totalGross)
: null;
// 합계행
const pinnedRow = rows.length > 0 ? {
yyyymm: '합계',
agentCount: rows.reduce((s, r) => s + r.agentCount, 0),
totalGross: rows.reduce((s, r) => s + r.totalGross, 0),
totalTax: rows.reduce((s, r) => s + r.totalTax, 0),
totalNet: rows.reduce((s, r) => s + r.totalNet, 0),
totalChargeback: rows.reduce((s, r) => s + r.totalChargeback, 0),
totalIncentive: rows.reduce((s, r) => s + r.totalIncentive, 0),
} as Partial<MonthlySummaryRow> : undefined;
// 차트 데이터 — 최근 12개월
const chartData = useMemo(() =>
rows.slice(-12).map((r) => ({
month: r.yyyymm.slice(4, 6) + '월',
총수수료: Math.round(r.totalGross / 1_000_000),
총지급: Math.round(r.totalNet / 1_000_000),
환수: Math.round(r.totalChargeback / 1_000_000),
})),
[rows]);
const handleSearch = (values: Record<string, unknown>) => {
setParams({
fromMonth: values.fromMonth as string | undefined,
toMonth: values.toMonth as string | undefined,
pageSize: 100,
});
};
return (
<PageContainer
title="월별 정산 요약"
description="정산월별 수수료 / 공제 / 지급 합계 — Materialized View"
extra={
<Button
icon={<IconDownload size={14} />}
onClick={() => rows.length && exportCsv(rows)}
disabled={!rows.length}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
CSV
</Button>
}
>
{/* 검색 */}
<SearchForm
conditions={[
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
]}
onSearch={handleSearch}
onReset={() => setParams({ fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 100 })}
/>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/kpi/monthly-summary 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
{/* KPI 카드 3개 */}
<Row gutter={20} style={{ marginBottom: 20 }}>
<Col span={8}>
<KpiCard
label="당월 총수수료"
value={thisMonth?.totalGross ?? 0}
unit="원"
delta={grossDelta}
deltaLabel="전월比"
color={COLOR_PRIMARY}
bg="#EBF3FF"
/>
</Col>
<Col span={8}>
<KpiCard
label="당월 총지급"
value={thisMonth?.totalNet ?? 0}
unit="원"
color={COLOR_SUCCESS}
bg="#E8FAF3"
/>
</Col>
<Col span={8}>
<KpiCard
label={`YTD 누계 (${dayjs().format('YYYY')})`}
value={ytdGross}
unit="원"
color="#7C3AED"
bg="#F3EEFF"
/>
</Col>
</Row>
{/* 차트 */}
<ProCard
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> 12 </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원</span>}
headerBordered
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
marginBottom: 20,
}}
>
<ResponsiveContainer width="100%" height={260}>
{chartData.length > 0 ? (
<BarChart data={chartData} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
formatter={(v: number) => [`${v}백만`, '']}
/>
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
<Bar dataKey="총수수료" fill={COLOR_PRIMARY} radius={[4, 4, 0, 0]} />
<Bar dataKey="총지급" fill={COLOR_SUCCESS} radius={[4, 4, 0, 0]} />
<Bar dataKey="환수" fill={COLOR_ERROR} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={[]} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
<XAxis /><YAxis />
</AreaChart>
)}
</ResponsiveContainer>
</ProCard>
{/* AG Grid */}
<ProCard
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<DataGrid<MonthlySummaryRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={480}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}