feat: P5-W3 본론 완료 — 회계결산/이상치예측/연말명세서 (V74~V78)

P5-W2 원천세·부가세 분기신고 미커밋분 + P5-W3 메뉴/권한 보강(V74) + P5-W3 본론(V75~V78)을 일괄 정리.

- V75 accounting_close / account_balance_snapshot (회계 결산)
- V76 retention_anomaly_alert / forecast_kpi_monthly (이상치·예측 KPI)
- V77 year_end_statement (연말 지급명세서)
- V78 P5-W3 본론 메뉴 4 + 권한 + 공통코드 7그룹 + 테스트데이터
- api: AccountingClose/YearEndStatement/ForecastKpi/RetentionAnomaly Service+Controller
- batch: BatchConfig Step 11/12 (원천세·부가세 분기) 연결
- frontend: 4화면(AccountingClose/YearEndStatement/RetentionAnomaly/ForecastKpi) + API 모듈 4 + 라우트
- frontend: usePermission/PermissionButton EXECUTE 권한 누락 보강
- fix: YearEndStatementMapper.xml 미존재 컬럼 a.agent_code 참조 제거

통합검증: Flyway V74~V78 운영DB 적용(v78), 4모듈 compileJava + 프론트 build PASS,
스모크 GET 8/8 + 쓰기 4/4 PASS, verify_v74.py 20/20 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-21 22:22:34 +09:00
parent d5ed642c80
commit 49f08f001d
80 changed files with 4506 additions and 62 deletions
@@ -0,0 +1,231 @@
import { useState, useCallback } from 'react';
import { Alert, Form, Input, Modal, Select, Space, Table, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
accountingCloseApi,
AccountingCloseResp,
AccountBalanceSnapshot,
AccountingCloseSearchParam,
} from '@/api/accounting-close';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<AccountingCloseResp>[] = [
{ field: 'closePeriod', headerName: '결산기간', width: 120, pinned: 'left' },
{ field: 'closeTypeName', headerName: '결산구분', width: 100 },
{ field: 'closeStatusName', headerName: '상태', width: 100 },
{ field: 'journalPrefix', headerName: '분개prefix', width: 120 },
{ field: 'journalCount', headerName: '분개수', width: 90, type: 'numericColumn' },
{ field: 'totalDebit', headerName: '차변합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'totalCredit', headerName: '대변합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'closedAt', headerName: '마감일시', width: 150 },
{ field: 'reopenReason', headerName: '재오픈사유', flex: 1 },
];
const SNAPSHOT_COLUMNS = [
{ title: '계정코드', dataIndex: 'accountCode', key: 'accountCode', width: 100 },
{ title: '계정명', dataIndex: 'accountName', key: 'accountName' },
{ title: '구분', dataIndex: 'accountType', key: 'accountType', width: 80 },
{ title: '차변합', dataIndex: 'debitTotal', key: 'debitTotal', align: 'right' as const, render: fmt },
{ title: '대변합', dataIndex: 'creditTotal', key: 'creditTotal', align: 'right' as const, render: fmt },
{ title: '잔액', dataIndex: 'balance', key: 'balance', align: 'right' as const, render: fmt },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function AccountingCloseList() {
const qc = useQueryClient();
const [params, setParams] = useState<AccountingCloseSearchParam>({ pageSize: 200 });
const [selectedRows, setSelectedRows] = useState<AccountingCloseResp[]>([]);
const [draftOpen, setDraftOpen] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [snapshots, setSnapshots] = useState<AccountBalanceSnapshot[] | null>(null);
const [draftForm] = Form.useForm();
const [reopenForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['accounting-closes', params],
queryFn: () => accountingCloseApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const selected = selectedRows[0];
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<AccountingCloseResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleCreateDraft = async () => {
const values = await draftForm.validateFields();
try {
await accountingCloseApi.createDraft(values);
message.success('결산 DRAFT 생성됨');
qc.invalidateQueries({ queryKey: ['accounting-closes'] });
setDraftOpen(false);
draftForm.resetFields();
} catch { /* request.ts 처리 */ }
};
const handleClose = () => {
if (!selected) return;
Modal.confirm({
title: '결산 마감 실행',
content: `${selected.closePeriod} (${selected.closeTypeName}) 을 마감하시겠습니까? 분개 채번 및 잔액 스냅샷이 생성됩니다.`,
okText: '마감 실행',
cancelText: '취소',
onOk: async () => {
try {
await accountingCloseApi.close(selected.closeId);
message.success('결산 마감 완료');
qc.invalidateQueries({ queryKey: ['accounting-closes'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
const handleReopen = async () => {
if (!selected) return;
const values = await reopenForm.validateFields();
try {
await accountingCloseApi.reopen(selected.closeId, values.reopenReason);
message.success('결산 재오픈됨');
qc.invalidateQueries({ queryKey: ['accounting-closes'] });
setReopenOpen(false);
reopenForm.resetFields();
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
};
const handleViewSnapshots = async () => {
if (!selected) return;
try {
setSnapshots(await accountingCloseApi.snapshots(selected.closeId));
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="회계 결산"
description="월/분기/연 단위 회계 마감 및 계정과목별 잔액 스냅샷"
extra={
<Space>
<PermissionButton menuCode="ACCOUNTING_CLOSE" permCode="READ"
disabled={!selected} onClick={handleViewSnapshots}>
</PermissionButton>
<PermissionButton menuCode="ACCOUNTING_CLOSE" permCode="CREATE"
onClick={() => setDraftOpen(true)}>
</PermissionButton>
<PermissionButton menuCode="ACCOUNTING_CLOSE" permCode="APPROVE"
disabled={!selected} onClick={() => setReopenOpen(true)}>
</PermissionButton>
<PermissionButton menuCode="ACCOUNTING_CLOSE" permCode="EXECUTE"
type="primary" disabled={!selected} onClick={handleClose}>
</PermissionButton>
</Space>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답"
description="/api/accounting-closes 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'closePeriod', label: '결산기간', span: 6 },
{ type: 'text', name: 'closeType', label: '구분(MONTHLY/QUARTERLY/YEARLY)', span: 8 },
{ type: 'text', name: 'closeStatus', label: '상태(DRAFT/CLOSED/REOPENED)', span: 8 },
]}
onSearch={(v) => setParams({ ...v as AccountingCloseSearchParam, pageSize: 200 })}
onReset={() => setParams({ pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AccountingCloseResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={520}
rowKey="closeId"
rowSelection="single"
onSelectionChanged={handleSelectionChanged}
/>
</ProCard>
{/* 결산 DRAFT 생성 */}
<Modal
title="결산 DRAFT 생성"
open={draftOpen}
onOk={handleCreateDraft}
onCancel={() => { setDraftOpen(false); draftForm.resetFields(); }}
okText="생성"
cancelText="취소"
>
<Form form={draftForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="closePeriod" label="결산기간"
rules={[{ required: true, message: '결산기간을 입력하세요' }]}
extra="월: YYYY-MM / 분기: YYYY-Q1 / 연: YYYY">
<Input placeholder="예: 2025-03" />
</Form.Item>
<Form.Item name="closeType" label="결산구분"
rules={[{ required: true, message: '결산구분을 선택하세요' }]}>
<Select
options={[
{ value: 'MONTHLY', label: '월결산' },
{ value: 'QUARTERLY', label: '분기결산' },
{ value: 'YEARLY', label: '연결산' },
]}
/>
</Form.Item>
</Form>
</Modal>
{/* 재오픈 */}
<Modal
title="결산 재오픈"
open={reopenOpen}
onOk={handleReopen}
onCancel={() => { setReopenOpen(false); reopenForm.resetFields(); }}
okText="재오픈"
cancelText="취소"
>
<p>{selected?.closePeriod} ({selected?.closeTypeName}) .</p>
<Form form={reopenForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="reopenReason" label="재오픈 사유"
rules={[{ required: true, message: '재오픈 사유를 입력하세요' }]}>
<Input.TextArea rows={3} placeholder="재오픈 사유" />
</Form.Item>
</Form>
</Modal>
{/* 잔액 스냅샷 */}
<Modal
title={`계정과목별 잔액 스냅샷 — ${selected?.closePeriod ?? ''}`}
open={snapshots !== null}
onCancel={() => setSnapshots(null)}
footer={null}
width={760}
>
<Table
size="small"
rowKey="snapshotId"
columns={SNAPSHOT_COLUMNS}
dataSource={snapshots ?? []}
pagination={false}
/>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,180 @@
import { useState, useCallback } from 'react';
import { Alert, Form, Input, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } 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 PermissionButton from '@/components/common/PermissionButton';
import {
yearEndStatementApi,
YearEndStatementResp,
YearEndStatementSearchParam,
} from '@/api/year-end-statement';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<YearEndStatementResp>[] = [
{ field: 'statementYear', headerName: '귀속연도', width: 100, pinned: 'left' },
{ field: 'agentCode', headerName: '설계사코드', width: 120 },
{ field: 'agentName', headerName: '설계사명', width: 120 },
{ field: 'orgName', headerName: '소속조직', width: 160 },
{ field: 'totalIncome', headerName: '총지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'totalWithheld', headerName: '원천징수세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'totalLocalTax', headerName: '지방소득세', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'fileFormat', headerName: '포맷', width: 80 },
{ field: 'fileStatusName', headerName: '상태', width: 100 },
{ field: 'generatedAt', headerName: '발급일시', width: 150 },
{ field: 'sentAt', headerName: '발송일시', width: 150 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function YearEndStatementList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const [params, setParams] = useState<YearEndStatementSearchParam>({
statementYear: currentYear,
pageSize: 500,
});
const [selectedRows, setSelectedRows] = useState<YearEndStatementResp[]>([]);
const [sendOpen, setSendOpen] = useState(false);
const [sendForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['year-end-statements', params],
queryFn: () => yearEndStatementApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
totalIncome: rows.reduce((s, r) => s + (r.totalIncome ?? 0), 0),
totalWithheld: rows.reduce((s, r) => s + (r.totalWithheld ?? 0), 0),
totalLocalTax: rows.reduce((s, r) => s + (r.totalLocalTax ?? 0), 0),
} as Partial<YearEndStatementResp> : undefined;
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<YearEndStatementResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleRegenerate = () => {
Modal.confirm({
title: '연말 지급명세서 재집계',
content: `${params.statementYear ?? currentYear}년 연말 지급명세서를 재집계하시겠습니까? 연 사업소득 집계가 반영됩니다.`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await yearEndStatementApi.regenerate({ statementYear: params.statementYear ?? currentYear });
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['year-end-statements'] });
} catch { /* request.ts 처리 */ }
},
});
};
const handleGenerate = () => {
Modal.confirm({
title: '명세서 파일 발급',
content: `선택한 ${selectedRows.length}건의 명세서 파일을 발급하시겠습니까?`,
okText: '발급',
cancelText: '취소',
onOk: async () => {
try {
await Promise.all(selectedRows.map((r) => yearEndStatementApi.generate(r.statementId)));
message.success('파일 발급 완료');
qc.invalidateQueries({ queryKey: ['year-end-statements'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
const handleSend = async () => {
const values = await sendForm.validateFields();
try {
await yearEndStatementApi.send({
ids: selectedRows.map((r) => r.statementId),
sentTo: values.sentTo,
});
message.success('발송 처리 완료');
qc.invalidateQueries({ queryKey: ['year-end-statements'] });
setSendOpen(false);
sendForm.resetFields();
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="연말 지급명세서"
description="설계사 연 사업소득 지급명세서 발급 및 발송"
extra={
<Space>
<PermissionButton menuCode="YEAR_END_STMT" permCode="EXECUTE" onClick={handleRegenerate}>
</PermissionButton>
<PermissionButton menuCode="YEAR_END_STMT" permCode="EXECUTE"
disabled={selectedRows.length === 0} onClick={handleGenerate}>
({selectedRows.length})
</PermissionButton>
<PermissionButton menuCode="YEAR_END_STMT" permCode="UPDATE"
type="primary" disabled={selectedRows.length === 0} onClick={() => setSendOpen(true)}>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답"
description="/api/year-end-statements 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'statementYear', label: '귀속연도', span: 6 },
{ type: 'text', name: 'fileStatus', label: '상태(DRAFT/GENERATED/SENT)', span: 8 },
]}
onSearch={(v) => setParams({ ...v as YearEndStatementSearchParam, pageSize: 500 })}
onReset={() => setParams({ statementYear: currentYear, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<YearEndStatementResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={560}
rowKey="statementId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
{/* 발송 처리 */}
<Modal
title="명세서 발송 처리"
open={sendOpen}
onOk={handleSend}
onCancel={() => { setSendOpen(false); sendForm.resetFields(); }}
okText="발송"
cancelText="취소"
>
<p> {selectedRows.length} .</p>
<Form form={sendForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="sentTo" label="발송처"
rules={[{ required: true, message: '발송처를 입력하세요' }]}>
<Input placeholder="예: 이메일 / 내부공유 경로" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { Alert, Form, Input, Modal, Select, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
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 PermissionButton from '@/components/common/PermissionButton';
import {
forecastKpiApi,
ForecastKpiResp,
ForecastKpiSearchParam,
} from '@/api/forecast-kpi';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v == null ? '-' : (v as number).toLocaleString());
const COLUMNS: ColDef<ForecastKpiResp>[] = [
{ field: 'forecastMonth', headerName: '예측월', width: 110, pinned: 'left' },
{ field: 'targetOrgName', headerName: '대상조직', width: 150, valueFormatter: (p) => (p.value as string) ?? '전사' },
{ field: 'metricName', headerName: '지표', width: 160 },
{ field: 'forecastValue', headerName: '예측값', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'lowerBound', headerName: '하한(-1σ)', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'upperBound', headerName: '상한(+1σ)', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'methodName', headerName: '예측기법', width: 130 },
{ field: 'basePeriod', headerName: '산출기준', width: 180 },
{ field: 'sampleCount', headerName: '표본수', width: 90, type: 'numericColumn' },
{ field: 'generatedAt', headerName: '산출일시', width: 150 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function ForecastKpiList() {
const qc = useQueryClient();
const nextMonth = dayjs().add(1, 'month').format('YYYY-MM');
const [params, setParams] = useState<ForecastKpiSearchParam>({ pageSize: 200 });
const [regenOpen, setRegenOpen] = useState(false);
const [regenForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['forecast-kpi', params],
queryFn: () => forecastKpiApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleRegenerate = async () => {
const values = await regenForm.validateFields();
try {
const affected = await forecastKpiApi.regenerate(values);
message.success(`차월 예측 재산출 완료 (${affected}건)`);
qc.invalidateQueries({ queryKey: ['forecast-kpi'] });
setRegenOpen(false);
regenForm.resetFields();
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="차월 KPI 예측"
description="이동평균 기반 차월 보험료/지급/유지율 예측"
extra={
<PermissionButton menuCode="FORECAST_KPI" permCode="EXECUTE"
type="primary" onClick={() => setRegenOpen(true)}>
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답"
description="/api/forecast-kpi 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'forecastMonth', label: '예측월(YYYY-MM)', span: 7 },
{ type: 'text', name: 'metricCode', label: '지표코드', span: 7 },
]}
onSearch={(v) => setParams({ ...v as ForecastKpiSearchParam, pageSize: 200 })}
onReset={() => setParams({ pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<ForecastKpiResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={520}
rowKey="forecastId"
/>
</ProCard>
{/* 재산출 */}
<Modal
title="차월 KPI 예측 재산출"
open={regenOpen}
onOk={handleRegenerate}
onCancel={() => { setRegenOpen(false); regenForm.resetFields(); }}
okText="재산출"
cancelText="취소"
>
<Form form={regenForm} layout="vertical"
initialValues={{ forecastMonth: nextMonth, method: 'SMA3' }} style={{ marginTop: 16 }}>
<Form.Item name="forecastMonth" label="예측 대상월"
rules={[{ required: true, message: '예측 대상월을 입력하세요 (YYYY-MM)' }]}>
<Input placeholder="예: 2025-05" />
</Form.Item>
<Form.Item name="method" label="예측 기법">
<Select
options={[
{ value: 'SMA3', label: '3개월 이동평균' },
{ value: 'SMA6', label: '6개월 이동평균' },
{ value: 'NAIVE', label: '단순 직전월' },
]}
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,169 @@
import { useState, useCallback } from 'react';
import { Alert, Form, Input, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, SelectionChangedEvent } 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 PermissionButton from '@/components/common/PermissionButton';
import {
retentionAnomalyApi,
RetentionAnomalyResp,
RetentionAnomalySearchParam,
} from '@/api/retention-anomaly';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const rate = (v: unknown) => (v == null ? '-' : `${((v as number) * 100).toFixed(2)}%`);
const COLUMNS: ColDef<RetentionAnomalyResp>[] = [
{ field: 'alertMonth', headerName: '발생월', width: 110, pinned: 'left' },
{ field: 'targetTypeName', headerName: '대상구분', width: 100 },
{ field: 'targetName', headerName: '대상', width: 150 },
{ field: 'retentionBand', headerName: '유지율밴드', width: 100 },
{ field: 'expectedRate', headerName: '기대유지율', flex: 1, type: 'numericColumn', valueFormatter: (p) => rate(p.value) },
{ field: 'actualRate', headerName: '실제유지율', flex: 1, type: 'numericColumn', valueFormatter: (p) => rate(p.value) },
{ field: 'deviation', headerName: '편차', flex: 1, type: 'numericColumn', valueFormatter: (p) => rate(p.value) },
{ field: 'severityName', headerName: '심각도', width: 90 },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'note', headerName: '비고', flex: 1 },
{ field: 'detectedAt', headerName: '탐지일시', width: 150 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function RetentionAnomalyList() {
const qc = useQueryClient();
const lastMonth = dayjs().subtract(1, 'month').format('YYYY-MM');
const [params, setParams] = useState<RetentionAnomalySearchParam>({ pageSize: 200 });
const [selectedRows, setSelectedRows] = useState<RetentionAnomalyResp[]>([]);
const [detectOpen, setDetectOpen] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [detectForm] = Form.useForm();
const [reviewForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['retention-anomalies', params],
queryFn: () => retentionAnomalyApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<RetentionAnomalyResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleDetect = async () => {
const values = await detectForm.validateFields();
try {
const affected = await retentionAnomalyApi.detect(values.alertMonth);
message.success(`이상치 재탐지 완료 (${affected}건)`);
qc.invalidateQueries({ queryKey: ['retention-anomalies'] });
setDetectOpen(false);
detectForm.resetFields();
} catch { /* request.ts 처리 */ }
};
const handleReview = async () => {
const values = await reviewForm.validateFields();
try {
await retentionAnomalyApi.review({
ids: selectedRows.map((r) => r.alertId),
status: values.status,
note: values.note,
});
message.success('검토 처리 완료');
qc.invalidateQueries({ queryKey: ['retention-anomalies'] });
setReviewOpen(false);
reviewForm.resetFields();
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="유지율 이상치"
description="13M/25M 유지율 이상 탐지 및 검토 관리"
extra={
<Space>
<PermissionButton menuCode="RETENTION_ANOMALY" permCode="UPDATE"
onClick={() => setDetectOpen(true)}>
</PermissionButton>
<PermissionButton menuCode="RETENTION_ANOMALY" permCode="UPDATE"
type="primary" disabled={selectedRows.length === 0} onClick={() => setReviewOpen(true)}>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답"
description="/api/retention-anomalies 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'alertMonth', label: '발생월(YYYY-MM)', span: 7 },
{ type: 'text', name: 'retentionBand', label: '밴드(13M/25M)', span: 6 },
{ type: 'text', name: 'severity', label: '심각도(LOW/MED/HIGH)', span: 7 },
{ type: 'text', name: 'status', label: '상태(NEW/REVIEWED/RESOLVED)', span: 8 },
]}
onSearch={(v) => setParams({ ...v as RetentionAnomalySearchParam, pageSize: 200 })}
onReset={() => setParams({ pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<RetentionAnomalyResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={520}
rowKey="alertId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
/>
</ProCard>
{/* 재탐지 */}
<Modal
title="유지율 이상치 재탐지"
open={detectOpen}
onOk={handleDetect}
onCancel={() => { setDetectOpen(false); detectForm.resetFields(); }}
okText="재탐지"
cancelText="취소"
>
<Form form={detectForm} layout="vertical" initialValues={{ alertMonth: lastMonth }} style={{ marginTop: 16 }}>
<Form.Item name="alertMonth" label="탐지 대상월"
rules={[{ required: true, message: '대상월을 입력하세요 (YYYY-MM)' }]}>
<Input placeholder="예: 2025-04" />
</Form.Item>
</Form>
</Modal>
{/* 검토 처리 */}
<Modal
title="이상치 검토 처리"
open={reviewOpen}
onOk={handleReview}
onCancel={() => { setReviewOpen(false); reviewForm.resetFields(); }}
okText="처리"
cancelText="취소"
>
<p> {selectedRows.length} .</p>
<Form form={reviewForm} layout="vertical" initialValues={{ status: 'REVIEWED' }} style={{ marginTop: 16 }}>
<Form.Item name="status" label="처리 상태"
rules={[{ required: true, message: '상태를 선택하세요' }]}>
<Input placeholder="REVIEWED / RESOLVED" />
</Form.Item>
<Form.Item name="note" label="검토 비고">
<Input.TextArea rows={3} placeholder="검토 내용" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -326,7 +326,7 @@ export default function ChargebackGradeList() {
rules={[
{ required: true, message: '경과월 시작을 입력하세요' },
{
validator: (_, v) =>
validator: (_: unknown, v: number) =>
v >= 0
? Promise.resolve()
: Promise.reject('0 이상의 값을 입력하세요'),
@@ -347,8 +347,8 @@ export default function ChargebackGradeList() {
placeholder="비워두면 무한대"
rules={[
{
validator: (_, v) => {
if (v == null || v === '') return Promise.resolve();
validator: (_: unknown, v: number) => {
if (v == null) return Promise.resolve();
if (v >= 0) return Promise.resolve();
return Promise.reject('0 이상의 값을 입력하세요');
},
@@ -364,7 +364,7 @@ export default function ChargebackGradeList() {
rules={[
{ required: true, message: '환수율을 입력하세요' },
{
validator: (_, v) =>
validator: (_: unknown, v: number) =>
v >= 0
? Promise.resolve()
: Promise.reject('0 이상의 값을 입력하세요'),
@@ -244,7 +244,7 @@ export default function InstallmentRatioList() {
rules={[
{ required: true, message: '비율을 입력하세요' },
{
validator: (_, v) =>
validator: (_: unknown, v: number) =>
v > 0 ? Promise.resolve() : Promise.reject('0보다 큰 값을 입력하세요'),
},
]}
@@ -379,7 +379,7 @@ export default function CommissionDispute() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="이의금액"
min={0}
/>
@@ -388,7 +388,7 @@ export default function CommissionDispute() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="청구금액"
min={0}
/>
@@ -305,7 +305,7 @@ export default function DeductionList() {
rules={[
{ required: true, message: '금액을 입력하세요' },
{
validator: (_, v) =>
validator: (_: unknown, v: number) =>
v === undefined || v === null || v > 0
? Promise.resolve()
: Promise.reject(new Error('금액은 양수여야 합니다')),
@@ -456,7 +456,7 @@ export default function IncentiveProgram() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="고정 지급액"
min={0}
addonAfter="원"
@@ -468,7 +468,7 @@ export default function IncentiveProgram() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="예산 (선택)"
min={0}
addonAfter="원"
@@ -323,7 +323,7 @@ export default function TaxInvoiceList() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="공급가액"
min={0}
/>
@@ -337,7 +337,7 @@ export default function TaxInvoiceList() {
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0) as unknown as 0}
placeholder="세액"
min={0}
/>