동기화

This commit is contained in:
GA Pro
2026-05-15 00:25:18 +09:00
parent 88e14edffd
commit d5ed642c80
69 changed files with 2634 additions and 26 deletions
+16
View File
@@ -3,6 +3,14 @@ import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
// ── P5: 연말명세서 / 분개장 ──────────────────────────
import AgentAnnualIncomeList from '@/pages/income/AgentAnnualIncomeList';
import AccountingJournalList from '@/pages/accounting/AccountingJournalList';
// ── P5-W2: 원천세 / 부가세 신고 ──────────────────────
import WithholdingTaxReportList from '@/pages/tax/WithholdingTaxReportList';
import VatReportList from '@/pages/tax/VatReportList';
// ── P4: 수수료 종류 ──────────────────────────────────
import CommissionMaster from '@/pages/commission/CommissionMaster';
import CollectionCommission from '@/pages/commission/CollectionCommission';
@@ -146,6 +154,14 @@ export default function App() {
<Route path="kpi/retention" element={<KpiRetention />} />
<Route path="kpi/cumulative" element={<KpiCumulative />} />
{/* P5: 연말명세서 / 분개장 */}
<Route path="commission/annual-income" element={<AgentAnnualIncomeList />} />
<Route path="commission/accounting-journal" element={<AccountingJournalList />} />
{/* P5-W2: 원천세 / 부가세 신고 */}
<Route path="commission/withholding-tax" element={<WithholdingTaxReportList />} />
<Route path="commission/vat-report" element={<VatReportList />} />
{/* P4: 수수료 종류 */}
<Route path="commission/type" element={<CommissionMaster />} />
<Route path="commission/master" element={<CommissionMaster />} />
+48
View File
@@ -0,0 +1,48 @@
import api, { unwrap } from './request';
export interface AccountingJournalResp extends Record<string, unknown> {
entryId: number;
journalNo?: string;
agentId?: number;
agentName?: string;
contractNo?: string;
settleMonth?: string;
accountCode: string;
accountName?: string;
debitAmount: number;
creditAmount: number;
description?: string;
status?: string;
statusName?: string;
closedAt?: string;
createdAt?: string;
}
export interface AccountingGenerateReq {
settleMonth: string;
}
export interface AccountingCloseReq {
journalNo: string;
entryIds: number[];
}
export interface AccountingSearchParam {
settleMonth?: string;
journalNo?: string;
agentName?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
export const accountingApi = {
list: (params?: AccountingSearchParam) =>
unwrap<{ list: AccountingJournalResp[]; total: number }>(
api.get('/api/accounting-entries', { params })
),
generate: (req: AccountingGenerateReq) =>
unwrap<void>(api.post('/api/accounting-entries/generate', req)),
close: (req: AccountingCloseReq) =>
unwrap<void>(api.put('/api/accounting-entries/close', req)),
};
+44
View File
@@ -0,0 +1,44 @@
import api, { unwrap } from './request';
export interface AgentAnnualIncomeResp extends Record<string, unknown> {
incomeId: number;
agentId: number;
agentName?: string;
agentCode?: string;
orgName?: string;
settleYear: number;
totalCommission: number;
totalTaxWithheld: number;
businessIncome: number;
status?: string;
statusName?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AgentAnnualIncomeSaveReq {
agentId: number;
settleYear: number;
totalCommission: number;
totalTaxWithheld: number;
businessIncome: number;
}
export interface AgentAnnualIncomeSearchParam {
settleYear?: number;
agentId?: number;
agentName?: string;
pageNum?: number;
pageSize?: number;
}
export const incomeApi = {
list: (params?: AgentAnnualIncomeSearchParam) =>
unwrap<{ list: AgentAnnualIncomeResp[]; total: number }>(
api.get('/api/agent-annual-incomes', { params })
),
detail: (id: number) =>
unwrap<AgentAnnualIncomeResp>(api.get(`/api/agent-annual-incomes/${id}`)),
regenerate: (year: number) =>
unwrap<void>(api.post(`/api/agent-annual-incomes/regenerate`, null, { params: { year } })),
};
+2
View File
@@ -40,4 +40,6 @@ export const regulatoryApi = {
unwrap<void>(api.put(`/api/regulatory-limits/${id}`, req)),
deactivate: (id: number) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}/deactivate`)),
activate: (id: number) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}/activate`)),
};
+47
View File
@@ -0,0 +1,47 @@
import api, { unwrap } from './request';
export interface VatReportResp extends Record<string, unknown> {
reportId: number;
reportYear: number;
reportQuarter: number;
salesAmount: number;
salesVat: number;
purchaseAmount: number;
purchaseVat: number;
payableVat: number;
status?: string;
statusName?: string;
reportedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface VatSearchParam {
reportYear?: number;
reportQuarter?: number;
status?: string;
pageNum?: number;
pageSize?: number;
}
export interface VatRegenerateReq {
reportYear: number;
reportQuarter: number;
}
export interface VatReportCompleteReq {
ids: number[];
}
export const vatApi = {
list: (params?: VatSearchParam) =>
unwrap<{ list: VatReportResp[]; total: number }>(
api.get('/api/vat-reports', { params })
),
detail: (id: number) =>
unwrap<VatReportResp>(api.get(`/api/vat-reports/${id}`)),
regenerate: (req: VatRegenerateReq) =>
unwrap<void>(api.post('/api/vat-reports/regenerate', req)),
complete: (req: VatReportCompleteReq) =>
unwrap<void>(api.put('/api/vat-reports/complete', req)),
};
+50
View File
@@ -0,0 +1,50 @@
import api, { unwrap } from './request';
export interface WithholdingTaxReportResp extends Record<string, unknown> {
reportId: number;
agentId: number;
agentName?: string;
agentCode?: string;
orgName?: string;
reportYear: number;
reportQuarter: number;
totalIncome: number;
withholdingTax: number;
status?: string;
statusName?: string;
reportedAt?: string;
createdAt?: string;
updatedAt?: string;
}
export interface WithholdingTaxSearchParam {
reportYear?: number;
reportQuarter?: number;
agentId?: number;
agentName?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
export interface WithholdingTaxRegenerateReq {
reportYear: number;
reportQuarter: number;
}
export interface WithholdingTaxReportCompleteReq {
ids: number[];
}
export const withholdingTaxApi = {
list: (params?: WithholdingTaxSearchParam) =>
unwrap<{ list: WithholdingTaxReportResp[]; total: number }>(
api.get('/api/withholding-tax-reports', { params })
),
detail: (id: number) =>
unwrap<WithholdingTaxReportResp>(api.get(`/api/withholding-tax-reports/${id}`)),
regenerate: (req: WithholdingTaxRegenerateReq) =>
unwrap<void>(api.post('/api/withholding-tax-reports/regenerate', req)),
complete: (req: WithholdingTaxReportCompleteReq) =>
unwrap<void>(api.put('/api/withholding-tax-reports/complete', req)),
};
@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AgGridReact } from '@ag-grid-community/react';
import { ModuleRegistry } from '@ag-grid-community/core';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle } from '@ag-grid-community/core';
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle, SelectionChangedEvent } from '@ag-grid-community/core';
import '@ag-grid-community/styles/ag-grid.css';
import '@ag-grid-community/styles/ag-theme-quartz.css';
import { GRAY } from '@/theme/tokens';
@@ -33,6 +33,8 @@ interface Props<T> {
rowKey?: keyof T;
/** 행별 스타일 콜백 (만료 임박 강조 등) */
getRowStyle?: (params: RowClassParams<T>) => RowStyle | undefined;
rowSelection?: 'single' | 'multiple';
onSelectionChanged?: (e: SelectionChangedEvent<T>) => void;
}
/**
@@ -50,6 +52,8 @@ export default function DataGrid<T extends Record<string, unknown>>({
onCellChanged,
rowKey,
getRowStyle: getRowStyleProp,
rowSelection: rowSelectionProp = 'single',
onSelectionChanged,
}: Props<T>) {
const apiRef = useRef<GridApi<T> | null>(null);
@@ -93,7 +97,8 @@ export default function DataGrid<T extends Record<string, unknown>>({
rowHeight={56}
headerHeight={44}
localeText={AG_GRID_LOCALE_KO}
rowSelection="single"
rowSelection={rowSelectionProp}
onSelectionChanged={onSelectionChanged}
getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined}
pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined}
getRowStyle={(params) => {
@@ -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 { accountingApi, AccountingJournalResp, AccountingSearchParam } from '@/api/accounting';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<AccountingJournalResp>[] = [
{ field: 'journalNo', headerName: '분개번호', width: 140, pinned: 'left', rowGroup: false },
{ field: 'settleMonth', headerName: '정산월', width: 100 },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'accountCode', headerName: '계정코드', width: 110 },
{ field: 'accountName', headerName: '계정명', width: 150 },
{ field: 'debitAmount', headerName: '차변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'creditAmount', headerName: '대변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'description', headerName: '적요', flex: 1 },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'closedAt', headerName: '마감일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function AccountingJournalList() {
const qc = useQueryClient();
const currentMonth = dayjs().format('YYYYMM');
const [params, setParams] = useState<AccountingSearchParam>({
settleMonth: currentMonth,
pageSize: 500,
});
const [selectedRows, setSelectedRows] = useState<AccountingJournalResp[]>([]);
const [closeModalOpen, setCloseModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [closeForm] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['accounting-journals', params],
queryFn: () => accountingApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<AccountingJournalResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleGenerate = async () => {
try {
await accountingApi.generate({ settleMonth: params.settleMonth ?? currentMonth });
message.success('분개 생성 완료');
qc.invalidateQueries({ queryKey: ['accounting-journals'] });
setGenerateModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleClose = async () => {
const values = await closeForm.validateFields();
const ids = selectedRows.map((r) => r.entryId);
try {
await accountingApi.close({ journalNo: values.journalNo, entryIds: ids });
message.success('마감 완료');
qc.invalidateQueries({ queryKey: ['accounting-journals'] });
setCloseModalOpen(false);
closeForm.resetFields();
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer
title="회계 분개장"
description="정산 회계 분개 엔트리 관리"
extra={
<Space>
<PermissionButton
menuCode="ACCOUNTING_JOURNAL"
permCode="CREATE"
onClick={() => setGenerateModalOpen(true)}
>
</PermissionButton>
<PermissionButton
menuCode="ACCOUNTING_JOURNAL"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={() => setCloseModalOpen(true)}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/accounting-journals 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'text', name: 'journalNo', label: '분개번호', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AccountingSearchParam, pageSize: 500 })}
onReset={() => setParams({ settleMonth: currentMonth, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AccountingJournalResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="entryId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
/>
</ProCard>
{/* 분개 생성 확인 모달 */}
<Modal
title="분개 생성"
open={generateModalOpen}
onOk={handleGenerate}
onCancel={() => setGenerateModalOpen(false)}
okText="생성"
cancelText="취소"
>
<p>{params.settleMonth} ?</p>
</Modal>
{/* 마감 처리 모달 */}
<Modal
title="분개 마감"
open={closeModalOpen}
onOk={handleClose}
onCancel={() => { setCloseModalOpen(false); closeForm.resetFields(); }}
okText="마감"
cancelText="취소"
>
<p> {selectedRows.length} .</p>
<Form form={closeForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="journalNo"
label="분개번호 (journal_no)"
rules={[{ required: true, message: '분개번호를 입력하세요' }]}
>
<Input placeholder="예: JNL-2024-001" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Alert, Modal, 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 { incomeApi, AgentAnnualIncomeResp, AgentAnnualIncomeSearchParam } from '@/api/income';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<AgentAnnualIncomeResp>[] = [
{ field: 'settleYear', headerName: '귀속연도', width: 100, pinned: 'left' },
{ field: 'agentCode', headerName: '설계사코드', width: 120 },
{ field: 'agentName', headerName: '설계사명', width: 120 },
{ field: 'orgName', headerName: '소속조직', width: 160 },
{ field: 'totalCommission', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'totalTaxWithheld', headerName: '원천징수세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'businessIncome', headerName: '사업소득', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'updatedAt', headerName: '최종수정', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function AgentAnnualIncomeList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const [params, setParams] = useState<AgentAnnualIncomeSearchParam>({
settleYear: currentYear,
pageSize: 500,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['annual-income', params],
queryFn: () => incomeApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
totalCommission: rows.reduce((s, r) => s + (r.totalCommission ?? 0), 0),
totalTaxWithheld: rows.reduce((s, r) => s + (r.totalTaxWithheld ?? 0), 0),
businessIncome: rows.reduce((s, r) => s + (r.businessIncome ?? 0), 0),
} as Partial<AgentAnnualIncomeResp> : undefined;
const handleRegenerate = () => {
Modal.confirm({
title: '연말 사업소득 재집계',
content: `${params.settleYear ?? currentYear}년 데이터를 재집계하시겠습니까? 기존 데이터가 갱신됩니다.`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await incomeApi.regenerate(params.settleYear ?? currentYear);
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['annual-income'] });
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="연말 사업소득 명세서"
description="설계사별 귀속연도 사업소득 집계 현황"
extra={
<PermissionButton
menuCode="ANNUAL_INCOME"
permCode="APPROVE"
type="primary"
onClick={handleRegenerate}
>
</PermissionButton>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/agent-annual-incomes 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'settleYear', label: '귀속연도', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AgentAnnualIncomeSearchParam, pageSize: 500 })}
onReset={() => setParams({ settleYear: currentYear, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AgentAnnualIncomeResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="incomeId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -59,6 +59,20 @@ export default function RegulatoryLimitList() {
});
};
const onActivate = (record: RegulatoryLimitResp) => {
Modal.confirm({
title: '규제 한도 활성화',
content: `[${record.limitTypeName ?? record.limitType}] 한도를 활성화하시겠습니까?`,
okText: '활성화',
cancelText: '취소',
onOk: async () => {
await regulatoryApi.activate(record.limitId);
message.success('활성화 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: RegulatoryLimitSaveReq = {
...v,
@@ -108,9 +122,12 @@ export default function RegulatoryLimitList() {
render: (_, r) => [
<PermissionButton key="edit" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
r.isActive && (
r.isActive ? (
<PermissionButton key="deact" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link" danger
onClick={() => onDeactivate(r)}></PermissionButton>
) : (
<PermissionButton key="act" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
onClick={() => onActivate(r)}></PermissionButton>
),
],
},
+154
View File
@@ -0,0 +1,154 @@
import { useState, useCallback } from 'react';
import { Alert, 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 { vatApi, VatReportResp, VatSearchParam } from '@/api/vat';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<VatReportResp>[] = [
{ field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' },
{ field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` },
{ field: 'salesAmount', headerName: '매출액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'salesVat', headerName: '매출세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'purchaseAmount', headerName: '매입액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'purchaseVat', headerName: '매입세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'payableVat', headerName: '납부세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'reportedAt', headerName: '신고일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function VatReportList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const currentQuarter = Math.ceil((dayjs().month() + 1) / 3);
const [params, setParams] = useState<VatSearchParam>({
reportYear: currentYear,
reportQuarter: currentQuarter,
pageSize: 200,
});
const [selectedRows, setSelectedRows] = useState<VatReportResp[]>([]);
const { data, isLoading, isError } = useQuery({
queryKey: ['vat-reports', params],
queryFn: () => vatApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
reportYear: '합계' as unknown as number,
salesAmount: rows.reduce((s, r) => s + (r.salesAmount ?? 0), 0),
salesVat: rows.reduce((s, r) => s + (r.salesVat ?? 0), 0),
purchaseAmount: rows.reduce((s, r) => s + (r.purchaseAmount ?? 0), 0),
purchaseVat: rows.reduce((s, r) => s + (r.purchaseVat ?? 0), 0),
payableVat: rows.reduce((s, r) => s + (r.payableVat ?? 0), 0),
} as Partial<VatReportResp> : undefined;
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<VatReportResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleRegenerate = () => {
Modal.confirm({
title: '부가세 재집계',
content: `${params.reportYear}${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await vatApi.regenerate({
reportYear: params.reportYear ?? currentYear,
reportQuarter: params.reportQuarter ?? currentQuarter,
});
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['vat-reports'] });
} catch { /* request.ts 처리 */ }
},
});
};
const handleComplete = () => {
Modal.confirm({
title: '신고완료 처리',
content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`,
okText: '신고완료',
cancelText: '취소',
onOk: async () => {
try {
// NOTE: ids 필드명은 T19 컨트롤러 확인 후 수정 필요
await vatApi.complete({ ids: selectedRows.map((r) => r.reportId) });
message.success('신고완료 처리됨');
qc.invalidateQueries({ queryKey: ['vat-reports'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="부가세 신고 현황"
description="분기별 매출/매입/납부 부가세 집계 및 관리"
extra={
<Space>
<PermissionButton menuCode="VAT_REPORT" permCode="APPROVE" onClick={handleRegenerate}>
</PermissionButton>
<PermissionButton
menuCode="VAT_REPORT"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={handleComplete}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/vat-reports 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'reportYear', label: '신고연도', span: 6 },
{ type: 'text', name: 'reportQuarter', label: '분기(1~4)', span: 6 },
]}
onSearch={(v) => setParams({ ...v as VatSearchParam, pageSize: 200 })}
onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<VatReportResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={520}
rowKey="reportId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,156 @@
import { useState, useCallback } from 'react';
import { Alert, 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 {
withholdingTaxApi,
WithholdingTaxReportResp,
WithholdingTaxSearchParam,
} from '@/api/withholdingTax';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<WithholdingTaxReportResp>[] = [
{ field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' },
{ field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` },
{ 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: 'withholdingTax', headerName: '원천징수세', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'statusName', headerName: '상태', width: 90 },
{ field: 'reportedAt', headerName: '신고일시', width: 140 },
];
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
export default function WithholdingTaxReportList() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const currentQuarter = Math.ceil((dayjs().month() + 1) / 3);
const [params, setParams] = useState<WithholdingTaxSearchParam>({
reportYear: currentYear,
reportQuarter: currentQuarter,
pageSize: 500,
});
const [selectedRows, setSelectedRows] = useState<WithholdingTaxReportResp[]>([]);
const { data, isLoading, isError } = useQuery({
queryKey: ['withholding-tax', params],
queryFn: () => withholdingTaxApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
totalIncome: rows.reduce((s, r) => s + (r.totalIncome ?? 0), 0),
withholdingTax: rows.reduce((s, r) => s + (r.withholdingTax ?? 0), 0),
} as Partial<WithholdingTaxReportResp> : undefined;
const handleSelectionChanged = useCallback((e: SelectionChangedEvent<WithholdingTaxReportResp>) => {
setSelectedRows(e.api.getSelectedRows());
}, []);
const handleRegenerate = () => {
Modal.confirm({
title: '원천세 재집계',
content: `${params.reportYear}${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`,
okText: '재집계',
cancelText: '취소',
onOk: async () => {
try {
await withholdingTaxApi.regenerate({
reportYear: params.reportYear ?? currentYear,
reportQuarter: params.reportQuarter ?? currentQuarter,
});
message.success('재집계 완료');
qc.invalidateQueries({ queryKey: ['withholding-tax'] });
} catch { /* request.ts 처리 */ }
},
});
};
const handleComplete = () => {
Modal.confirm({
title: '신고완료 처리',
content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`,
okText: '신고완료',
cancelText: '취소',
onOk: async () => {
try {
// NOTE: ids 필드명은 T18 컨트롤러 확인 후 수정 필요
await withholdingTaxApi.complete({ ids: selectedRows.map((r) => r.reportId) });
message.success('신고완료 처리됨');
qc.invalidateQueries({ queryKey: ['withholding-tax'] });
setSelectedRows([]);
} catch { /* request.ts 처리 */ }
},
});
};
return (
<PageContainer
title="원천세 신고 현황"
description="설계사별 분기 원천세 신고 집계 및 관리"
extra={
<Space>
<PermissionButton menuCode="WITHHOLDING_TAX" permCode="APPROVE" onClick={handleRegenerate}>
</PermissionButton>
<PermissionButton
menuCode="WITHHOLDING_TAX"
permCode="APPROVE"
type="primary"
disabled={selectedRows.length === 0}
onClick={handleComplete}
>
({selectedRows.length})
</PermissionButton>
</Space>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="API 미응답"
description="/api/withholding-tax-reports 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'reportYear', label: '신고연도', span: 6 },
{ type: 'text', name: 'reportQuarter', label: '분기(1~4)', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setParams({ ...v as WithholdingTaxSearchParam, pageSize: 500 })}
onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 500 })}
/>
<ProCard style={cardStyle}>
<DataGrid<WithholdingTaxReportResp>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={620}
rowKey="reportId"
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}