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 ( +{delta.toFixed(1)}% ); if (delta < 0) return ( {delta.toFixed(1)}% ); return ( 변동없음 ); }; return (
{label}
{fmtM(value)} {unit}
{(delta !== null && delta !== undefined) && (
{renderDelta()} {deltaLabel && {deltaLabel}}
)}
); } // ──────────────────────────────────────────────────────── // AG Grid 컬럼 정의 // ──────────────────────────────────────────────────────── const COLUMNS: ColDef[] = [ { 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({ 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 : 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) => { setParams({ fromMonth: values.fromMonth as string | undefined, toMonth: values.toMonth as string | undefined, pageSize: 100, }); }; return ( } onClick={() => rows.length && exportCsv(rows)} disabled={!rows.length} style={{ display: 'flex', alignItems: 'center', gap: 4 }} > CSV 다운로드 } > {/* 검색 */} setParams({ fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 100 })} /> {isError && ( )} {/* KPI 카드 3개 */} {/* 차트 */} 최근 12개월 수수료 추이} subTitle={단위: 백만원} headerBordered style={{ background: '#ffffff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, marginBottom: 20, }} > {chartData.length > 0 ? ( [`${v}백만`, '']} /> ) : ( )} {/* AG Grid */} rows={rows} columns={COLUMNS} loading={isLoading} height={480} pinnedBottomRow={pinnedRow} /> ); }