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:
+16
-4
@@ -11,6 +11,12 @@ import AccountingJournalList from '@/pages/accounting/AccountingJournalList';
|
||||
import WithholdingTaxReportList from '@/pages/tax/WithholdingTaxReportList';
|
||||
import VatReportList from '@/pages/tax/VatReportList';
|
||||
|
||||
// ── P5-W3: 결산 / 연말명세서 / 이상치 / 예측 ──────────
|
||||
import AccountingCloseList from '@/pages/accounting/AccountingCloseList';
|
||||
import YearEndStatementList from '@/pages/income/YearEndStatementList';
|
||||
import RetentionAnomalyList from '@/pages/kpi/RetentionAnomalyList';
|
||||
import ForecastKpiList from '@/pages/kpi/ForecastKpiList';
|
||||
|
||||
// ── P4: 수수료 종류 ──────────────────────────────────
|
||||
import CommissionMaster from '@/pages/commission/CommissionMaster';
|
||||
import CollectionCommission from '@/pages/commission/CollectionCommission';
|
||||
@@ -149,10 +155,12 @@ export default function App() {
|
||||
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
|
||||
|
||||
{/* KPI 대시보드 */}
|
||||
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
|
||||
<Route path="kpi/org" element={<KpiOrgSummary />} />
|
||||
<Route path="kpi/retention" element={<KpiRetention />} />
|
||||
<Route path="kpi/cumulative" element={<KpiCumulative />} />
|
||||
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
|
||||
<Route path="kpi/org" element={<KpiOrgSummary />} />
|
||||
<Route path="kpi/retention" element={<KpiRetention />} />
|
||||
<Route path="kpi/cumulative" element={<KpiCumulative />} />
|
||||
<Route path="kpi/retention-anomaly" element={<RetentionAnomalyList />} />
|
||||
<Route path="kpi/forecast" element={<ForecastKpiList />} />
|
||||
|
||||
{/* P5: 연말명세서 / 분개장 */}
|
||||
<Route path="commission/annual-income" element={<AgentAnnualIncomeList />} />
|
||||
@@ -162,6 +170,10 @@ export default function App() {
|
||||
<Route path="commission/withholding-tax" element={<WithholdingTaxReportList />} />
|
||||
<Route path="commission/vat-report" element={<VatReportList />} />
|
||||
|
||||
{/* P5-W3: 회계 결산 / 연말 지급명세서 */}
|
||||
<Route path="commission/accounting-close" element={<AccountingCloseList />} />
|
||||
<Route path="commission/year-end-statement" element={<YearEndStatementList />} />
|
||||
|
||||
{/* P4: 수수료 종류 */}
|
||||
<Route path="commission/type" element={<CommissionMaster />} />
|
||||
<Route path="commission/master" element={<CommissionMaster />} />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface AccountingCloseResp extends Record<string, unknown> {
|
||||
closeId: number;
|
||||
closePeriod: string;
|
||||
closeType: string;
|
||||
closeTypeName?: string;
|
||||
closeStatus: string;
|
||||
closeStatusName?: string;
|
||||
journalPrefix?: string;
|
||||
journalCount?: number;
|
||||
totalDebit?: number;
|
||||
totalCredit?: number;
|
||||
balanceDiff?: number;
|
||||
closedAt?: string;
|
||||
closedByName?: string;
|
||||
reopenedAt?: string;
|
||||
reopenedByName?: string;
|
||||
reopenReason?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AccountBalanceSnapshot extends Record<string, unknown> {
|
||||
snapshotId: number;
|
||||
closeId: number;
|
||||
accountCode: string;
|
||||
accountName?: string;
|
||||
accountType?: string;
|
||||
debitTotal: number;
|
||||
creditTotal: number;
|
||||
balance: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface AccountingCloseSearchParam {
|
||||
closePeriod?: string;
|
||||
closeType?: string;
|
||||
closeStatus?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AccountingCloseDraftReq {
|
||||
closePeriod: string;
|
||||
closeType: string;
|
||||
}
|
||||
|
||||
export const accountingCloseApi = {
|
||||
list: (params?: AccountingCloseSearchParam) =>
|
||||
unwrap<{ list: AccountingCloseResp[]; total: number }>(
|
||||
api.get('/api/accounting-closes', { params })
|
||||
),
|
||||
detail: (id: number) =>
|
||||
unwrap<AccountingCloseResp>(api.get(`/api/accounting-closes/${id}`)),
|
||||
snapshots: (id: number) =>
|
||||
unwrap<AccountBalanceSnapshot[]>(api.get(`/api/accounting-closes/${id}/snapshots`)),
|
||||
createDraft: (req: AccountingCloseDraftReq) =>
|
||||
unwrap<number>(api.post('/api/accounting-closes/draft', req)),
|
||||
close: (id: number) =>
|
||||
unwrap<AccountingCloseResp>(api.post(`/api/accounting-closes/${id}/close`, {})),
|
||||
reopen: (id: number, reopenReason: string) =>
|
||||
unwrap<void>(api.post(`/api/accounting-closes/${id}/reopen`, { reopenReason })),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface ForecastKpiResp extends Record<string, unknown> {
|
||||
forecastId: number;
|
||||
forecastMonth: string;
|
||||
targetOrgId?: number;
|
||||
targetOrgName?: string;
|
||||
metricCode: string;
|
||||
metricName?: string;
|
||||
forecastValue?: number;
|
||||
lowerBound?: number;
|
||||
upperBound?: number;
|
||||
method?: string;
|
||||
methodName?: string;
|
||||
basePeriod?: string;
|
||||
sampleCount?: number;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ForecastKpiSearchParam {
|
||||
forecastMonth?: string;
|
||||
targetOrgId?: number;
|
||||
metricCode?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ForecastKpiRegenerateReq {
|
||||
forecastMonth: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export const forecastKpiApi = {
|
||||
list: (params?: ForecastKpiSearchParam) =>
|
||||
unwrap<{ list: ForecastKpiResp[]; total: number }>(
|
||||
api.get('/api/forecast-kpi', { params })
|
||||
),
|
||||
regenerate: (req: ForecastKpiRegenerateReq) =>
|
||||
unwrap<number>(api.post('/api/forecast-kpi/regenerate', req)),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface RetentionAnomalyResp extends Record<string, unknown> {
|
||||
alertId: number;
|
||||
targetType: string;
|
||||
targetTypeName?: string;
|
||||
targetId?: number;
|
||||
targetName?: string;
|
||||
alertMonth: string;
|
||||
retentionBand: string;
|
||||
expectedRate?: number;
|
||||
actualRate?: number;
|
||||
deviation?: number;
|
||||
severity: string;
|
||||
severityName?: string;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
note?: string;
|
||||
detectedAt?: string;
|
||||
reviewedAt?: string;
|
||||
reviewedByName?: string;
|
||||
}
|
||||
|
||||
export interface RetentionAnomalySearchParam {
|
||||
alertMonth?: string;
|
||||
targetType?: string;
|
||||
retentionBand?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface RetentionAnomalyReviewReq {
|
||||
ids: number[];
|
||||
status: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const retentionAnomalyApi = {
|
||||
list: (params?: RetentionAnomalySearchParam) =>
|
||||
unwrap<{ list: RetentionAnomalyResp[]; total: number }>(
|
||||
api.get('/api/retention-anomalies', { params })
|
||||
),
|
||||
detail: (id: number) =>
|
||||
unwrap<RetentionAnomalyResp>(api.get(`/api/retention-anomalies/${id}`)),
|
||||
detect: (alertMonth: string) =>
|
||||
unwrap<number>(api.post('/api/retention-anomalies/detect', { alertMonth })),
|
||||
review: (req: RetentionAnomalyReviewReq) =>
|
||||
unwrap<void>(api.put('/api/retention-anomalies/review', req)),
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface YearEndStatementResp extends Record<string, unknown> {
|
||||
statementId: number;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
agentCode?: string;
|
||||
businessNo?: string;
|
||||
orgName?: string;
|
||||
statementYear: number;
|
||||
statementType?: string;
|
||||
statementTypeName?: string;
|
||||
totalIncome: number;
|
||||
totalWithheld: number;
|
||||
totalLocalTax: number;
|
||||
filePath?: string;
|
||||
fileFormat?: string;
|
||||
fileStatus?: string;
|
||||
fileStatusName?: string;
|
||||
generatedAt?: string;
|
||||
sentAt?: string;
|
||||
sentTo?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface YearEndStatementSearchParam {
|
||||
statementYear?: number;
|
||||
agentId?: number;
|
||||
orgId?: number;
|
||||
fileStatus?: string;
|
||||
fileFormat?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface YearEndStatementRegenerateReq {
|
||||
statementYear: number;
|
||||
agentIds?: number[];
|
||||
statementType?: string;
|
||||
}
|
||||
|
||||
export interface YearEndStatementSendReq {
|
||||
ids: number[];
|
||||
sentTo: string;
|
||||
}
|
||||
|
||||
export const yearEndStatementApi = {
|
||||
list: (params?: YearEndStatementSearchParam) =>
|
||||
unwrap<{ list: YearEndStatementResp[]; total: number }>(
|
||||
api.get('/api/year-end-statements', { params })
|
||||
),
|
||||
detail: (id: number) =>
|
||||
unwrap<YearEndStatementResp>(api.get(`/api/year-end-statements/${id}`)),
|
||||
regenerate: (req: YearEndStatementRegenerateReq) =>
|
||||
unwrap<number>(api.post('/api/year-end-statements/regenerate', req)),
|
||||
generate: (id: number, fileFormat?: string) =>
|
||||
unwrap<void>(api.post(`/api/year-end-statements/${id}/generate`, { fileFormat })),
|
||||
send: (req: YearEndStatementSendReq) =>
|
||||
unwrap<void>(api.put('/api/year-end-statements/send', req)),
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface Props extends ButtonProps {
|
||||
menuCode: string;
|
||||
permCode: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'EXPORT';
|
||||
permCode: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'EXPORT' | 'EXECUTE';
|
||||
}
|
||||
|
||||
/** 권한 없으면 렌더링 자체를 안 함 */
|
||||
@@ -15,7 +15,8 @@ export default function PermissionButton({ menuCode, permCode, children, ...rest
|
||||
(permCode === 'UPDATE' && perms.canUpdate) ||
|
||||
(permCode === 'DELETE' && perms.canDelete) ||
|
||||
(permCode === 'APPROVE' && perms.canApprove) ||
|
||||
(permCode === 'EXPORT' && perms.canExport);
|
||||
(permCode === 'EXPORT' && perms.canExport) ||
|
||||
(permCode === 'EXECUTE' && perms.canExecute);
|
||||
if (!allowed) return null;
|
||||
return <Button {...rest}>{children}</Button>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface MenuPermissions {
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
canExport: boolean;
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,7 @@ export function usePermission(menuCode: string): MenuPermissions {
|
||||
canDelete: perms.includes('DELETE'),
|
||||
canApprove: perms.includes('APPROVE'),
|
||||
canExport: perms.includes('EXPORT'),
|
||||
canExecute: perms.includes('EXECUTE'),
|
||||
};
|
||||
}, [tree, menuCode]);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user