319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|