동기화
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user