Files
ga-commission-system/ga-frontend/src/pages/kpi/KpiMonthlySummary.tsx
T

319 lines
13 KiB
TypeScript
Raw Normal View History

2026-05-14 01:21:27 +09:00
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>
);
}