동기화
This commit is contained in:
@@ -3,6 +3,30 @@ import LoginPage from '@/pages/LoginPage';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
|
||||
// ── P4: 수수료 종류 ──────────────────────────────────
|
||||
import CommissionMaster from '@/pages/commission/CommissionMaster';
|
||||
import CollectionCommission from '@/pages/commission/CollectionCommission';
|
||||
import RenewalCommission from '@/pages/commission/RenewalCommission';
|
||||
import ReinstatementCommission from '@/pages/commission/ReinstatementCommission';
|
||||
import PersistencyBonus from '@/pages/commission/PersistencyBonus';
|
||||
import OverrideLedger from '@/pages/commission/OverrideLedger';
|
||||
|
||||
// ── P4: 설계사 라이프사이클 ───────────────────────────
|
||||
import AgentContracts from '@/pages/agent/AgentContracts';
|
||||
import AgentLicenses from '@/pages/agent/AgentLicenses';
|
||||
import AgentTrainings from '@/pages/agent/AgentTrainings';
|
||||
|
||||
// ── P4: 계약 운영 ────────────────────────────────────
|
||||
import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
|
||||
import Complaints from '@/pages/contracts/Complaints';
|
||||
|
||||
// ── P4: 시스템 ──────────────────────────────────────
|
||||
import Approvals from '@/pages/system/Approvals';
|
||||
import Notices from '@/pages/system/Notices';
|
||||
|
||||
// ── P4: 출금 ────────────────────────────────────────
|
||||
import WithdrawRequest from '@/pages/payments/WithdrawRequest';
|
||||
|
||||
import OrgTree from '@/pages/org/OrgTree';
|
||||
import AgentList from '@/pages/org/AgentList';
|
||||
import AgentDetail from '@/pages/org/AgentDetail';
|
||||
@@ -29,6 +53,15 @@ import SettleList from '@/pages/settle/SettleList';
|
||||
import BatchRun from '@/pages/settle/BatchRun';
|
||||
import PaymentList from '@/pages/settle/PaymentList';
|
||||
|
||||
import PerformanceReport from '@/pages/report/PerformanceReport';
|
||||
import OrgReport from '@/pages/report/OrgReport';
|
||||
import ChargebackRiskReport from '@/pages/report/ChargebackRiskReport';
|
||||
|
||||
import KpiMonthlySummary from '@/pages/kpi/KpiMonthlySummary';
|
||||
import KpiOrgSummary from '@/pages/kpi/KpiOrgSummary';
|
||||
import KpiRetention from '@/pages/kpi/KpiRetention';
|
||||
import KpiCumulative from '@/pages/kpi/KpiCumulative';
|
||||
|
||||
import UserList from '@/pages/system/UserList';
|
||||
import RoleList from '@/pages/system/RoleList';
|
||||
import MenuManage from '@/pages/system/MenuManage';
|
||||
@@ -84,6 +117,42 @@ export default function App() {
|
||||
<Route path="settle/batch" element={<BatchRun />} />
|
||||
<Route path="payments" element={<PaymentList />} />
|
||||
|
||||
{/* 리포트 */}
|
||||
<Route path="report/performance" element={<PerformanceReport />} />
|
||||
<Route path="report/org" element={<OrgReport />} />
|
||||
<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 />} />
|
||||
|
||||
{/* P4: 수수료 종류 */}
|
||||
<Route path="commission/type" element={<CommissionMaster />} />
|
||||
<Route path="commission/master" element={<CommissionMaster />} />
|
||||
<Route path="commission/collection" element={<CollectionCommission />} />
|
||||
<Route path="commission/renewal" element={<RenewalCommission />} />
|
||||
<Route path="commission/reinstatement" element={<ReinstatementCommission />} />
|
||||
<Route path="commission/persistency" element={<PersistencyBonus />} />
|
||||
<Route path="commission/override-ledger" element={<OverrideLedger />} />
|
||||
|
||||
{/* P4: 설계사 라이프사이클 */}
|
||||
<Route path="agent/contracts" element={<AgentContracts />} />
|
||||
<Route path="agent/licenses" element={<AgentLicenses />} />
|
||||
<Route path="agent/trainings" element={<AgentTrainings />} />
|
||||
|
||||
{/* P4: 계약 운영 */}
|
||||
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
|
||||
<Route path="contracts/complaints" element={<Complaints />} />
|
||||
|
||||
{/* P4: 시스템 */}
|
||||
<Route path="system/approvals" element={<Approvals />} />
|
||||
<Route path="system/notices" element={<Notices />} />
|
||||
|
||||
{/* P4: 출금 */}
|
||||
<Route path="payments/withdraw" element={<WithdrawRequest />} />
|
||||
|
||||
{/* 시스템관리 */}
|
||||
<Route path="system/users" element={<UserList />} />
|
||||
<Route path="system/roles" element={<RoleList />} />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface AgentContractRow extends Record<string, unknown> {
|
||||
contractId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
carrierId: number;
|
||||
carrierName: string;
|
||||
contractType: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
commissionRate: number;
|
||||
status: string;
|
||||
terminatedAt?: string;
|
||||
terminationReason?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentContractSaveReq {
|
||||
agentId: number;
|
||||
carrierId: number;
|
||||
contractType: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
commissionRate: number;
|
||||
}
|
||||
|
||||
export interface AgentContractSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
carrierId?: number;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const agentContractApi = {
|
||||
list: (p: AgentContractSearchParam) =>
|
||||
unwrap<PageResponse<AgentContractRow>>(
|
||||
api.get('/api/agent-contracts', { params: p }),
|
||||
),
|
||||
create: (body: AgentContractSaveReq) =>
|
||||
unwrap<AgentContractRow>(api.post('/api/agent-contracts', body)),
|
||||
update: (id: number, body: Partial<AgentContractSaveReq>) =>
|
||||
unwrap<AgentContractRow>(api.put(`/api/agent-contracts/${id}`, body)),
|
||||
terminate: (id: number, reason: string) =>
|
||||
unwrap<void>(api.post(`/api/agent-contracts/${id}/terminate`, { reason })),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/agent-contracts/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface ApprovalLineRow extends Record<string, unknown> {
|
||||
lineId: number;
|
||||
lineName: string;
|
||||
description: string;
|
||||
stepCount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
steps?: ApprovalStep[];
|
||||
}
|
||||
|
||||
export interface ApprovalStep extends Record<string, unknown> {
|
||||
stepId: number;
|
||||
lineId: number;
|
||||
stepOrder: number;
|
||||
approverId: number;
|
||||
approverName: string;
|
||||
approverTitle?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestRow extends Record<string, unknown> {
|
||||
requestId: number;
|
||||
lineId: number;
|
||||
lineName: string;
|
||||
requesterId: number;
|
||||
requesterName: string;
|
||||
title: string;
|
||||
content: string;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
status: string; // PENDING / IN_PROGRESS / APPROVED / REJECTED
|
||||
currentApproverName?: string;
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
steps?: ApprovalRequestStep[];
|
||||
}
|
||||
|
||||
export interface ApprovalRequestStep extends Record<string, unknown> {
|
||||
stepId: number;
|
||||
stepOrder: number;
|
||||
approverId: number;
|
||||
approverName: string;
|
||||
status: string; // PENDING / APPROVED / REJECTED
|
||||
comment?: string;
|
||||
processedAt?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalLineSaveReq {
|
||||
lineName: string;
|
||||
description: string;
|
||||
steps: { stepOrder: number; approverId: number; role: string }[];
|
||||
}
|
||||
|
||||
export interface ApprovalRequestSaveReq {
|
||||
lineId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ApprovalSearchParam {
|
||||
status?: string;
|
||||
requesterId?: number;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const approvalApi = {
|
||||
// Lines
|
||||
lines: (p?: ApprovalSearchParam) =>
|
||||
unwrap<PageResponse<ApprovalLineRow>>(
|
||||
api.get('/api/approvals/lines', { params: p }),
|
||||
),
|
||||
lineCreate: (body: ApprovalLineSaveReq) =>
|
||||
unwrap<ApprovalLineRow>(api.post('/api/approvals/lines', body)),
|
||||
lineUpdate: (id: number, body: Partial<ApprovalLineSaveReq>) =>
|
||||
unwrap<ApprovalLineRow>(api.put(`/api/approvals/lines/${id}`, body)),
|
||||
lineDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/approvals/lines/${id}`)),
|
||||
|
||||
// Requests
|
||||
requests: (p?: ApprovalSearchParam) =>
|
||||
unwrap<PageResponse<ApprovalRequestRow>>(
|
||||
api.get('/api/approvals/requests', { params: p }),
|
||||
),
|
||||
myRequests: (p?: ApprovalSearchParam) =>
|
||||
unwrap<PageResponse<ApprovalRequestRow>>(
|
||||
api.get('/api/approvals/requests/mine', { params: p }),
|
||||
),
|
||||
pendingRequests: (p?: ApprovalSearchParam) =>
|
||||
unwrap<PageResponse<ApprovalRequestRow>>(
|
||||
api.get('/api/approvals/requests/pending', { params: p }),
|
||||
),
|
||||
requestCreate: (body: ApprovalRequestSaveReq) =>
|
||||
unwrap<ApprovalRequestRow>(api.post('/api/approvals/requests', body)),
|
||||
requestDetail: (id: number) =>
|
||||
unwrap<ApprovalRequestRow>(api.get(`/api/approvals/requests/${id}`)),
|
||||
advance: (id: number, body: { approved: boolean; comment?: string }) =>
|
||||
unwrap<void>(api.post(`/api/approvals/requests/${id}/advance`, body)),
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface AttachmentMeta extends Record<string, unknown> {
|
||||
attachmentId: number;
|
||||
refType: string;
|
||||
refId: number;
|
||||
originalName: string;
|
||||
storedPath: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
uploadedBy: number;
|
||||
uploaderName: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export const attachmentApi = {
|
||||
list: (refType: string, refId: number) =>
|
||||
unwrap<AttachmentMeta[]>(
|
||||
api.get('/api/attachments', { params: { refType, refId } }),
|
||||
),
|
||||
upload: (refType: string, refId: number, file: File, onProgress?: (pct: number) => void) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('refType', refType);
|
||||
form.append('refId', String(refId));
|
||||
return unwrap<AttachmentMeta>(
|
||||
api.post('/api/attachments', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (evt) => {
|
||||
if (onProgress && evt.total) {
|
||||
onProgress(Math.round((evt.loaded / evt.total) * 100));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/attachments/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
// ── 공통 ──────────────────────────────────────────
|
||||
export interface CommissionSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
settleMonth?: string; // YYYYMM
|
||||
commissionType?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// ── CommissionMaster ──────────────────────────────
|
||||
export interface CommissionMasterRow extends Record<string, unknown> {
|
||||
masterId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
settleMonth: string;
|
||||
commissionType: string;
|
||||
commissionTypeName: string;
|
||||
grossAmount: number;
|
||||
netAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── CollectionCommission ──────────────────────────
|
||||
export interface CollectionRuleRow extends Record<string, unknown> {
|
||||
ruleId: number;
|
||||
carrierId: number;
|
||||
carrierName: string;
|
||||
productType: string;
|
||||
paymentCycle: string;
|
||||
ratePercent: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CollectionLedgerRow extends Record<string, unknown> {
|
||||
ledgerId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
contractNo: string;
|
||||
settleMonth: string;
|
||||
collectionAmount: number;
|
||||
commissionAmount: number;
|
||||
ratePercent: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── RenewalCommission ─────────────────────────────
|
||||
export interface RenewalRuleRow extends Record<string, unknown> {
|
||||
ruleId: number;
|
||||
carrierId: number;
|
||||
carrierName: string;
|
||||
productType: string;
|
||||
ratePercent: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RenewalLedgerRow extends Record<string, unknown> {
|
||||
ledgerId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
contractNo: string;
|
||||
settleMonth: string;
|
||||
renewalPremium: number;
|
||||
commissionAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── ReinstatementCommission ───────────────────────
|
||||
export interface ReinstatementRuleRow extends Record<string, unknown> {
|
||||
ruleId: number;
|
||||
carrierId: number;
|
||||
carrierName: string;
|
||||
productType: string;
|
||||
ratePercent: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ReinstatementLedgerRow extends Record<string, unknown> {
|
||||
ledgerId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
contractNo: string;
|
||||
settleMonth: string;
|
||||
reinstatementPremium: number;
|
||||
commissionAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── PersistencyBonus ──────────────────────────────
|
||||
export interface PersistencyRuleRow extends Record<string, unknown> {
|
||||
ruleId: number;
|
||||
bonusType: string; // 13M/25M/37M/49M
|
||||
minRate: number;
|
||||
bonusAmount?: number;
|
||||
bonusRate?: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PersistencyBonusRow extends Record<string, unknown> {
|
||||
bonusId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
bonusType: string;
|
||||
evaluationMonth: string;
|
||||
retentionRate: number;
|
||||
bonusAmount: number;
|
||||
paidYn: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── OverrideLedger ────────────────────────────────
|
||||
export interface OverrideLedgerRow extends Record<string, unknown> {
|
||||
ledgerId: number;
|
||||
fromAgentId: number;
|
||||
fromAgentName: string;
|
||||
toAgentId: number;
|
||||
toAgentName: string;
|
||||
settleMonth: string;
|
||||
baseCommission: number;
|
||||
overrideRate: number;
|
||||
overrideAmount: number;
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OverrideLedgerSearchParam {
|
||||
fromAgentId?: number;
|
||||
toAgentId?: number;
|
||||
agentName?: string;
|
||||
settleMonth?: string;
|
||||
orgId?: number;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────
|
||||
export const commissionApi = {
|
||||
// CommissionMaster
|
||||
masterList: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<CommissionMasterRow>>(
|
||||
api.get('/api/commissions', { params: p }),
|
||||
),
|
||||
masterByAgent: (agentId: number, yyyymm: string) =>
|
||||
unwrap<CommissionMasterRow[]>(
|
||||
api.get(`/api/commissions/agent/${agentId}`, { params: { yyyymm } }),
|
||||
),
|
||||
|
||||
// CollectionCommission
|
||||
collectionRules: (p?: Partial<CommissionSearchParam>) =>
|
||||
unwrap<PageResponse<CollectionRuleRow>>(
|
||||
api.get('/api/collection-commissions/rules', { params: p }),
|
||||
),
|
||||
collectionRuleCreate: (body: Partial<CollectionRuleRow>) =>
|
||||
unwrap<CollectionRuleRow>(api.post('/api/collection-commissions/rules', body)),
|
||||
collectionRuleUpdate: (id: number, body: Partial<CollectionRuleRow>) =>
|
||||
unwrap<CollectionRuleRow>(api.put(`/api/collection-commissions/rules/${id}`, body)),
|
||||
collectionRuleDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/collection-commissions/rules/${id}`)),
|
||||
collectionLedgers: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<CollectionLedgerRow>>(
|
||||
api.get('/api/collection-commissions/ledgers', { params: p }),
|
||||
),
|
||||
|
||||
// RenewalCommission
|
||||
renewalRules: (p?: Partial<CommissionSearchParam>) =>
|
||||
unwrap<PageResponse<RenewalRuleRow>>(
|
||||
api.get('/api/renewal-commissions/rules', { params: p }),
|
||||
),
|
||||
renewalRuleCreate: (body: Partial<RenewalRuleRow>) =>
|
||||
unwrap<RenewalRuleRow>(api.post('/api/renewal-commissions/rules', body)),
|
||||
renewalRuleUpdate: (id: number, body: Partial<RenewalRuleRow>) =>
|
||||
unwrap<RenewalRuleRow>(api.put(`/api/renewal-commissions/rules/${id}`, body)),
|
||||
renewalRuleDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/renewal-commissions/rules/${id}`)),
|
||||
renewalLedgers: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<RenewalLedgerRow>>(
|
||||
api.get('/api/renewal-commissions/ledgers', { params: p }),
|
||||
),
|
||||
|
||||
// ReinstatementCommission
|
||||
reinstatementRules: (p?: Partial<CommissionSearchParam>) =>
|
||||
unwrap<PageResponse<ReinstatementRuleRow>>(
|
||||
api.get('/api/reinstatement-commissions/rules', { params: p }),
|
||||
),
|
||||
reinstatementRuleCreate: (body: Partial<ReinstatementRuleRow>) =>
|
||||
unwrap<ReinstatementRuleRow>(api.post('/api/reinstatement-commissions/rules', body)),
|
||||
reinstatementRuleUpdate: (id: number, body: Partial<ReinstatementRuleRow>) =>
|
||||
unwrap<ReinstatementRuleRow>(api.put(`/api/reinstatement-commissions/rules/${id}`, body)),
|
||||
reinstatementRuleDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/reinstatement-commissions/rules/${id}`)),
|
||||
reinstatementLedgers: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<ReinstatementLedgerRow>>(
|
||||
api.get('/api/reinstatement-commissions/ledgers', { params: p }),
|
||||
),
|
||||
|
||||
// PersistencyBonus
|
||||
persistencyRules: (p?: Partial<CommissionSearchParam>) =>
|
||||
unwrap<PageResponse<PersistencyRuleRow>>(
|
||||
api.get('/api/persistency-bonus/rules', { params: p }),
|
||||
),
|
||||
persistencyRuleCreate: (body: Partial<PersistencyRuleRow>) =>
|
||||
unwrap<PersistencyRuleRow>(api.post('/api/persistency-bonus/rules', body)),
|
||||
persistencyRuleUpdate: (id: number, body: Partial<PersistencyRuleRow>) =>
|
||||
unwrap<PersistencyRuleRow>(api.put(`/api/persistency-bonus/rules/${id}`, body)),
|
||||
persistencyRuleDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/persistency-bonus/rules/${id}`)),
|
||||
persistencyBonuses: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<PersistencyBonusRow>>(
|
||||
api.get('/api/persistency-bonus/bonuses', { params: p }),
|
||||
),
|
||||
persistencyBonusUpsert: (body: Partial<PersistencyBonusRow>) =>
|
||||
unwrap<PersistencyBonusRow>(api.post('/api/persistency-bonus/bonuses', body)),
|
||||
|
||||
// OverrideLedger
|
||||
overrideLedgers: (p: OverrideLedgerSearchParam) =>
|
||||
unwrap<PageResponse<OverrideLedgerRow>>(
|
||||
api.get('/api/override-ledgers', { params: p }),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface ComplaintRow extends Record<string, unknown> {
|
||||
complaintId: number;
|
||||
contractNo: string;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
complaintType: string;
|
||||
content: string;
|
||||
receivedDate: string;
|
||||
status: string;
|
||||
closedAt?: string;
|
||||
chargebackTriggered: boolean;
|
||||
chargebackAmount?: number;
|
||||
processNote?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ComplaintSaveReq {
|
||||
contractNo: string;
|
||||
agentId: number;
|
||||
complaintType: string;
|
||||
content: string;
|
||||
receivedDate: string;
|
||||
chargebackTriggered: boolean;
|
||||
}
|
||||
|
||||
export interface ComplaintSearchParam {
|
||||
contractNo?: string;
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
complaintType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const complaintApi = {
|
||||
list: (p: ComplaintSearchParam) =>
|
||||
unwrap<PageResponse<ComplaintRow>>(
|
||||
api.get('/api/complaints', { params: p }),
|
||||
),
|
||||
create: (body: ComplaintSaveReq) =>
|
||||
unwrap<ComplaintRow>(api.post('/api/complaints', body)),
|
||||
update: (id: number, body: Partial<ComplaintSaveReq>) =>
|
||||
unwrap<ComplaintRow>(api.put(`/api/complaints/${id}`, body)),
|
||||
close: (id: number, processNote: string) =>
|
||||
unwrap<void>(api.post(`/api/complaints/${id}/close`, { processNote })),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/complaints/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface ContractEndorsementRow extends Record<string, unknown> {
|
||||
endorsementId: number;
|
||||
contractNo: string;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
endorsementType: string; // PREMIUM / INSURED / BENEFICIARY / ETC
|
||||
endorsementTypeName: string;
|
||||
changeDescription: string;
|
||||
endorsementDate: string;
|
||||
recalculateRequired: boolean;
|
||||
recalculatedAt?: string;
|
||||
status: string;
|
||||
processedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContractEndorsementSaveReq {
|
||||
contractNo: string;
|
||||
agentId: number;
|
||||
endorsementType: string;
|
||||
changeDescription: string;
|
||||
endorsementDate: string;
|
||||
recalculateRequired: boolean;
|
||||
}
|
||||
|
||||
export interface EndorsementSearchParam {
|
||||
contractNo?: string;
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
endorsementType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const endorsementApi = {
|
||||
list: (p: EndorsementSearchParam) =>
|
||||
unwrap<PageResponse<ContractEndorsementRow>>(
|
||||
api.get('/api/contract-endorsements', { params: p }),
|
||||
),
|
||||
create: (body: ContractEndorsementSaveReq) =>
|
||||
unwrap<ContractEndorsementRow>(api.post('/api/contract-endorsements', body)),
|
||||
update: (id: number, body: Partial<ContractEndorsementSaveReq>) =>
|
||||
unwrap<ContractEndorsementRow>(api.put(`/api/contract-endorsements/${id}`, body)),
|
||||
process: (id: number) =>
|
||||
unwrap<void>(api.post(`/api/contract-endorsements/${id}/process`)),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/contract-endorsements/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 공통 검색 파라미터
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export interface KpiSearchParam {
|
||||
fromMonth?: string; // YYYYMM
|
||||
toMonth?: string; // YYYYMM
|
||||
orgId?: number;
|
||||
agentId?: number;
|
||||
carrierId?: number;
|
||||
levelType?: 'AGENT' | 'ORG';
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 응답 타입
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export interface MonthlySummaryRow extends Record<string, unknown> {
|
||||
yyyymm: string;
|
||||
agentCount: number;
|
||||
totalRecruit: number;
|
||||
totalMaintain: number;
|
||||
totalOverride: number;
|
||||
totalChargeback: number;
|
||||
totalGross: number;
|
||||
totalTax: number;
|
||||
totalNet: number;
|
||||
totalIncentive: number;
|
||||
refreshedAt?: string;
|
||||
}
|
||||
|
||||
export interface OrgSummaryRow extends Record<string, unknown> {
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
orgType?: string;
|
||||
yyyymm: string;
|
||||
agentCount: number;
|
||||
totalRecruit: number;
|
||||
totalMaintain: number;
|
||||
totalGross: number;
|
||||
totalNet: number;
|
||||
refreshedAt?: string;
|
||||
}
|
||||
|
||||
export interface RetentionRow extends Record<string, unknown> {
|
||||
contractMonth: string;
|
||||
carrierId?: number;
|
||||
carrierName?: string;
|
||||
totalContracts: number;
|
||||
retained13m: number;
|
||||
retained25m: number;
|
||||
retentionRate13m: number;
|
||||
retentionRate25m: number;
|
||||
refreshedAt?: string;
|
||||
}
|
||||
|
||||
export interface CumulativeRow extends Record<string, unknown> {
|
||||
levelType: 'AGENT' | 'ORG';
|
||||
entityId: number;
|
||||
entityName: string;
|
||||
orgId?: number;
|
||||
orgName?: string;
|
||||
firstSettleMonth?: string;
|
||||
lastSettleMonth?: string;
|
||||
settleMonthCount: number;
|
||||
cumGross: number;
|
||||
cumNet: number;
|
||||
cumChargeback: number;
|
||||
cumOverride: number;
|
||||
cumIncentive: number;
|
||||
refreshedAt?: string;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// API 함수
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const kpiApi = {
|
||||
monthlySummary: (p: KpiSearchParam) =>
|
||||
unwrap<PageResponse<MonthlySummaryRow>>(
|
||||
api.get('/api/kpi/monthly-summary', { params: p }),
|
||||
),
|
||||
|
||||
orgSummary: (p: KpiSearchParam) =>
|
||||
unwrap<PageResponse<OrgSummaryRow>>(
|
||||
api.get('/api/kpi/org-summary', { params: p }),
|
||||
),
|
||||
|
||||
retention: (p: KpiSearchParam) =>
|
||||
unwrap<PageResponse<RetentionRow>>(
|
||||
api.get('/api/kpi/retention', { params: p }),
|
||||
),
|
||||
|
||||
cumulative: (p: KpiSearchParam) =>
|
||||
unwrap<PageResponse<CumulativeRow>>(
|
||||
api.get('/api/kpi/cumulative', { params: p }),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface AgentLicenseRow extends Record<string, unknown> {
|
||||
licenseId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
licenseType: string; // LIFE / NONLIFE / VARIABLE
|
||||
licenseTypeName: string;
|
||||
licenseNo: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
renewedAt?: string;
|
||||
status: string;
|
||||
daysUntilExpiry?: number;
|
||||
}
|
||||
|
||||
export interface AgentLicenseSaveReq {
|
||||
agentId: number;
|
||||
licenseType: string;
|
||||
licenseNo: string;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AgentLicenseSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
licenseType?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const licenseApi = {
|
||||
list: (p: AgentLicenseSearchParam) =>
|
||||
unwrap<PageResponse<AgentLicenseRow>>(
|
||||
api.get('/api/agent-licenses', { params: p }),
|
||||
),
|
||||
expiring: (days = 30) =>
|
||||
unwrap<AgentLicenseRow[]>(
|
||||
api.get('/api/agent-licenses/expiring', { params: { days } }),
|
||||
),
|
||||
create: (body: AgentLicenseSaveReq) =>
|
||||
unwrap<AgentLicenseRow>(api.post('/api/agent-licenses', body)),
|
||||
update: (id: number, body: Partial<AgentLicenseSaveReq>) =>
|
||||
unwrap<AgentLicenseRow>(api.put(`/api/agent-licenses/${id}`, body)),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/agent-licenses/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface NoticeRow extends Record<string, unknown> {
|
||||
noticeId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
noticeType: string; // GENERAL / IMPORTANT / SYSTEM
|
||||
isPinned: boolean;
|
||||
authorId: number;
|
||||
authorName: string;
|
||||
viewCount: number;
|
||||
publishedAt?: string;
|
||||
expiresAt?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NoticeSaveReq {
|
||||
title: string;
|
||||
content: string;
|
||||
noticeType: string;
|
||||
isPinned: boolean;
|
||||
publishedAt?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface NoticeSearchParam {
|
||||
title?: string;
|
||||
noticeType?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const noticeApi = {
|
||||
list: (p?: NoticeSearchParam) =>
|
||||
unwrap<PageResponse<NoticeRow>>(
|
||||
api.get('/api/notices', { params: p }),
|
||||
),
|
||||
detail: (id: number) =>
|
||||
unwrap<NoticeRow>(api.get(`/api/notices/${id}`)),
|
||||
create: (body: NoticeSaveReq) =>
|
||||
unwrap<NoticeRow>(api.post('/api/notices', body)),
|
||||
update: (id: number, body: Partial<NoticeSaveReq>) =>
|
||||
unwrap<NoticeRow>(api.put(`/api/notices/${id}`, body)),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/notices/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface UserNotificationRow extends Record<string, unknown> {
|
||||
notificationId: number;
|
||||
userId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
notificationType: string;
|
||||
refType?: string;
|
||||
refId?: number;
|
||||
isRead: boolean;
|
||||
readAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
list: (p?: { isRead?: boolean; pageNum?: number; pageSize?: number }) =>
|
||||
unwrap<PageResponse<UserNotificationRow>>(
|
||||
api.get('/api/user-notifications', { params: p }),
|
||||
),
|
||||
unreadCount: () =>
|
||||
unwrap<number>(api.get('/api/user-notifications/unread-count')),
|
||||
markRead: (id: number) =>
|
||||
unwrap<void>(api.post(`/api/user-notifications/${id}/read`)),
|
||||
markAllRead: () =>
|
||||
unwrap<void>(api.post('/api/user-notifications/read-all')),
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface AgentTrainingRow extends Record<string, unknown> {
|
||||
trainingId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
trainingName: string;
|
||||
trainingType: string;
|
||||
completedHours: number;
|
||||
trainingDate: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface TrainingRequirement extends Record<string, unknown> {
|
||||
requirementId: number;
|
||||
trainingType: string;
|
||||
requiredHours: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface AnnualHoursSummary extends Record<string, unknown> {
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
year: number;
|
||||
completedHours: number;
|
||||
requiredHours: number;
|
||||
satisfactionRate: number;
|
||||
satisfied: boolean;
|
||||
}
|
||||
|
||||
export interface AgentTrainingSaveReq {
|
||||
agentId: number;
|
||||
trainingName: string;
|
||||
trainingType: string;
|
||||
completedHours: number;
|
||||
trainingDate: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface AgentTrainingSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
year?: number;
|
||||
trainingType?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const trainingApi = {
|
||||
list: (p: AgentTrainingSearchParam) =>
|
||||
unwrap<PageResponse<AgentTrainingRow>>(
|
||||
api.get('/api/agent-trainings', { params: p }),
|
||||
),
|
||||
annualHours: (agentId: number, year: number) =>
|
||||
unwrap<AnnualHoursSummary>(
|
||||
api.get('/api/agent-trainings/annual-hours', { params: { agentId, year } }),
|
||||
),
|
||||
requirements: () =>
|
||||
unwrap<TrainingRequirement[]>(
|
||||
api.get('/api/training-requirements'),
|
||||
),
|
||||
create: (body: AgentTrainingSaveReq) =>
|
||||
unwrap<AgentTrainingRow>(api.post('/api/agent-trainings', body)),
|
||||
update: (id: number, body: Partial<AgentTrainingSaveReq>) =>
|
||||
unwrap<AgentTrainingRow>(api.put(`/api/agent-trainings/${id}`, body)),
|
||||
delete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/agent-trainings/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface WithdrawRequestRow extends Record<string, unknown> {
|
||||
withdrawId: number;
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
settleMonth: string;
|
||||
requestAmount: number;
|
||||
bankCode: string;
|
||||
accountNo: string;
|
||||
accountHolder: string;
|
||||
status: string; // PENDING / IN_APPROVAL / APPROVED / SENT / COMPLETED / FAILED
|
||||
approvalRequestId?: number;
|
||||
approvalStatus?: string;
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
failReason?: string;
|
||||
}
|
||||
|
||||
export interface WithdrawRequestSaveReq {
|
||||
settleMonth: string;
|
||||
requestAmount: number;
|
||||
bankCode: string;
|
||||
accountNo: string;
|
||||
accountHolder: string;
|
||||
}
|
||||
|
||||
export interface WithdrawSearchParam {
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
settleMonth?: string;
|
||||
status?: string;
|
||||
viewAll?: boolean;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const withdrawApi = {
|
||||
list: (p: WithdrawSearchParam) =>
|
||||
unwrap<PageResponse<WithdrawRequestRow>>(
|
||||
api.get('/api/withdraw-requests', { params: p }),
|
||||
),
|
||||
create: (body: WithdrawRequestSaveReq) =>
|
||||
unwrap<WithdrawRequestRow>(api.post('/api/withdraw-requests', body)),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Steps, Typography } from 'antd';
|
||||
import { IconCheck, IconX, IconClock } from '@tabler/icons-react';
|
||||
import { ApprovalRequestStep } from '@/api/approval';
|
||||
import { COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY, GRAY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
steps: ApprovalRequestStep[];
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function stepIcon(status: string) {
|
||||
if (status === 'APPROVED') return <IconCheck size={14} style={{ color: COLOR_SUCCESS }} />;
|
||||
if (status === 'REJECTED') return <IconX size={14} style={{ color: COLOR_ERROR }} />;
|
||||
return <IconClock size={14} style={{ color: COLOR_PRIMARY }} />;
|
||||
}
|
||||
|
||||
function stepStatus(status: string): 'finish' | 'error' | 'process' | 'wait' {
|
||||
if (status === 'APPROVED') return 'finish';
|
||||
if (status === 'REJECTED') return 'error';
|
||||
if (status === 'IN_PROGRESS') return 'process';
|
||||
return 'wait';
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 진행 시각화 컴포넌트.
|
||||
* Steps 수평 렌더링 + 단계별 상태 색상.
|
||||
*/
|
||||
export default function ApprovalProgress({ steps, currentStep }: Props) {
|
||||
const items = steps.map((s) => ({
|
||||
title: (
|
||||
<Text style={{ fontSize: 12, fontWeight: 600, color: GRAY[700] }}>
|
||||
{s.approverName}
|
||||
</Text>
|
||||
),
|
||||
description: (
|
||||
<div style={{ fontSize: 11, color: GRAY[500] }}>
|
||||
{s.comment && <div style={{ marginTop: 2, color: GRAY[600] }}>{s.comment}</div>}
|
||||
{s.processedAt && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{s.processedAt.slice(0, 10)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: stepIcon(s.status),
|
||||
status: stepStatus(s.status),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStep - 1}
|
||||
items={items}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, List, Progress, Space, Typography, Upload, message } from 'antd';
|
||||
import { IconFile, IconTrash, IconUpload } from '@tabler/icons-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { attachmentApi, AttachmentMeta } from '@/api/attachment';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface Props {
|
||||
refType: string;
|
||||
refId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공용 첨부파일 업로드/목록/삭제 컴포넌트.
|
||||
* refType + refId 로 연결. Drag+Drop 지원.
|
||||
*/
|
||||
export default function AttachmentUpload({ refType, refId }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const { data: files = [], isLoading } = useQuery({
|
||||
queryKey: ['attachments', refType, refId],
|
||||
queryFn: () => attachmentApi.list(refType, refId),
|
||||
enabled: !!refId,
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
await attachmentApi.upload(refType, refId, file, setProgress);
|
||||
message.success(`${file.name} 업로드 완료`);
|
||||
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
|
||||
} catch {
|
||||
message.error('업로드 실패');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
}
|
||||
return false; // prevent antd auto-upload
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
try {
|
||||
await attachmentApi.delete(id);
|
||||
message.success(`${name} 삭제 완료`);
|
||||
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
|
||||
} catch {
|
||||
message.error('삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const fmtSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger
|
||||
multiple={false}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
borderRadius: RADIUS.md,
|
||||
borderColor: GRAY[200],
|
||||
background: GRAY[50],
|
||||
padding: '12px 0',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, color: GRAY[500], fontSize: 13 }}>
|
||||
<IconUpload size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||
파일을 드래그하거나 클릭하여 업로드 (최대 50MB)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{uploading && (
|
||||
<Progress percent={progress} status="active" style={{ marginTop: 8 }} />
|
||||
)}
|
||||
|
||||
{!isLoading && files.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
background: '#fff',
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
borderRadius: RADIUS.md,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
dataSource={files}
|
||||
renderItem={(f: AttachmentMeta) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
key="del"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<IconTrash size={14} />}
|
||||
style={{ color: COLOR_ERROR }}
|
||||
onClick={() => handleDelete(f.attachmentId, f.originalName)}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Space>
|
||||
<IconFile size={14} style={{ color: GRAY[400] }} />
|
||||
<Text style={{ fontSize: 13 }}>{f.originalName}</Text>
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>({fmtSize(f.fileSize)})</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Badge, Button, Dropdown, Empty, Space, Typography } from 'antd';
|
||||
import { IconBell } from '@tabler/icons-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationApi, UserNotificationRow } from '@/api/notification';
|
||||
import { GRAY, COLOR_PRIMARY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* MainLayout 우측 상단 알림 벨.
|
||||
* 미읽음 카운트 빨간 dot + Dropdown 최근 10개 리스트.
|
||||
*/
|
||||
export default function NotificationBell() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: count = 0 } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationApi.unreadCount,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: page } = useQuery({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: () => notificationApi.list({ pageSize: 10 }),
|
||||
});
|
||||
|
||||
const notifications = page?.list ?? [];
|
||||
|
||||
const handleRead = async (id: number) => {
|
||||
await notificationApi.markRead(id);
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const handleReadAll = async () => {
|
||||
await notificationApi.markAllRead();
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const overlay = (
|
||||
<div style={{
|
||||
width: 340,
|
||||
background: '#fff',
|
||||
borderRadius: RADIUS.lg,
|
||||
boxShadow: SHADOW.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${GRAY[100]}`,
|
||||
}}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 700, color: GRAY[800] }}>알림</Text>
|
||||
{count > 0 && (
|
||||
<Button type="link" size="small" onClick={handleReadAll}
|
||||
style={{ fontSize: 12, padding: 0 }}>
|
||||
모두 읽음
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="알림이 없습니다"
|
||||
style={{ padding: '24px 0' }} />
|
||||
) : (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{notifications.map((n: UserNotificationRow) => (
|
||||
<div
|
||||
key={n.notificationId}
|
||||
onClick={() => !n.isRead && handleRead(n.notificationId)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
cursor: n.isRead ? 'default' : 'pointer',
|
||||
background: n.isRead ? '#fff' : '#EBF3FF',
|
||||
borderBottom: `1px solid ${GRAY[100]}`,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
fontWeight: n.isRead ? 400 : 600,
|
||||
color: GRAY[800],
|
||||
}}>
|
||||
{n.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500] }}>{n.content}</Text>
|
||||
<Text style={{ fontSize: 11, color: GRAY[400] }}>
|
||||
{n.createdAt?.slice(0, 16)}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownRender={() => overlay}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Badge count={count} size="small" offset={[-3, 3]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconBell size={18} />}
|
||||
style={{
|
||||
color: GRAY[600],
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
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 } from '@ag-grid-community/core';
|
||||
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle } 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';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule]);
|
||||
|
||||
/**
|
||||
* AG Grid 한국어 로케일 (자주 쓰이는 항목 위주).
|
||||
* 전체 키 목록은 AG Grid 공식 문서 참조.
|
||||
*/
|
||||
const AG_GRID_LOCALE_KO: Record<string, string> = {
|
||||
page: '페이지', more: '더보기', to: '~', of: '/', next: '다음', last: '마지막',
|
||||
first: '처음', previous: '이전', loadingOoo: '불러오는 중...',
|
||||
@@ -33,13 +31,15 @@ interface Props<T> {
|
||||
/** 셀 편집 가능 시 콜백 */
|
||||
onCellChanged?: (row: T, field: string, newValue: unknown) => void;
|
||||
rowKey?: keyof T;
|
||||
/** 행별 스타일 콜백 (만료 임박 강조 등) */
|
||||
getRowStyle?: (params: RowClassParams<T>) => RowStyle | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* AG Grid Community 클라이언트사이드 래퍼.
|
||||
* - 100건 이상 / 셀 편집 / 합계행이 필요한 화면용
|
||||
* - 서버사이드는 별도 SSRM 모듈 필요 (Community 한정 → enterprise만 지원)
|
||||
* 여기서는 현실적인 절충안으로 클라이언트사이드 + 외부 페이징 사용
|
||||
* - ag-theme-quartz 적용
|
||||
* - 헤더 폰트/높이 tokens 와 동기화
|
||||
* - 로딩 시 Skeleton 표시
|
||||
*/
|
||||
export default function DataGrid<T extends Record<string, unknown>>({
|
||||
rows,
|
||||
@@ -49,15 +49,17 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
pinnedBottomRow,
|
||||
onCellChanged,
|
||||
rowKey,
|
||||
getRowStyle: getRowStyleProp,
|
||||
}: Props<T>) {
|
||||
const apiRef = useRef<GridApi<T> | null>(null);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(
|
||||
() => ({
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
suppressMovable: false,
|
||||
cellStyle: { display: 'flex', alignItems: 'center' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -69,10 +71,18 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
useEffect(() => {
|
||||
if (apiRef.current) {
|
||||
if (loading) apiRef.current.showLoadingOverlay();
|
||||
else apiRef.current.hideOverlay();
|
||||
else apiRef.current.hideOverlay();
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
if (loading && !rows?.length) {
|
||||
return (
|
||||
<div style={{ padding: 24, background: '#fff', borderRadius: 16, border: `1px solid ${GRAY[100]}` }}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ag-theme-quartz" style={{ height, width: '100%' }}>
|
||||
<AgGridReact<T>
|
||||
@@ -80,15 +90,18 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
columnDefs={columns}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows
|
||||
rowHeight={36}
|
||||
headerHeight={38}
|
||||
rowHeight={56}
|
||||
headerHeight={44}
|
||||
localeText={AG_GRID_LOCALE_KO}
|
||||
rowSelection="single"
|
||||
getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined}
|
||||
pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined}
|
||||
getRowStyle={(params) => {
|
||||
if (params.node.rowPinned) {
|
||||
return { background: '#fafafa', fontWeight: 500 };
|
||||
return { background: '#f0f4ff', fontWeight: 600 };
|
||||
}
|
||||
if (getRowStyleProp) {
|
||||
return getRowStyleProp(params as RowClassParams<T>);
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
@@ -98,8 +111,15 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
}
|
||||
}}
|
||||
onGridReady={onGridReady}
|
||||
loadingOverlayComponent={() => <span>불러오는 중...</span>}
|
||||
noRowsOverlayComponent={() => <span>데이터가 없습니다</span>}
|
||||
loadingOverlayComponent={() => (
|
||||
<div style={{ padding: 24, color: GRAY[500], fontSize: 13 }}>불러오는 중...</div>
|
||||
)}
|
||||
noRowsOverlayComponent={() => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 40, color: GRAY[400] }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>📭</div>
|
||||
<div style={{ fontSize: 13 }}>데이터가 없습니다</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
import { Button, Result } from 'antd';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] 페이지 오류:', error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="페이지 오류"
|
||||
subTitle={this.state.error?.message ?? '알 수 없는 오류가 발생했습니다.'}
|
||||
extra={
|
||||
<Button type="primary" onClick={this.handleRetry}>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PageContainer as ProPageContainer } from '@ant-design/pro-components';
|
||||
import { Typography } from 'antd';
|
||||
import { GRAY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -9,22 +13,33 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design Pro 의 PageContainer 래퍼.
|
||||
* - 자동 Breadcrumb / Title / Tabs / Footer 슬롯 제공
|
||||
* - extra 는 우측 상단 액션 영역
|
||||
* 모든 페이지 공통 래퍼.
|
||||
* - 타이틀 + 설명 + 우측 액션 버튼 슬롯
|
||||
* - breadcrumb 자동 표시 (ProPageContainer 기본)
|
||||
* - 배경 / 간격 일관성
|
||||
*/
|
||||
export default function PageContainer({ title, description, extra, children }: Props) {
|
||||
return (
|
||||
<ProPageContainer
|
||||
header={{
|
||||
title,
|
||||
title: (
|
||||
<span style={{ fontSize: 24, fontWeight: 700, color: GRAY[800], letterSpacing: -0.5 }}>
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
breadcrumb: {},
|
||||
ghost: false,
|
||||
ghost: true,
|
||||
extra,
|
||||
style: {
|
||||
paddingBottom: description ? 8 : 0,
|
||||
},
|
||||
}}
|
||||
content={
|
||||
description ? <span style={{ color: '#666', fontSize: 13 }}>{description}</span> : undefined
|
||||
description ? (
|
||||
<Text style={{ fontSize: 14, color: GRAY[500] }}>{description}</Text>
|
||||
) : undefined
|
||||
}
|
||||
style={{ minHeight: 'calc(100vh - 56px)' }}
|
||||
>
|
||||
{children}
|
||||
</ProPageContainer>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Button, Col, DatePicker, Form, Input, Row, Space } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Button, Col, DatePicker, Form, Input, Row, Space, Typography } from 'antd';
|
||||
import { IconChevronDown, IconChevronUp, IconSearch, IconX } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from './CodeSelect';
|
||||
import { GRAY, COLOR_PRIMARY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export interface SearchCondition {
|
||||
type: 'text' | 'code' | 'dateRange' | 'month';
|
||||
name: string;
|
||||
label: string;
|
||||
groupCode?: string; // type=code 일 때
|
||||
groupCode?: string; // type=code 일 때
|
||||
placeholder?: string;
|
||||
span?: number; // 24그리드 기준 너비 (default 6)
|
||||
span?: number; // 24그리드 기준 너비 (default 6)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -18,14 +22,25 @@ interface Props {
|
||||
initialValues?: Record<string, unknown>;
|
||||
onSearch: (values: Record<string, unknown>) => void;
|
||||
onReset?: () => void;
|
||||
/** 초기에 접힘 상태로 시작할지 (default: false = 펼침) */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 배열만 넘기면 자동 렌더링되는 검색 폼.
|
||||
* dateRange → startDate/endDate 로 분해, month → settleMonth(yyyyMM)
|
||||
* - 펼침/접힘 토글 지원
|
||||
* - dateRange → startDate/endDate 분해
|
||||
* - month → settleMonth (yyyyMM)
|
||||
*/
|
||||
export default function SearchForm({ conditions, initialValues, onSearch, onReset }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
export default function SearchForm({
|
||||
conditions,
|
||||
initialValues,
|
||||
onSearch,
|
||||
onReset,
|
||||
defaultCollapsed = false,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
|
||||
const handleSearch = (raw: Record<string, unknown>) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -50,35 +65,95 @@ export default function SearchForm({ conditions, initialValues, onSearch, onRese
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSearch}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #FAFBFC 0%, #F5F7FA 100%)',
|
||||
padding: '14px 16px 6px',
|
||||
background: '#ffffff',
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04)',
|
||||
marginBottom: 16,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #EEF1F5',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
{conditions.map((c) => (
|
||||
<Col span={c.span ?? 6} key={c.name}>
|
||||
<Form.Item name={c.name} label={c.label}>
|
||||
{renderField(c)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 24 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">검색</Button>
|
||||
<Button onClick={handleReset}>초기화</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
{/* 헤더 — 클릭해서 펼침/접힘 */}
|
||||
<div
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: GRAY[50],
|
||||
borderBottom: collapsed ? 'none' : `1px solid ${GRAY[100]}`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Space size={6} align="center">
|
||||
<IconSearch size={14} style={{ color: COLOR_PRIMARY }} />
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, color: GRAY[700] }}>검색 조건</Text>
|
||||
{collapsed && (
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>
|
||||
(클릭하여 펼치기)
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
{collapsed
|
||||
? <IconChevronDown size={16} style={{ color: GRAY[400] }} />
|
||||
: <IconChevronUp size={16} style={{ color: GRAY[400] }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* 폼 본체 */}
|
||||
{!collapsed && (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSearch}
|
||||
style={{ padding: '14px 16px 4px' }}
|
||||
>
|
||||
<Row gutter={[16, 0]}>
|
||||
{conditions.map((c) => (
|
||||
<Col span={c.span ?? 6} key={c.name}>
|
||||
<Form.Item
|
||||
name={c.name}
|
||||
label={
|
||||
<Text style={{ fontSize: 12, fontWeight: 500, color: GRAY[600] }}>
|
||||
{c.label}
|
||||
</Text>
|
||||
}
|
||||
style={{ marginBottom: 14 }}
|
||||
>
|
||||
{renderField(c)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{/* 버튼 컬럼 */}
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 14 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<IconSearch size={14} />}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
icon={<IconX size={14} />}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+327
-11
@@ -1,24 +1,340 @@
|
||||
@import './styles/variables.css';
|
||||
|
||||
html, body, #root { height: 100%; margin: 0; padding: 0; }
|
||||
/* ────────────────────────────────────────────────
|
||||
기본 리셋
|
||||
──────────────────────────────────────────────── */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo",
|
||||
"Malgun Gothic", "맑은 고딕", system-ui, sans-serif;
|
||||
background: #f5f5f5;
|
||||
font-size: 13px;
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont,
|
||||
'Apple SD Gothic Neo', 'Malgun Gothic', '맑은 고딕',
|
||||
system-ui, sans-serif;
|
||||
font-size: 15px;
|
||||
background: var(--ga-gray-50);
|
||||
color: var(--ga-gray-800);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 숫자 셀에 등폭 숫자 (정렬 일관) */
|
||||
.ga-tabular { font-variant-numeric: tabular-nums; }
|
||||
/* ────────────────────────────────────────────────
|
||||
숫자 등폭 — 금액/날짜 컬럼 정렬
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
/* 레이아웃 콘테이너 */
|
||||
.ga-page-container { max-width: var(--ga-page-max-width); margin: 0 auto; }
|
||||
/* 금액 표시 공통 */
|
||||
.ga-amount {
|
||||
font-feature-settings: 'tnum';
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
페이지 컨테이너 최대 폭
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-page-container {
|
||||
max-width: var(--ga-page-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
사이드바 — 토스 활성 메뉴 인디케이터
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-menu-item-selected {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 60%;
|
||||
width: 4px;
|
||||
background: var(--ga-primary);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Ant Design Table — 토스 스타일
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-table-thead > tr > th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--ga-gray-50) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--ga-gray-600);
|
||||
border-bottom: 1px solid var(--ga-gray-200) !important;
|
||||
}
|
||||
|
||||
/* zebra 줄무늬 제거 — 토스는 hover 만 */
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 56px;
|
||||
font-size: 14px;
|
||||
color: var(--ga-gray-800);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--ga-gray-50) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Ant Design Pagination — 토스풍
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-table-pagination.ant-pagination {
|
||||
justify-content: flex-end !important;
|
||||
margin-top: 16px !important;
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
border-radius: 8px !important;
|
||||
border-color: var(--ga-gray-200) !important;
|
||||
}
|
||||
|
||||
.ant-pagination-item-active {
|
||||
background: var(--ga-primary) !important;
|
||||
border-color: var(--ga-primary) !important;
|
||||
}
|
||||
|
||||
.ant-pagination-item-active a {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.ant-pagination-prev .ant-pagination-item-link,
|
||||
.ant-pagination-next .ant-pagination-item-link {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
AG Grid — quartz 테마 토스 커스터마이징
|
||||
──────────────────────────────────────────────── */
|
||||
.ag-theme-quartz {
|
||||
--ag-font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
|
||||
--ag-font-size: 14px;
|
||||
--ag-header-height: 44px;
|
||||
--ag-row-height: 56px;
|
||||
--ag-header-background-color: var(--ga-gray-50);
|
||||
--ag-header-foreground-color: var(--ga-gray-600);
|
||||
--ag-header-column-separator-color: var(--ga-gray-200);
|
||||
--ag-odd-row-background-color: #ffffff;
|
||||
--ag-even-row-background-color: #ffffff;
|
||||
--ag-row-hover-color: var(--ga-gray-50);
|
||||
--ag-selected-row-background-color: var(--ga-primary-light);
|
||||
--ag-border-color: var(--ga-gray-200);
|
||||
--ag-cell-horizontal-padding: 16px;
|
||||
--ag-borders: solid 1px;
|
||||
--ag-border-radius: 0;
|
||||
}
|
||||
|
||||
/* AG Grid 합계행 강조 */
|
||||
.ag-theme-quartz .ag-row-pinned {
|
||||
background: #fafafa !important;
|
||||
font-weight: 500;
|
||||
background: var(--ga-primary-light) !important;
|
||||
font-weight: 700;
|
||||
color: var(--ga-gray-800);
|
||||
}
|
||||
|
||||
/* AG Grid 헤더 폰트 통일 */
|
||||
.ag-theme-quartz .ag-header-cell-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Ant Design Card — 토스 카드 스타일
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-card {
|
||||
box-shadow: var(--ga-shadow-sm) !important;
|
||||
border: 1px solid var(--ga-gray-100) !important;
|
||||
border-radius: 16px !important;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom-color: var(--ga-gray-100) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
검색 폼 영역 — filled 톤
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-pro-table-search {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid var(--ga-gray-100) !important;
|
||||
border-radius: var(--ga-radius-lg) !important;
|
||||
box-shadow: var(--ga-shadow-sm) !important;
|
||||
margin-bottom: var(--ga-space-4) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
입력창 — filled 배경 (토스 스타일 옵션)
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-input-filled .ant-input,
|
||||
.ga-input-filled .ant-input-affix-wrapper,
|
||||
.ga-input-filled .ant-select-selector,
|
||||
.ga-input-filled .ant-picker {
|
||||
background: var(--ga-gray-100) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.ga-input-filled .ant-input:focus,
|
||||
.ga-input-filled .ant-input-affix-wrapper-focused,
|
||||
.ga-input-filled .ant-select-focused .ant-select-selector,
|
||||
.ga-input-filled .ant-picker-focused {
|
||||
background: #ffffff !important;
|
||||
border-color: var(--ga-primary) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
버튼 — 토스 느낌 (hover 어두워짐)
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-btn {
|
||||
transition: all 0.2s ease !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
filter: brightness(0.93) !important;
|
||||
}
|
||||
|
||||
.ant-btn:not(.ant-btn-primary):hover {
|
||||
border-color: var(--ga-gray-300) !important;
|
||||
color: var(--ga-gray-800) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
모달 — 큰 라운드
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-modal-content {
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Drawer
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-drawer-content-wrapper {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.ant-drawer-content {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Toast 메시지 위치 통일
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-message {
|
||||
top: 16px !important;
|
||||
right: 16px !important;
|
||||
left: auto !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
폼 라벨
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-form-item-label > label {
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--ga-gray-600) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
태그/칩 — 토스 둥글둥글
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-tag {
|
||||
border-radius: 999px !important;
|
||||
font-weight: 500;
|
||||
border: none !important;
|
||||
padding: 2px 10px !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
Skeleton 로딩 공통
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-skeleton-card {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-radius: var(--ga-radius-lg);
|
||||
border: 1px solid var(--ga-gray-100);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
빈 상태 (EmptyState)
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--ga-gray-400);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
KPI 카드 트렌드 화살표
|
||||
──────────────────────────────────────────────── */
|
||||
.ga-kpi-trend-up { color: var(--ga-success); }
|
||||
.ga-kpi-trend-down { color: var(--ga-danger); }
|
||||
.ga-kpi-trend-flat { color: var(--ga-gray-500); }
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
링크 색상
|
||||
──────────────────────────────────────────────── */
|
||||
a {
|
||||
color: var(--ga-primary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--ga-primary-hover);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
페이지 헤더 (ProPageContainer)
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-pro-page-container-warp-page-header {
|
||||
background: var(--ga-gray-50) !important;
|
||||
border-bottom: 1px solid var(--ga-gray-100) !important;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
스크롤바
|
||||
──────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--ga-gray-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ga-gray-300);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ga-gray-400);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────
|
||||
전환 효과 통일
|
||||
──────────────────────────────────────────────── */
|
||||
.ant-modal,
|
||||
.ant-drawer {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProLayout, type MenuDataItem } from '@ant-design/pro-components';
|
||||
import { Badge, Button, DatePicker, Dropdown, Space } from 'antd';
|
||||
import { Avatar, Button, DatePicker, Dropdown, Space, Typography } from 'antd';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { IconBell, IconLogout, IconUser } from '@tabler/icons-react';
|
||||
import { IconChevronDown, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { menuApi, type MenuResp } from '@/api/menu';
|
||||
import { authApi } from '@/api/auth';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { getMenuIcon } from '@/components/common/MenuIcon';
|
||||
import ErrorBoundary from '@/components/common/ErrorBoundary';
|
||||
import NotificationBell from '@/components/biz/NotificationBell';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_LIGHT, GRAY, LAYOUT,
|
||||
} from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const COLLAPSE_KEY = 'ga-sider-collapsed';
|
||||
|
||||
/** API 메뉴 트리 → ProLayout MenuDataItem */
|
||||
function toProMenu(menus: MenuResp[]): MenuDataItem[] {
|
||||
return menus.map((m) => {
|
||||
const item: MenuDataItem = {
|
||||
name: m.menuName,
|
||||
path: m.menuPath ?? `/_grp/${m.menuCode}`,
|
||||
key: m.menuCode,
|
||||
icon: getMenuIcon(m.menuCode, m.menuPath),
|
||||
name: m.menuName,
|
||||
path: m.menuPath ?? `/_grp/${m.menuCode}`,
|
||||
key: m.menuCode,
|
||||
icon: getMenuIcon(m.menuCode, m.menuPath),
|
||||
};
|
||||
if (m.children?.length) item.children = toProMenu(m.children);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/** 로컬스토리지에서 collapse 상태 읽기 */
|
||||
function readCollapsed(): boolean {
|
||||
try { return localStorage.getItem(COLLAPSE_KEY) === 'true'; } catch { return false; }
|
||||
}
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const auth = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const auth = useAuthStore();
|
||||
const [settleMonth, setSettleMonth] = useState(dayjs());
|
||||
const [collapsed, setCollapsed] = useState(readCollapsed);
|
||||
|
||||
// collapse 상태 localStorage 저장
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(COLLAPSE_KEY, String(collapsed)); } catch { /* ignore */ }
|
||||
}, [collapsed]);
|
||||
|
||||
const { data: menus = [] } = useQuery({
|
||||
queryKey: ['menu', 'my'],
|
||||
queryFn: menuApi.myMenus,
|
||||
queryFn: menuApi.myMenus,
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -43,21 +63,62 @@ export default function MainLayout() {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const displayName = auth.userName ?? auth.loginId ?? '관리자';
|
||||
const roleLabel = auth.roles?.[0] ?? 'USER';
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '내 프로필',
|
||||
icon: <IconUser size={14} />,
|
||||
onClick: () => navigate('/system/users'),
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: '시스템 설정',
|
||||
icon: <IconSettings size={14} />,
|
||||
onClick: () => navigate('/system/config'),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: '로그아웃',
|
||||
icon: <IconLogout size={14} />,
|
||||
danger: true,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProLayout
|
||||
title="GA 정산시스템"
|
||||
logo="/vite.svg"
|
||||
logo={
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: COLOR_PRIMARY,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontWeight: 700, fontSize: 14, letterSpacing: -0.5,
|
||||
}}>
|
||||
GA
|
||||
</div>
|
||||
}
|
||||
layout="mix"
|
||||
navTheme="light"
|
||||
contentWidth="Fluid"
|
||||
fixedHeader
|
||||
fixSiderbar
|
||||
siderWidth={220}
|
||||
siderWidth={LAYOUT.siderWidth}
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
menu={{ locale: false }}
|
||||
menuDataRender={() => toProMenu(menus)}
|
||||
location={{ pathname: location.pathname }}
|
||||
menuItemRender={(item, dom) => (
|
||||
<div onClick={() => item.path && !item.path.startsWith('/_grp/') && navigate(item.path)}>
|
||||
<div
|
||||
onClick={() =>
|
||||
item.path && !item.path.startsWith('/_grp/') && navigate(item.path)
|
||||
}
|
||||
>
|
||||
{dom}
|
||||
</div>
|
||||
)}
|
||||
@@ -65,21 +126,27 @@ export default function MainLayout() {
|
||||
onMenuHeaderClick={() => navigate('/')}
|
||||
token={{
|
||||
sider: {
|
||||
colorMenuBackground: '#ffffff',
|
||||
colorTextMenuSelected: '#185FA5',
|
||||
colorBgMenuItemSelected: '#E6F1FB',
|
||||
colorTextMenuItemHover: '#1677FF',
|
||||
colorMenuBackground: '#ffffff',
|
||||
colorTextMenuSelected: COLOR_PRIMARY,
|
||||
colorBgMenuItemSelected: COLOR_PRIMARY_LIGHT,
|
||||
colorTextMenuItemHover: GRAY[800],
|
||||
colorTextMenu: GRAY[600],
|
||||
colorTextMenuSecondary: GRAY[500],
|
||||
colorTextSubMenuSelected: COLOR_PRIMARY,
|
||||
},
|
||||
header: {
|
||||
colorBgHeader: '#ffffff',
|
||||
colorHeaderTitle: '#185FA5',
|
||||
heightLayoutHeader: 56,
|
||||
colorBgHeader: '#ffffff',
|
||||
colorHeaderTitle: GRAY[800],
|
||||
heightLayoutHeader: LAYOUT.headerHeight,
|
||||
colorBgRightActionsItemHover: GRAY[100],
|
||||
},
|
||||
bgLayout: '#f5f7fa',
|
||||
bgLayout: GRAY[50],
|
||||
colorTextAppListIconHover: COLOR_PRIMARY,
|
||||
}}
|
||||
actionsRender={() => [
|
||||
<Space key="month" size={4} align="center">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>정산월</span>
|
||||
// 정산월 선택
|
||||
<Space key="month" size={6} align="center" style={{ marginRight: 4 }}>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500], whiteSpace: 'nowrap' }}>정산월</Text>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={settleMonth}
|
||||
@@ -87,36 +154,66 @@ export default function MainLayout() {
|
||||
allowClear={false}
|
||||
format="YYYY-MM"
|
||||
size="small"
|
||||
style={{ width: 100 }}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
</Space>,
|
||||
<Badge key="bell" count={3} size="small" offset={[-2, 4]}>
|
||||
<Button type="text" icon={<IconBell size={18} />} />
|
||||
</Badge>,
|
||||
|
||||
// 알림 벨 (NotificationBell — unread-count API 연동)
|
||||
<NotificationBell key="bell" />,
|
||||
]}
|
||||
avatarProps={{
|
||||
size: 'small',
|
||||
title: auth.userName ?? auth.loginId ?? '관리자',
|
||||
icon: <IconUser size={14} />,
|
||||
size: 'small',
|
||||
style: {
|
||||
background: COLOR_PRIMARY,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
src: undefined,
|
||||
children: displayName.charAt(0),
|
||||
render: (_props, dom) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'logout', label: '로그아웃', icon: <IconLogout size={14} />, onClick: handleLogout },
|
||||
],
|
||||
}}
|
||||
menu={{ items: userMenuItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
{dom}
|
||||
<Space
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = GRAY[100];
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{dom}
|
||||
<div style={{ lineHeight: 1.2 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: GRAY[900] }}>{displayName}</div>
|
||||
<div style={{ fontSize: 11, color: GRAY[500] }}>{roleLabel}</div>
|
||||
</div>
|
||||
<IconChevronDown size={12} style={{ color: GRAY[400] }} />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
),
|
||||
}}
|
||||
footerRender={() => (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0', fontSize: 11, color: '#bbb' }}>
|
||||
© GA Commission System v1.0.0-alpha · trading_ai
|
||||
<div style={{
|
||||
textAlign: 'center', padding: '8px 0',
|
||||
fontSize: 11, color: GRAY[400],
|
||||
borderTop: `1px solid ${GRAY[200]}`,
|
||||
}}>
|
||||
GA Commission System v1.0.0-alpha
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</ProLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+140
-46
@@ -2,76 +2,170 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConfigProvider, ThemeConfig } from 'antd';
|
||||
import { ConfigProvider, type ThemeConfig, message } from 'antd';
|
||||
import koKR from 'antd/locale/ko_KR';
|
||||
import 'dayjs/locale/ko';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_PRIMARY_LIGHT,
|
||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_ERROR, COLOR_INFO,
|
||||
GRAY, FONT_FAMILY, FONT_SIZE, RADIUS, SPACING, LAYOUT,
|
||||
} from './theme/tokens';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// 토스트 메시지 전역 설정 (top-right, 최대 5개, 3초)
|
||||
message.config({ top: 16, maxCount: 5, duration: 3 });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
});
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: {
|
||||
// 브랜드
|
||||
colorPrimary: '#1677FF',
|
||||
colorSuccess: '#0F6E56',
|
||||
colorWarning: '#BA7517',
|
||||
colorError: '#E24B4A',
|
||||
colorInfo: '#1677FF',
|
||||
// ── 브랜드 — 토스 블루 ──
|
||||
colorPrimary: COLOR_PRIMARY,
|
||||
colorPrimaryBg: COLOR_PRIMARY_LIGHT,
|
||||
colorSuccess: COLOR_SUCCESS,
|
||||
colorWarning: COLOR_WARNING,
|
||||
colorError: COLOR_ERROR,
|
||||
colorInfo: COLOR_INFO,
|
||||
|
||||
// 폰트
|
||||
fontFamily: "'Pretendard', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
fontSize: 13,
|
||||
fontSizeHeading1: 22,
|
||||
fontSizeHeading2: 18,
|
||||
fontSizeHeading3: 16,
|
||||
// ── 배경 ──
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgLayout: GRAY[50],
|
||||
colorBgElevated: '#ffffff',
|
||||
colorBgSpotlight: GRAY[100],
|
||||
|
||||
// 라운딩
|
||||
borderRadius: 6,
|
||||
borderRadiusLG: 8,
|
||||
// ── 텍스트 — 토스 단계 ──
|
||||
colorText: GRAY[800],
|
||||
colorTextSecondary: GRAY[600],
|
||||
colorTextTertiary: GRAY[500],
|
||||
colorTextDisabled: GRAY[400],
|
||||
|
||||
// 간격
|
||||
padding: 16,
|
||||
paddingLG: 24,
|
||||
margin: 16,
|
||||
// ── 테두리 — 거의 보이지 않게 ──
|
||||
colorBorder: GRAY[200],
|
||||
colorBorderSecondary: GRAY[100],
|
||||
|
||||
// 배경
|
||||
colorBgContainer: '#ffffff',
|
||||
colorBgLayout: '#f5f5f5',
|
||||
colorBgElevated: '#ffffff',
|
||||
// ── 폰트 ──
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: FONT_SIZE.base, // 15px
|
||||
fontSizeHeading1: FONT_SIZE['2xl'], // 32px
|
||||
fontSizeHeading2: FONT_SIZE.xl, // 24px
|
||||
fontSizeHeading3: FONT_SIZE.lg, // 20px
|
||||
fontSizeHeading4: FONT_SIZE.md, // 16px
|
||||
fontSizeHeading5: FONT_SIZE.base, // 15px
|
||||
|
||||
// ── Border Radius — 토스 비율 ──
|
||||
borderRadius: RADIUS.md, // 12 (버튼, 입력)
|
||||
borderRadiusLG: RADIUS.lg, // 16 (카드)
|
||||
borderRadiusSM: RADIUS.sm, // 8
|
||||
borderRadiusXS: 4,
|
||||
|
||||
// ── 공간 ──
|
||||
padding: SPACING[4],
|
||||
paddingLG: SPACING[6],
|
||||
paddingSM: SPACING[2],
|
||||
paddingXS: SPACING[1],
|
||||
margin: SPACING[4],
|
||||
marginLG: SPACING[6],
|
||||
marginSM: SPACING[2],
|
||||
|
||||
// ── 컨트롤 높이 — 토스는 큼직하게 ──
|
||||
controlHeight: 40, // 기본 입력/버튼
|
||||
controlHeightSM: 32,
|
||||
controlHeightLG: 48, // large 입력/버튼
|
||||
|
||||
// ── 그림자 — 옅은 합성 ──
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04)',
|
||||
boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06)',
|
||||
},
|
||||
|
||||
components: {
|
||||
// ── 메뉴 ──
|
||||
Menu: {
|
||||
itemHeight: 40,
|
||||
itemMarginInline: 8,
|
||||
itemBorderRadius: 6,
|
||||
subMenuItemBg: 'transparent',
|
||||
itemSelectedBg: '#e6f1fb',
|
||||
itemSelectedColor: '#185fa5',
|
||||
itemHeight: 44,
|
||||
itemMarginInline: 8,
|
||||
itemBorderRadius: RADIUS.sm,
|
||||
subMenuItemBg: 'transparent',
|
||||
itemSelectedBg: COLOR_PRIMARY_LIGHT,
|
||||
itemSelectedColor: COLOR_PRIMARY,
|
||||
itemHoverBg: GRAY[50],
|
||||
itemHoverColor: GRAY[800],
|
||||
itemColor: GRAY[600],
|
||||
},
|
||||
|
||||
// ── 테이블 ──
|
||||
Table: {
|
||||
headerBg: '#fafafa',
|
||||
rowHoverBg: '#f5f7fa',
|
||||
cellPaddingBlock: 10,
|
||||
cellPaddingInline: 12,
|
||||
headerSplitColor: '#f0f0f0',
|
||||
headerBg: GRAY[50],
|
||||
rowHoverBg: GRAY[50],
|
||||
cellPaddingBlock: 14,
|
||||
cellPaddingInline: 16,
|
||||
headerSplitColor: GRAY[200],
|
||||
},
|
||||
|
||||
// ── 버튼 — 토스 CTA 스타일 ──
|
||||
Button: {
|
||||
controlHeight: 32,
|
||||
controlHeightSM: 28,
|
||||
paddingInline: 16,
|
||||
paddingInline: 20,
|
||||
fontWeight: 600,
|
||||
borderRadius: RADIUS.md,
|
||||
primaryShadow: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
},
|
||||
Input: { controlHeight: 32, paddingInline: 10 },
|
||||
Select: { controlHeight: 32 },
|
||||
Card: { paddingLG: 16 },
|
||||
|
||||
// ── 입력 — 토스 filled 느낌 ──
|
||||
Input: {
|
||||
paddingInline: 12,
|
||||
paddingBlock: 12,
|
||||
borderRadius: RADIUS.md,
|
||||
hoverBorderColor: COLOR_PRIMARY,
|
||||
},
|
||||
|
||||
// ── 카드 ──
|
||||
Card: {
|
||||
bodyPadding: SPACING[6], // 24px
|
||||
headerBg: '#ffffff',
|
||||
borderRadius: RADIUS.lg, // 16px
|
||||
},
|
||||
|
||||
// ── 레이아웃 ──
|
||||
Layout: {
|
||||
headerHeight: 48,
|
||||
headerBg: '#ffffff',
|
||||
headerPadding: '0 16px',
|
||||
siderBg: '#ffffff',
|
||||
bodyBg: '#f5f5f5',
|
||||
headerHeight: LAYOUT.headerHeight,
|
||||
headerBg: '#ffffff',
|
||||
siderBg: '#ffffff',
|
||||
bodyBg: GRAY[50],
|
||||
},
|
||||
|
||||
// ── 폼 ──
|
||||
Form: {
|
||||
itemMarginBottom: 20,
|
||||
labelFontSize: 13,
|
||||
labelColor: GRAY[600],
|
||||
},
|
||||
|
||||
// ── 배지 ──
|
||||
Badge: {
|
||||
textFontSize: 11,
|
||||
},
|
||||
|
||||
// ── 태그/칩 ──
|
||||
Tag: {
|
||||
borderRadius: RADIUS.full,
|
||||
},
|
||||
|
||||
// ── Select ──
|
||||
Select: {
|
||||
borderRadius: RADIUS.md,
|
||||
},
|
||||
|
||||
// ── DatePicker ──
|
||||
DatePicker: {
|
||||
borderRadius: RADIUS.md,
|
||||
},
|
||||
|
||||
// ── 모달 ──
|
||||
Modal: {
|
||||
borderRadius: RADIUS.xl, // 20px
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+414
-120
@@ -1,24 +1,92 @@
|
||||
import { ProCard, StatisticCard } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
import { Skeleton, Tag, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Legend,
|
||||
Area, AreaChart,
|
||||
} from 'recharts';
|
||||
import {
|
||||
IconCash, IconCheck, IconClock, IconAlertTriangle, IconTrendingUp,
|
||||
IconCash, IconUsers, IconFileCheck, IconAlertTriangle, IconTrendingUp,
|
||||
IconTrendingDown, IconMinus, IconClock, IconArrowUpRight,
|
||||
} from '@tabler/icons-react';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING, COLOR_ERROR,
|
||||
COLOR_PRIMARY_LIGHT, GRAY, SHADOW,
|
||||
} from '@/theme/tokens';
|
||||
|
||||
const { Statistic } = StatisticCard;
|
||||
const { Text } = Typography;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Mock 데이터
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
const KPI = [
|
||||
{ title: '모집수수료', value: 124_500_000, suffix: '원', precision: 0,
|
||||
color: '#1677FF', bg: '#E6F1FB', icon: <IconCash size={20} />, delta: '+8.2%' },
|
||||
{ title: '유지수수료', value: 82_300_000, suffix: '원', precision: 0,
|
||||
color: '#0F6E56', bg: '#E1F5EE', icon: <IconCheck size={20} />, delta: '+3.1%' },
|
||||
{ title: '확정 대기', value: 12, suffix: '건', precision: 0,
|
||||
color: '#BA7517', bg: '#FAEEDA', icon: <IconClock size={20} />, delta: '-2건' },
|
||||
{ title: '대사 차이', value: 3, suffix: '건', precision: 0,
|
||||
color: '#E24B4A', bg: '#FCEBEB', icon: <IconAlertTriangle size={20} />, delta: '+1건' },
|
||||
{
|
||||
title: '모집수수료',
|
||||
value: 124_500_000,
|
||||
suffix: '원',
|
||||
delta: 8.2,
|
||||
prevText: '전월比',
|
||||
color: COLOR_PRIMARY,
|
||||
bg: COLOR_PRIMARY_LIGHT,
|
||||
icon: <IconCash size={20} />,
|
||||
format: (v: number) => `${(v / 1_000_000).toFixed(1)}백만`,
|
||||
},
|
||||
{
|
||||
title: '활성 설계사',
|
||||
value: 342,
|
||||
suffix: '명',
|
||||
delta: 12,
|
||||
prevText: '전월比',
|
||||
color: COLOR_SUCCESS,
|
||||
bg: '#E8FAF3',
|
||||
icon: <IconUsers size={20} />,
|
||||
format: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '신계약',
|
||||
value: 87,
|
||||
suffix: '건',
|
||||
delta: -3.1,
|
||||
prevText: '전월比',
|
||||
color: COLOR_WARNING,
|
||||
bg: '#FFF6E6',
|
||||
icon: <IconFileCheck size={20} />,
|
||||
format: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '대사 차이',
|
||||
value: 3,
|
||||
suffix: '건',
|
||||
delta: 1,
|
||||
prevText: '전월比',
|
||||
color: COLOR_ERROR,
|
||||
bg: '#FEF0F1',
|
||||
icon: <IconAlertTriangle size={20} />,
|
||||
format: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '확정 대기',
|
||||
value: 12,
|
||||
suffix: '건',
|
||||
delta: -2,
|
||||
prevText: '전월比',
|
||||
color: '#7C3AED',
|
||||
bg: '#F3EEFF',
|
||||
icon: <IconClock size={20} />,
|
||||
format: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '유지수수료',
|
||||
value: 82_300_000,
|
||||
suffix: '원',
|
||||
delta: 3.1,
|
||||
prevText: '전월比',
|
||||
color: '#0891B2',
|
||||
bg: '#E0F7FA',
|
||||
icon: <IconArrowUpRight size={20} />,
|
||||
format: (v: number) => `${(v / 1_000_000).toFixed(1)}백만`,
|
||||
},
|
||||
];
|
||||
|
||||
const MONTHLY = [
|
||||
@@ -30,144 +98,370 @@ const MONTHLY = [
|
||||
{ month: '5월', recruit: 125, maintain: 82, chargeback: 4 },
|
||||
];
|
||||
|
||||
const TREND = [
|
||||
{ month: '12월', net: 160 },
|
||||
{ month: '1월', net: 169 },
|
||||
{ month: '2월', net: 179 },
|
||||
{ month: '3월', net: 189 },
|
||||
{ month: '4월', net: 193 },
|
||||
{ month: '5월', net: 203 },
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ name: '데이터 수신', status: 'done', time: '02:14' },
|
||||
{ name: '데이터 변환', status: 'done', time: '00:52' },
|
||||
{ name: '대사 검증', status: 'done', time: '00:21' },
|
||||
{ name: '모집수수료', status: 'done', time: '01:08' },
|
||||
{ name: '유지수수료', status: 'done', time: '00:46' },
|
||||
{ name: '환수/예외', status: 'progress', time: '00:33' },
|
||||
{ name: '데이터 수신', status: 'done', time: '02:14' },
|
||||
{ name: '데이터 변환', status: 'done', time: '00:52' },
|
||||
{ name: '대사 검증', status: 'done', time: '00:21' },
|
||||
{ name: '모집수수료', status: 'done', time: '01:08' },
|
||||
{ name: '유지수수료', status: 'done', time: '00:46' },
|
||||
{ name: '환수/예외', status: 'progress', time: '진행 중' },
|
||||
{ name: '오버라이드', status: 'pending' },
|
||||
{ name: '집계', status: 'pending' },
|
||||
];
|
||||
|
||||
const ALERTS = [
|
||||
{
|
||||
tag: '대사불일치', color: 'error' as const,
|
||||
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이',
|
||||
time: '10분 전',
|
||||
},
|
||||
{
|
||||
tag: '파싱에러', color: 'warning' as const,
|
||||
text: '한화생명 raw 파일 12행 날짜 형식 오류 (log #4521)',
|
||||
time: '1시간 전',
|
||||
},
|
||||
{
|
||||
tag: '승인대기', color: 'processing' as const,
|
||||
text: '예외금액 원장 승인 대기 5건 (정산월 202605)',
|
||||
time: '3시간 전',
|
||||
},
|
||||
];
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 서브 컴포넌트
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
function TrendIndicator({ delta }: { delta: number }) {
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<span className="ga-kpi-trend-up" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600 }}>
|
||||
<IconTrendingUp size={12} />
|
||||
+{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (delta < 0) {
|
||||
return (
|
||||
<span className="ga-kpi-trend-down" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600 }}>
|
||||
<IconTrendingDown size={12} />
|
||||
{delta.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ga-kpi-trend-flat" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12 }}>
|
||||
<IconMinus size={12} />
|
||||
변동없음
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ k }: { k: typeof KPI[0] }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
padding: '24px 28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
height: '100%',
|
||||
transition: 'box-shadow 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* 상단: 아이콘 + 라벨 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>
|
||||
{k.title}
|
||||
</Text>
|
||||
<div style={{
|
||||
width: 36, height: 36,
|
||||
borderRadius: 10,
|
||||
background: k.bg,
|
||||
color: k.color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{k.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중간: 큰 숫자 */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 34,
|
||||
fontWeight: 700,
|
||||
color: GRAY[800],
|
||||
lineHeight: 1,
|
||||
fontFeatureSettings: "'tnum'",
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{k.format(k.value)}
|
||||
</span>
|
||||
<Text style={{ fontSize: 14, color: GRAY[500], fontWeight: 400 }}>
|
||||
{k.suffix}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 하단: 트렌드 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<TrendIndicator delta={k.delta} />
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>{k.prevText}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 메인 컴포넌트
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<PageContainer
|
||||
title="대시보드"
|
||||
description="2026-05 정산 요약 / 실시간 배치 진행 / 알림"
|
||||
description="2026-05 정산 요약 · 실시간 배치 진행 · 알림"
|
||||
>
|
||||
{/* KPI */}
|
||||
<ProCard
|
||||
gutter={16}
|
||||
ghost
|
||||
style={{ marginBottom: 16 }}
|
||||
wrap
|
||||
|
||||
{/* ── KPI 카드 6개 ── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 20,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{KPI.map((k) => (
|
||||
<ProCard
|
||||
key={k.title}
|
||||
colSpan={6}
|
||||
bordered={false}
|
||||
style={{ borderTop: `3px solid ${k.color}` }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: k.bg, color: k.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{k.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Statistic
|
||||
title={k.title}
|
||||
value={k.value}
|
||||
suffix={k.suffix}
|
||||
precision={k.precision}
|
||||
description={
|
||||
<Tag color={k.delta.startsWith('+') ? 'success' : 'error'} style={{ marginTop: 4 }}>
|
||||
<IconTrendingUp size={11} style={{ verticalAlign: -2 }} /> {k.delta}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProCard>
|
||||
<KpiCard key={k.title} k={k} />
|
||||
))}
|
||||
</ProCard>
|
||||
</div>
|
||||
|
||||
{/* 차트 + 배치 진행 */}
|
||||
<ProCard gutter={16} ghost wrap style={{ marginBottom: 16 }}>
|
||||
{/* ── 차트 2개 + 배치 진행 ── */}
|
||||
<ProCard gutter={[20, 20]} ghost wrap style={{ marginBottom: 20 }}>
|
||||
|
||||
{/* 월별 수수료 바 차트 */}
|
||||
<ProCard
|
||||
colSpan={16}
|
||||
title="월별 추이 (최근 6개월)"
|
||||
colSpan={{ xs: 24, md: 16 }}
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>월별 수수료 추이</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>최근 6개월 (백만원)</span>}
|
||||
headerBordered
|
||||
bordered={false}
|
||||
style={{ minHeight: 320 }}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={MONTHLY}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12, fill: '#666' }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: '#666' }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="recruit" name="모집" fill="#1677FF" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="maintain" name="유지" fill="#0F6E56" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="chargeback" name="환수" fill="#E24B4A" radius={[4, 4, 0, 0]} />
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={MONTHLY} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 12, border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.md, fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
|
||||
<Bar dataKey="recruit" name="모집" fill={COLOR_PRIMARY} radius={[6, 6, 0, 0]} />
|
||||
<Bar dataKey="maintain" name="유지" fill={COLOR_SUCCESS} radius={[6, 6, 0, 0]} />
|
||||
<Bar dataKey="chargeback" name="환수" fill={COLOR_ERROR} radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
|
||||
{/* 배치 진행 */}
|
||||
<ProCard
|
||||
colSpan={8}
|
||||
title="배치 진행 (5월)"
|
||||
colSpan={{ xs: 24, md: 8 }}
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>배치 진행</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>2026-05</span>}
|
||||
headerBordered
|
||||
bordered={false}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
{STEPS.map((s, i) => {
|
||||
const dot = s.status === 'done' ? '#0F6E56'
|
||||
: s.status === 'progress' ? '#1677FF' : '#ddd';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 0',
|
||||
borderBottom: i < STEPS.length - 1 ? '1px dashed #f5f5f5' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: dot,
|
||||
boxShadow: s.status === 'progress' ? '0 0 0 4px rgba(22,119,255,0.15)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: s.status === 'pending' ? '#bbb' : '#333' }}>
|
||||
Step {i + 1}. {s.name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const isDone = s.status === 'done';
|
||||
const isProgress = s.status === 'progress';
|
||||
const dotColor = isDone ? COLOR_SUCCESS : isProgress ? COLOR_PRIMARY : GRAY[300];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 0',
|
||||
borderBottom: i < STEPS.length - 1 ? `1px solid ${GRAY[100]}` : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 8, height: 8,
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
flexShrink: 0,
|
||||
boxShadow: isProgress
|
||||
? `0 0 0 3px ${COLOR_PRIMARY}22`
|
||||
: isDone ? `0 0 0 3px ${COLOR_SUCCESS}22` : 'none',
|
||||
}} />
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
color: s.status === 'pending' ? GRAY[400] : GRAY[700],
|
||||
fontWeight: isProgress ? 700 : 400,
|
||||
}}>
|
||||
{s.name}
|
||||
</Text>
|
||||
{isProgress && (
|
||||
<Tag color="blue" style={{ fontSize: 10, padding: '0 6px', margin: 0 }}>진행</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Text style={{ fontSize: 11, color: GRAY[400] }}>{s.time ?? '-'}</Text>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: '#999' }}>{s.time ?? '-'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 13, color: GRAY[500] }}>전체 진행률</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 700, color: COLOR_PRIMARY }}>62.5%</Text>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 6, background: GRAY[100], borderRadius: 999, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', width: '62.5%',
|
||||
background: COLOR_PRIMARY,
|
||||
borderRadius: 999,
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
|
||||
{/* 알림 */}
|
||||
<ProCard title="최근 알림" headerBordered bordered={false}>
|
||||
{[
|
||||
{ tag: '대사불일치', color: 'error',
|
||||
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이', time: '10분 전' },
|
||||
{ tag: '파싱에러', color: 'warning',
|
||||
text: '한화생명 raw 파일 12행 날짜 형식 오류 (parse_error_log #4521)', time: '1시간 전' },
|
||||
{ tag: '승인대기', color: 'processing',
|
||||
text: '예외금액 원장 승인 대기 5건 (정산월 202605)', time: '3시간 전' },
|
||||
].map((n, i, arr) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
|
||||
borderBottom: i < arr.length - 1 ? '1px solid #f5f5f5' : 'none',
|
||||
}}>
|
||||
<Tag color={n.color}>{n.tag}</Tag>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>{n.text}</span>
|
||||
<span style={{ fontSize: 11, color: '#999' }}>{n.time}</span>
|
||||
{/* ── 실지급 추이 + 알림 ── */}
|
||||
<ProCard gutter={[20, 20]} ghost wrap>
|
||||
|
||||
{/* 실지급 라인 차트 */}
|
||||
<ProCard
|
||||
colSpan={{ xs: 24, md: 12 }}
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>실지급 월별 추이</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>최근 6개월 누적 (백만원)</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={TREND} margin={{ top: 8, right: 8, left: -8, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="netGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={COLOR_PRIMARY} stopOpacity={0.12} />
|
||||
<stop offset="95%" stopColor={COLOR_PRIMARY} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: 12, border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.md, fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="net"
|
||||
name="실지급"
|
||||
stroke={COLOR_PRIMARY}
|
||||
strokeWidth={2}
|
||||
fill="url(#netGrad)"
|
||||
dot={{ fill: COLOR_PRIMARY, r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
|
||||
{/* 최근 알림 */}
|
||||
<ProCard
|
||||
colSpan={{ xs: 24, md: 12 }}
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>최근 알림</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
extra={
|
||||
<Text style={{ fontSize: 13, color: COLOR_PRIMARY, cursor: 'pointer', fontWeight: 500 }}>전체 보기</Text>
|
||||
}
|
||||
>
|
||||
{ALERTS.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
padding: '14px 0',
|
||||
borderBottom: i < ALERTS.length - 1 ? `1px solid ${GRAY[100]}` : 'none',
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
color={n.color}
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
>
|
||||
{n.tag}
|
||||
</Tag>
|
||||
<Text style={{ flex: 1, fontSize: 13, color: GRAY[700], lineHeight: 1.5 }}>
|
||||
{n.text}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: GRAY[400], whiteSpace: 'nowrap' }}>
|
||||
{n.time}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 12, display: 'none' }} className="ga-empty-state">
|
||||
<Text style={{ fontSize: 13, color: GRAY[400] }}>알림이 없습니다</Text>
|
||||
</div>
|
||||
))}
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
|
||||
{/* ── 스켈레톤 로딩 데모 (hidden) ── */}
|
||||
<div style={{ display: 'none' }}>
|
||||
<div className="ga-skeleton-card">
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { Button, Card, Form, Input, message } from 'antd';
|
||||
import { Button, Divider, Form, Input, Typography } from 'antd';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '@/api/auth';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { COLOR_PRIMARY, GRAY, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
|
||||
const onFinish = async (values: { loginId: string; password: string }) => {
|
||||
try {
|
||||
const resp = await authApi.login(values);
|
||||
localStorage.setItem('accessToken', resp.accessToken);
|
||||
localStorage.setItem('accessToken', resp.accessToken);
|
||||
if (resp.refreshToken) localStorage.setItem('refreshToken', resp.refreshToken);
|
||||
setAuth({
|
||||
userId: resp.userId, loginId: resp.loginId,
|
||||
userName: resp.userName, roles: resp.roles,
|
||||
userId: resp.userId,
|
||||
loginId: resp.loginId,
|
||||
userName: resp.userName,
|
||||
roles: resp.roles,
|
||||
});
|
||||
message.success(`${resp.userName}님 환영합니다`);
|
||||
navigate('/');
|
||||
} catch {
|
||||
// 인터셉터에서 메시지 처리
|
||||
@@ -24,19 +29,113 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||
minHeight: '100vh' }}>
|
||||
<Card title="GA 수수료 정산 솔루션" style={{ width: 400 }}>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label="아이디" name="loginId" rules={[{ required: true }]}>
|
||||
<Input autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item label="비밀번호" name="password" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>로그인</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: GRAY[50],
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 440,
|
||||
background: '#ffffff',
|
||||
borderRadius: 20,
|
||||
boxShadow: SHADOW.lg,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 상단 브랜드 헤더 */}
|
||||
<div
|
||||
style={{
|
||||
background: COLOR_PRIMARY,
|
||||
padding: '36px 40px 32px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 52, height: 52,
|
||||
borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.18)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
fontSize: 20,
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
GA
|
||||
</div>
|
||||
<Title level={3} style={{ color: '#ffffff', margin: 0, fontWeight: 700, fontSize: 22 }}>
|
||||
GA 정산시스템
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 6, display: 'block' }}>
|
||||
GA Commission Management System
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
<div style={{ padding: '36px 40px 40px' }}>
|
||||
<Form layout="vertical" onFinish={onFinish} autoComplete="off">
|
||||
<Form.Item
|
||||
label={<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[600] }}>아이디</Text>}
|
||||
name="loginId"
|
||||
rules={[{ required: true, message: '아이디를 입력하세요' }]}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: GRAY[400] }} />}
|
||||
placeholder="아이디 입력"
|
||||
size="large"
|
||||
autoFocus
|
||||
style={{ height: 52, borderRadius: 12, fontSize: 15 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[600] }}>비밀번호</Text>}
|
||||
name="password"
|
||||
rules={[{ required: true, message: '비밀번호를 입력하세요' }]}
|
||||
style={{ marginBottom: 28 }}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: GRAY[400] }} />}
|
||||
placeholder="비밀번호 입력"
|
||||
size="large"
|
||||
style={{ height: 52, borderRadius: 12, fontSize: 15 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
로그인
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '28px 0 20px', borderColor: GRAY[100] }} />
|
||||
|
||||
<Text style={{ fontSize: 13, color: GRAY[400], display: 'block', textAlign: 'center' }}>
|
||||
문의: 시스템 관리자 · 내선 1234
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import {
|
||||
agentContractApi,
|
||||
AgentContractRow,
|
||||
AgentContractSaveReq,
|
||||
AgentContractSearchParam,
|
||||
} from '@/api/agent-contract';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_WARNING_BG, COLOR_WARNING_BORDER } from '@/theme/tokens';
|
||||
|
||||
const COLUMNS: ColDef<AgentContractRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
|
||||
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
||||
{ field: 'contractType', headerName: '계약유형', width: 120 },
|
||||
{ field: 'effectiveFrom', headerName: '효력시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '효력종료', width: 120 },
|
||||
{ field: 'commissionRate', headerName: '위촉수수료율(%)', width: 140, type: 'numericColumn' },
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="AGENT_CONTRACT_STATUS" value={p.value} />,
|
||||
},
|
||||
{ field: 'terminatedAt', headerName: '해촉일', width: 120 },
|
||||
];
|
||||
|
||||
export default function AgentContracts() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<AgentContractSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [terminateId, setTerminateId] = useState<number | null>(null);
|
||||
const [terminateReason, setTerminateReason] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['agent-contracts', params],
|
||||
queryFn: () => agentContractApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as AgentContractSaveReq;
|
||||
try {
|
||||
await agentContractApi.create(values);
|
||||
message.success('위촉계약 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['agent-contracts'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!terminateId) return;
|
||||
try {
|
||||
await agentContractApi.terminate(terminateId, terminateReason);
|
||||
message.success('해촉 처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['agent-contracts'] });
|
||||
setTerminateId(null);
|
||||
setTerminateReason('');
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="위촉계약 관리"
|
||||
description="보험사-설계사 위촉계약 목록 및 해촉 처리"
|
||||
extra={
|
||||
<PermissionButton menuCode="AGENT_CONTRACTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 위촉계약 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-contracts 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_CONTRACT_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as AgentContractSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<AgentContractRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 180, pinned: 'right',
|
||||
cellRenderer: (p: { data: AgentContractRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="AGENT_CONTRACTS" permCode="APPROVE" size="small" danger
|
||||
disabled={p.data.status === 'TERMINATED'}
|
||||
onClick={() => { setTerminateId(p.data.contractId); setTerminateReason(''); }}
|
||||
>
|
||||
해촉
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="contractId"
|
||||
getRowStyle={(params) => {
|
||||
if (params.data?.status === 'TERMINATED') {
|
||||
return { background: COLOR_WARNING_BG, borderLeft: `3px solid ${COLOR_WARNING_BORDER}` };
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 등록 모달 */}
|
||||
<Modal title="위촉계약 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="carrierId" label="보험사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="contractType" label="계약유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="EXCLUSIVE / GENERAL" />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="효력시작일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveTo" label="효력종료일">
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="commissionRate" label="위촉수수료율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 해촉 모달 */}
|
||||
<Modal
|
||||
title="해촉 처리"
|
||||
open={terminateId !== null}
|
||||
onOk={handleTerminate}
|
||||
onCancel={() => setTerminateId(null)}
|
||||
okText="해촉 확정"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="해촉 사유" required>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={terminateReason}
|
||||
onChange={(e) => setTerminateReason(e.target.value)}
|
||||
placeholder="해촉 사유를 입력하세요"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef, RowClassParams } 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 { licenseApi, AgentLicenseRow, AgentLicenseSaveReq, AgentLicenseSearchParam } from '@/api/license';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_WARNING, COLOR_WARNING_BG, COLOR_WARNING_BORDER, COLOR_ERROR, COLOR_ERROR_BG } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const COLUMNS: ColDef<AgentLicenseRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
|
||||
{ field: 'licenseTypeName', headerName: '자격 유형', width: 120 },
|
||||
{ field: 'licenseNo', headerName: '자격증번호', width: 160 },
|
||||
{ field: 'issuedAt', headerName: '발급일', width: 120 },
|
||||
{ field: 'expiresAt', headerName: '만료일', width: 120 },
|
||||
{ field: 'renewedAt', headerName: '갱신일', width: 120 },
|
||||
{
|
||||
field: 'daysUntilExpiry', headerName: '만료까지', width: 110,
|
||||
cellRenderer: (p: { value?: number }) => {
|
||||
const d = p.value;
|
||||
if (d == null) return '-';
|
||||
const color = d <= 0 ? COLOR_ERROR : d <= 30 ? COLOR_WARNING : GRAY[600];
|
||||
return <span style={{ color, fontWeight: d != null && d <= 30 ? 700 : 400 }}>{d <= 0 ? '만료' : `D-${d}`}</span>;
|
||||
},
|
||||
},
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
export default function AgentLicenses() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<AgentLicenseSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['agent-licenses', params],
|
||||
queryFn: () => licenseApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: expiring = [] } = useQuery({
|
||||
queryKey: ['agent-licenses', 'expiring'],
|
||||
queryFn: () => licenseApi.expiring(30),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as AgentLicenseSaveReq;
|
||||
try {
|
||||
await licenseApi.create(values);
|
||||
message.success('자격증 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['agent-licenses'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const getRowStyle = (p: RowClassParams<AgentLicenseRow>): Record<string, string> | undefined => {
|
||||
const d = p.data?.daysUntilExpiry;
|
||||
if (d != null && d <= 0) return { background: COLOR_ERROR_BG };
|
||||
if (d != null && d <= 30) return { background: COLOR_WARNING_BG, borderLeft: `3px solid ${COLOR_WARNING_BORDER}` };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="자격증 관리"
|
||||
description="설계사 생보/손보/변액 자격증 관리"
|
||||
extra={
|
||||
<PermissionButton menuCode="AGENT_LICENSES" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 자격증 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-licenses 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{/* 만료 임박 헤더 카드 */}
|
||||
{expiring.length > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
background: COLOR_WARNING_BG,
|
||||
border: `1px solid ${COLOR_WARNING_BORDER}`,
|
||||
borderRadius: RADIUS.md,
|
||||
marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<Tag color="warning" style={{ fontWeight: 700, fontSize: 13 }}>
|
||||
30일 내 만료 {expiring.length}건
|
||||
</Tag>
|
||||
<Text style={{ fontSize: 13, color: GRAY[700] }}>
|
||||
{expiring.slice(0, 3).map((e) => `${e.agentName} (${e.licenseTypeName})`).join(', ')}
|
||||
{expiring.length > 3 ? ` 외 ${expiring.length - 3}건` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'licenseType', label: '자격 유형', groupCode: 'LICENSE_TYPE', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'LICENSE_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as AgentLicenseSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<AgentLicenseRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="licenseId"
|
||||
getRowStyle={getRowStyle}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal title="자격증 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="licenseType" label="자격 유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="LIFE / NONLIFE / VARIABLE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="licenseNo" label="자격증 번호" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="issuedAt" label="발급일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expiresAt" label="만료일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Typography, 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 {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
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 { trainingApi, AgentTrainingRow, AgentTrainingSaveReq, AgentTrainingSearchParam } from '@/api/training';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_SUCCESS, COLOR_ERROR } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const COLUMNS: ColDef<AgentTrainingRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
|
||||
{ field: 'trainingName', headerName: '교육명', flex: 1 },
|
||||
{ field: 'trainingType', headerName: '교육유형', width: 120 },
|
||||
{ field: 'completedHours', headerName: '이수시간(H)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'trainingDate', headerName: '교육일', width: 120 },
|
||||
{ field: 'provider', headerName: '교육기관', width: 140 },
|
||||
{ field: 'year', headerName: '연도', width: 90 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
const REQUIRED_HOURS = 12; // 의무 12시간/년
|
||||
|
||||
export default function AgentTrainings() {
|
||||
const qc = useQueryClient();
|
||||
const currentYear = dayjs().year();
|
||||
const [params, setParams] = useState<AgentTrainingSearchParam>({ year: currentYear, pageSize: 200 });
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
|
||||
const [selectedAgentName, setSelectedAgentName] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['agent-trainings', params],
|
||||
queryFn: () => trainingApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: annualHours } = useQuery({
|
||||
queryKey: ['agent-trainings', 'annual', selectedAgentId, currentYear],
|
||||
queryFn: () => trainingApi.annualHours(selectedAgentId!, currentYear),
|
||||
enabled: !!selectedAgentId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
// 설계사별 연간 합계 계산 (클라이언트사이드)
|
||||
const agentHoursMap: Record<number, number> = {};
|
||||
rows.forEach((r) => {
|
||||
agentHoursMap[r.agentId] = (agentHoursMap[r.agentId] ?? 0) + r.completedHours;
|
||||
});
|
||||
|
||||
const chartData = selectedAgentId && annualHours ? [
|
||||
{ label: '이수', hours: annualHours.completedHours },
|
||||
{ label: '의무', hours: annualHours.requiredHours },
|
||||
] : [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as AgentTrainingSaveReq;
|
||||
try {
|
||||
await trainingApi.create(values);
|
||||
message.success('교육 이수 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['agent-trainings'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const satisfactionRate = annualHours
|
||||
? Math.min(100, Math.round((annualHours.completedHours / REQUIRED_HOURS) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="보수교육 관리"
|
||||
description="설계사 의무교육 이수 시간 관리 (의무: 12시간/년)"
|
||||
extra={
|
||||
<PermissionButton menuCode="AGENT_TRAININGS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 교육 이수 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-trainings 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'text', name: 'year', label: '연도', span: 6 },
|
||||
{ type: 'code', name: 'trainingType', label: '교육유형', groupCode: 'TRAINING_TYPE', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as AgentTrainingSearchParam, pageSize: 200 })}
|
||||
onReset={() => setParams({ year: currentYear, pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<Row gutter={20} style={{ marginBottom: 16 }}>
|
||||
{/* 설계사 선택 → 연간 차트 */}
|
||||
<Col span={8}>
|
||||
<ProCard
|
||||
title={<Text style={{ fontSize: 14, fontWeight: 700 }}>설계사별 연간 이수 현황</Text>}
|
||||
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, height: 300 }}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="설계사 선택"
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
options={Array.from(new Set(rows.map((r) => r.agentId))).map((id) => {
|
||||
const r = rows.find((x) => x.agentId === id)!;
|
||||
return { value: id, label: r.agentName };
|
||||
})}
|
||||
onChange={(val, opt) => {
|
||||
setSelectedAgentId(val);
|
||||
const single = Array.isArray(opt) ? opt[0] : opt;
|
||||
setSelectedAgentName(single?.label ?? '');
|
||||
}}
|
||||
/>
|
||||
|
||||
{annualHours ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Progress
|
||||
percent={satisfactionRate}
|
||||
strokeColor={satisfactionRate >= 100 ? COLOR_SUCCESS : COLOR_ERROR}
|
||||
format={() => `${annualHours.completedHours}H / ${REQUIRED_HOURS}H`}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500] }}>
|
||||
{annualHours.satisfied ? '의무시간 충족' : `부족: ${REQUIRED_HOURS - annualHours.completedHours}시간`}
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text style={{ fontSize: 13, color: GRAY[400] }}>설계사를 선택하면 연간 현황이 표시됩니다</Text>
|
||||
)}
|
||||
</ProCard>
|
||||
</Col>
|
||||
|
||||
{/* 미충족 설계사 현황 */}
|
||||
<Col span={16}>
|
||||
<ProCard
|
||||
title={<Text style={{ fontSize: 14, fontWeight: 700 }}>이수 현황 요약 ({currentYear}년)</Text>}
|
||||
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, height: 300 }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={Object.entries(agentHoursMap).slice(0, 10).map(([id, hours]) => {
|
||||
const agent = rows.find((r) => r.agentId === Number(id));
|
||||
return { name: agent?.agentName ?? id, hours };
|
||||
})}
|
||||
margin={{ top: 8, right: 16, left: -8, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(v) => [`${v}시간`, '이수시간']} />
|
||||
<ReferenceLine y={REQUIRED_HOURS} stroke={COLOR_ERROR} strokeDasharray="4 2" label={{ value: '의무 12H', fontSize: 11, fill: COLOR_ERROR }} />
|
||||
<Bar dataKey="hours" fill={COLOR_PRIMARY} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<AgentTrainingRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={500}
|
||||
rowKey="trainingId"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal title="교육 이수 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="trainingName" label="교육명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="trainingType" label="교육유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="MANDATORY / OPTIONAL" />
|
||||
</Form.Item>
|
||||
<Form.Item name="completedHours" label="이수시간(H)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.5} />
|
||||
</Form.Item>
|
||||
<Form.Item name="trainingDate" label="교육일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="교육기관">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, 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 {
|
||||
commissionApi,
|
||||
CollectionRuleRow, CollectionLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const RULE_COLS: ColDef<CollectionRuleRow>[] = [
|
||||
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
||||
{ field: 'productType', headerName: '상품유형', width: 120 },
|
||||
{ field: 'paymentCycle', headerName: '납입주기', width: 100 },
|
||||
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<CollectionLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'collectionAmount', headerName: '수납금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'ratePercent', headerName: '수수료율(%)', width: 110, type: 'numericColumn' },
|
||||
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
export default function CollectionCommission() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<CollectionRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['collection', 'rules'],
|
||||
queryFn: () => commissionApi.collectionRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['collection', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.collectionLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: CollectionRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.collectionRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.collectionRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.collectionRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
collectionAmount: ledgers.reduce((s, r) => s + (r.collectionAmount ?? 0), 0),
|
||||
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
|
||||
} as Partial<CollectionLedgerRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="수금수수료" description="수금수수료 룰 관리 및 원장 조회">
|
||||
{rulesError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/collection-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '수수료 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="COLLECT_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<CollectionRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: CollectionRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="COLLECT_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="COLLECT_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={rulesLoading}
|
||||
height={520}
|
||||
rowKey="ruleId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ledgers',
|
||||
label: '수금 원장',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<CollectionLedgerRow>
|
||||
rows={ledgers}
|
||||
columns={LEDGER_COLS}
|
||||
loading={ledgersLoading}
|
||||
height={520}
|
||||
rowKey="ledgerId"
|
||||
pinnedBottomRow={pinnedLedger}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRule ? '룰 수정' : '룰 등록'}
|
||||
open={ruleModalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setRuleModalOpen(false)}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentCycle" label="납입주기">
|
||||
<Input placeholder="MONTHLY / ANNUAL" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveTo" label="적용종료">
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } 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 { commissionApi, CommissionMasterRow, CommissionSearchParam } from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const COLUMNS: ColDef<CommissionMasterRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
|
||||
{ field: 'commissionTypeName', headerName: '수수료 종류', width: 140 },
|
||||
{ field: 'grossAmount', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'netAmount', headerName: '실지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function CommissionMaster() {
|
||||
const [params, setParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 200,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['commission', 'master', params],
|
||||
queryFn: () => commissionApi.masterList(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
commissionTypeName: '',
|
||||
grossAmount: rows.reduce((s, r) => s + (r.grossAmount ?? 0), 0),
|
||||
netAmount: rows.reduce((s, r) => s + (r.netAmount ?? 0), 0),
|
||||
} as Partial<CommissionMasterRow> : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="수수료 통합 마스터"
|
||||
description="설계사+정산월 기준 9종 수수료 통합 조회"
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'code', name: 'commissionType', label: '수수료 종류', groupCode: 'COMMISSION_TYPE', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon
|
||||
message="API 미응답"
|
||||
description="/api/commissions 를 확인하세요."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<CommissionMasterRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="masterId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Radio, Space, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } 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 { commissionApi, OverrideLedgerRow, OverrideLedgerSearchParam } from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtPct = (v: unknown) => `${((v as number) ?? 0).toFixed(2)}%`;
|
||||
|
||||
const COLUMNS: ColDef<OverrideLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'fromAgentName', headerName: '원천 설계사(From)', width: 150 },
|
||||
{ field: 'toAgentName', headerName: '수령 설계사(To)', width: 150 },
|
||||
{ field: 'orgName', headerName: '조직', width: 130 },
|
||||
{ field: 'baseCommission', headerName: '기준 수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'overrideRate', headerName: '오버라이드율', width: 130, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
|
||||
{ field: 'overrideAmount', headerName: '오버라이드액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
type FilterMode = 'both' | 'from' | 'to';
|
||||
|
||||
export default function OverrideLedger() {
|
||||
const [mode, setMode] = useState<FilterMode>('both');
|
||||
const [params, setParams] = useState<OverrideLedgerSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 200,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['override', 'ledgers', params, mode],
|
||||
queryFn: () => commissionApi.overrideLedgers(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const allRows = data?.list ?? [];
|
||||
|
||||
// 클라이언트사이드 방향 필터
|
||||
const rows = mode === 'from'
|
||||
? allRows.filter((r) => r.fromAgentId === params.fromAgentId)
|
||||
: mode === 'to'
|
||||
? allRows.filter((r) => r.toAgentId === params.toAgentId)
|
||||
: allRows;
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
fromAgentName: '합계',
|
||||
baseCommission: rows.reduce((s, r) => s + (r.baseCommission ?? 0), 0),
|
||||
overrideAmount: rows.reduce((s, r) => s + (r.overrideAmount ?? 0), 0),
|
||||
} as Partial<OverrideLedgerRow> : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="오버라이드 원장"
|
||||
description="From-Agent → To-Agent 오버라이드 발생 원장 (조회 전용)"
|
||||
extra={
|
||||
<Space>
|
||||
<Text style={{ fontSize: 13, color: GRAY[500] }}>방향 필터:</Text>
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="both">전체</Radio.Button>
|
||||
<Radio.Button value="from">From 기준</Radio.Button>
|
||||
<Radio.Button value="to">To 기준</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/override-ledgers 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as OverrideLedgerSearchParam, pageSize: 200 })}
|
||||
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<OverrideLedgerRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="ledgerId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Col, Form, Input, InputNumber, Modal, Row, Space, Tabs, Tag, Typography, 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 {
|
||||
commissionApi,
|
||||
PersistencyRuleRow, PersistencyBonusRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtPct = (v: unknown) => `${((v as number) ?? 0).toFixed(1)}%`;
|
||||
|
||||
// 유지보수수당 등급별 색상
|
||||
const BONUS_TYPE_COLOR: Record<string, string> = {
|
||||
'13M': COLOR_PRIMARY,
|
||||
'25M': COLOR_SUCCESS,
|
||||
'37M': COLOR_WARNING,
|
||||
'49M': '#7C3AED',
|
||||
};
|
||||
|
||||
const RULE_COLS: ColDef<PersistencyRuleRow>[] = [
|
||||
{
|
||||
field: 'bonusType', headerName: '유지 기준', width: 100,
|
||||
cellRenderer: (p: { value: string }) => (
|
||||
<Tag style={{ background: BONUS_TYPE_COLOR[p.value] ?? GRAY[200], color: '#fff', border: 'none', borderRadius: 4 }}>
|
||||
{p.value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ field: 'minRate', headerName: '최저유지율(%)', width: 130, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
|
||||
{ field: 'bonusAmount', headerName: '정액지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'bonusRate', headerName: '정률지급(%)', flex: 1, type: 'numericColumn', valueFormatter: (p) => p.value != null ? fmtPct(p.value) : '-' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
const BONUS_COLS: ColDef<PersistencyBonusRow>[] = [
|
||||
{ field: 'evaluationMonth', headerName: '평가월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{
|
||||
field: 'bonusType', headerName: '기준', width: 90,
|
||||
cellRenderer: (p: { value: string }) => (
|
||||
<Tag style={{ background: BONUS_TYPE_COLOR[p.value] ?? GRAY[200], color: '#fff', border: 'none', borderRadius: 4 }}>
|
||||
{p.value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ field: 'retentionRate', headerName: '유지율(%)', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
|
||||
{ field: 'bonusAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'paidYn', headerName: '지급여부', width: 90 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
export default function PersistencyBonus() {
|
||||
const qc = useQueryClient();
|
||||
const [bonusParams, setBonusParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<PersistencyRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
|
||||
queryKey: ['persistency', 'rules'],
|
||||
queryFn: () => commissionApi.persistencyRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: bonusData, isLoading: bonusLoading } = useQuery({
|
||||
queryKey: ['persistency', 'bonuses', bonusParams],
|
||||
queryFn: () => commissionApi.persistencyBonuses(bonusParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const bonuses = bonusData?.list ?? [];
|
||||
|
||||
// 등급별 통계 카드
|
||||
const byType = ['13M', '25M', '37M', '49M'].map((t) => ({
|
||||
type: t,
|
||||
count: bonuses.filter((b) => b.bonusType === t).length,
|
||||
total: bonuses.filter((b) => b.bonusType === t).reduce((s, b) => s + (b.bonusAmount ?? 0), 0),
|
||||
}));
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: PersistencyRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.persistencyRuleUpdate(editingRule.ruleId, values);
|
||||
} else {
|
||||
await commissionApi.persistencyRuleCreate(values);
|
||||
}
|
||||
message.success('저장 완료');
|
||||
qc.invalidateQueries({ queryKey: ['persistency', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', onOk: async () => {
|
||||
await commissionApi.persistencyRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['persistency', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedBonus = bonuses.length > 0 ? {
|
||||
agentName: '합계',
|
||||
bonusAmount: bonuses.reduce((s, b) => s + (b.bonusAmount ?? 0), 0),
|
||||
} as Partial<PersistencyBonusRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="유지보수수당" description="13M/25M/37M/49M 유지율 기반 보너스 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/persistency-bonus 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '지급 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="PERSIST_BONUS" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<PersistencyRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: PersistencyRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="PERSIST_BONUS" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="PERSIST_BONUS" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={rulesLoading}
|
||||
height={420}
|
||||
rowKey="ruleId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bonuses',
|
||||
label: '지급 이력',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '평가월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setBonusParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setBonusParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
{/* 등급별 통계 카드 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{byType.map((t) => (
|
||||
<Col span={6} key={t.type}>
|
||||
<div style={{
|
||||
...cardStyle, padding: '16px 20px',
|
||||
borderLeft: `4px solid ${BONUS_TYPE_COLOR[t.type] ?? GRAY[300]}`,
|
||||
}}>
|
||||
<Text style={{ fontSize: 13, color: GRAY[500] }}>{t.type} 유지</Text>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: BONUS_TYPE_COLOR[t.type] ?? GRAY[700] }}>
|
||||
{t.count}건
|
||||
</div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>{t.total.toLocaleString()}원</Text>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<PersistencyBonusRow>
|
||||
rows={bonuses}
|
||||
columns={BONUS_COLS}
|
||||
loading={bonusLoading}
|
||||
height={480}
|
||||
rowKey="bonusId"
|
||||
pinnedBottomRow={pinnedBonus}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRule ? '룰 수정' : '룰 등록'}
|
||||
open={ruleModalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setRuleModalOpen(false)}
|
||||
okText="저장" cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="bonusType" label="유지 기준" rules={[{ required: true }]}>
|
||||
<Input placeholder="13M / 25M / 37M / 49M" />
|
||||
</Form.Item>
|
||||
<Form.Item name="minRate" label="최저유지율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bonusAmount" label="정액지급(원)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bonusRate" label="정률지급(%)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, Tabs, 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 {
|
||||
commissionApi,
|
||||
ReinstatementRuleRow, ReinstatementLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const RULE_COLS: ColDef<ReinstatementRuleRow>[] = [
|
||||
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
||||
{ field: 'productType', headerName: '상품유형', width: 120 },
|
||||
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<ReinstatementLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'reinstatementPremium', headerName: '부활보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
export default function ReinstatementCommission() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<ReinstatementRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
|
||||
queryKey: ['reinstatement', 'rules'],
|
||||
queryFn: () => commissionApi.reinstatementRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['reinstatement', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.reinstatementLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: ReinstatementRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.reinstatementRuleUpdate(editingRule.ruleId, values);
|
||||
} else {
|
||||
await commissionApi.reinstatementRuleCreate(values);
|
||||
}
|
||||
message.success('저장 완료');
|
||||
qc.invalidateQueries({ queryKey: ['reinstatement', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.reinstatementRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['reinstatement', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
reinstatementPremium: ledgers.reduce((s, r) => s + (r.reinstatementPremium ?? 0), 0),
|
||||
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
|
||||
} as Partial<ReinstatementLedgerRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="부활수수료" description="실효 계약 부활 시 수수료 룰 및 원장">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/reinstatement-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '수수료 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="REINSTATE_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ReinstatementRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: ReinstatementRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="REINSTATE_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="REINSTATE_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={rulesLoading}
|
||||
height={480}
|
||||
rowKey="ruleId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ledgers',
|
||||
label: '부활 원장',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ReinstatementLedgerRow>
|
||||
rows={ledgers}
|
||||
columns={LEDGER_COLS}
|
||||
loading={ledgersLoading}
|
||||
height={480}
|
||||
rowKey="ledgerId"
|
||||
pinnedBottomRow={pinnedLedger}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRule ? '룰 수정' : '룰 등록'}
|
||||
open={ruleModalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setRuleModalOpen(false)}
|
||||
okText="저장" cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, 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 {
|
||||
commissionApi,
|
||||
RenewalRuleRow, RenewalLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const RULE_COLS: ColDef<RenewalRuleRow>[] = [
|
||||
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
||||
{ field: 'productType', headerName: '상품유형', width: 120 },
|
||||
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<RenewalLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'renewalPremium', headerName: '갱신보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
];
|
||||
|
||||
export default function RenewalCommission() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<RenewalRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
|
||||
queryKey: ['renewal', 'rules'],
|
||||
queryFn: () => commissionApi.renewalRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['renewal', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.renewalLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: RenewalRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.renewalRuleUpdate(editingRule.ruleId, values);
|
||||
} else {
|
||||
await commissionApi.renewalRuleCreate(values);
|
||||
}
|
||||
message.success('저장 완료');
|
||||
qc.invalidateQueries({ queryKey: ['renewal', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.renewalRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['renewal', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
renewalPremium: ledgers.reduce((s, r) => s + (r.renewalPremium ?? 0), 0),
|
||||
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
|
||||
} as Partial<RenewalLedgerRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="갱신수수료" description="자동차/실비 갱신 수수료 룰 및 원장">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/renewal-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '수수료 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="RENEWAL_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<RenewalRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: RenewalRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="RENEWAL_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="RENEWAL_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={rulesLoading}
|
||||
height={480}
|
||||
rowKey="ruleId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ledgers',
|
||||
label: '갱신 원장',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<RenewalLedgerRow>
|
||||
rows={ledgers}
|
||||
columns={LEDGER_COLS}
|
||||
loading={ledgersLoading}
|
||||
height={480}
|
||||
rowKey="ledgerId"
|
||||
pinnedBottomRow={pinnedLedger}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRule ? '룰 수정' : '룰 등록'}
|
||||
open={ruleModalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setRuleModalOpen(false)}
|
||||
okText="저장" cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Checkbox, Form, Input, Modal, Typography, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import AttachmentUpload from '@/components/biz/AttachmentUpload';
|
||||
import { complaintApi, ComplaintRow, ComplaintSaveReq, ComplaintSearchParam } from '@/api/complaint';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_ERROR_BG, COLOR_ERROR_BORDER } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const COLUMNS: ColDef<ComplaintRow>[] = [
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'complaintType', headerName: '민원유형', width: 120 },
|
||||
{ field: 'content', headerName: '민원내용', flex: 1 },
|
||||
{ field: 'receivedDate', headerName: '접수일', width: 120 },
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="COMPLAINT_STATUS" value={p.value} />,
|
||||
},
|
||||
{
|
||||
field: 'chargebackTriggered', headerName: '환수', width: 80,
|
||||
cellRenderer: (p: { value: boolean }) =>
|
||||
p.value ? <Text style={{ color: '#E24B4A', fontWeight: 700 }}>환수</Text> : <Text style={{ color: GRAY[400] }}>-</Text>,
|
||||
},
|
||||
{ field: 'chargebackAmount', headerName: '환수금액', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'closedAt', headerName: '처리일', width: 120 },
|
||||
];
|
||||
|
||||
export default function Complaints() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<ComplaintSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [closeTarget, setCloseTarget] = useState<number | null>(null);
|
||||
const [processNote, setProcessNote] = useState('');
|
||||
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['complaints', params],
|
||||
queryFn: () => complaintApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
const chargebackCount = rows.filter((r) => r.chargebackTriggered).length;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as ComplaintSaveReq;
|
||||
try {
|
||||
const created = await complaintApi.create(values);
|
||||
message.success('민원 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['complaints'] });
|
||||
setCreatedId(created.complaintId);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!closeTarget) return;
|
||||
try {
|
||||
await complaintApi.close(closeTarget, processNote);
|
||||
message.success('처리완료');
|
||||
qc.invalidateQueries({ queryKey: ['complaints'] });
|
||||
setCloseTarget(null);
|
||||
setProcessNote('');
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="민원 관리"
|
||||
description="고객 민원 접수, 처리, 환수 연동"
|
||||
extra={
|
||||
<PermissionButton menuCode="COMPLAINTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 민원 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/complaints 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{chargebackCount > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
background: COLOR_ERROR_BG,
|
||||
border: `1px solid ${COLOR_ERROR_BORDER}`,
|
||||
borderRadius: RADIUS.md,
|
||||
marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Text style={{ fontSize: 13, color: '#E24B4A', fontWeight: 700 }}>환수 민원 {chargebackCount}건 — 정산 시 자동 반영</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'complaintType', label: '민원유형', groupCode: 'COMPLAINT_TYPE', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'COMPLAINT_STATUS', span: 6 },
|
||||
{ type: 'dateRange', name: 'receivedDate', label: '접수일', span: 8 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as ComplaintSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<ComplaintRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 120, pinned: 'right',
|
||||
cellRenderer: (p: { data: ComplaintRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="COMPLAINTS" permCode="APPROVE" size="small" type="primary"
|
||||
disabled={p.data.status === 'CLOSED'}
|
||||
onClick={() => { setCloseTarget(p.data.complaintId); setProcessNote(''); }}
|
||||
>
|
||||
처리완료
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="complaintId"
|
||||
getRowStyle={(params) => {
|
||||
if (params.data?.chargebackTriggered) {
|
||||
return { background: '#FEF0F1' };
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 등록 모달 */}
|
||||
<Modal title="민원 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소" width={560}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="complaintType" label="민원유형" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="content" label="민원내용" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="receivedDate" label="접수일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="chargebackTriggered" label="환수 연동" valuePropName="checked">
|
||||
<Checkbox>환수 트리거 (13M 이내 민원)</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 처리완료 모달 */}
|
||||
<Modal
|
||||
title="처리완료 확인"
|
||||
open={closeTarget !== null}
|
||||
onOk={handleClose}
|
||||
onCancel={() => setCloseTarget(null)}
|
||||
okText="처리완료"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="처리 내용" required>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={processNote}
|
||||
onChange={(e) => setProcessNote(e.target.value)}
|
||||
placeholder="처리 내용을 입력하세요"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 첨부파일 (마지막 등록 건) */}
|
||||
{createdId && (
|
||||
<ProCard
|
||||
title="첨부파일"
|
||||
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, marginTop: 16 }}
|
||||
>
|
||||
<AttachmentUpload refType="COMPLAINT" refId={createdId} />
|
||||
</ProCard>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Checkbox, Form, Input, Modal, Space, Tag, Typography, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import { endorsementApi, ContractEndorsementRow, ContractEndorsementSaveReq, EndorsementSearchParam } from '@/api/endorsement';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_WARNING_BG, COLOR_WARNING_BORDER } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const COLUMNS: ColDef<ContractEndorsementRow>[] = [
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'endorsementTypeName', headerName: '변경유형', width: 130 },
|
||||
{ field: 'changeDescription', headerName: '변경내용', flex: 1 },
|
||||
{ field: 'endorsementDate', headerName: '변경일', width: 120 },
|
||||
{
|
||||
field: 'recalculateRequired', headerName: '재계산', width: 90,
|
||||
cellRenderer: (p: { value: boolean }) => p.value
|
||||
? <Tag color="warning" style={{ margin: 0, fontWeight: 700 }}>필요</Tag>
|
||||
: <Tag color="default" style={{ margin: 0 }}>불필요</Tag>,
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="ENDORSEMENT_STATUS" value={p.value} />,
|
||||
},
|
||||
{ field: 'processedAt', headerName: '처리일', width: 120 },
|
||||
];
|
||||
|
||||
export default function ContractEndorsements() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<EndorsementSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['endorsements', params],
|
||||
queryFn: () => endorsementApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
const recalcCount = rows.filter((r) => r.recalculateRequired && r.status !== 'PROCESSED').length;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as ContractEndorsementSaveReq;
|
||||
try {
|
||||
await endorsementApi.create(values);
|
||||
message.success('계약 변경 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['endorsements'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleProcess = (id: number) => Modal.confirm({
|
||||
title: '처리 확정',
|
||||
content: '변경사항을 처리 완료로 변경합니다. 재계산이 필요한 경우 정산 배치를 다시 실행하세요.',
|
||||
onOk: async () => {
|
||||
await endorsementApi.process(id);
|
||||
message.success('처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['endorsements'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="계약 변경 관리"
|
||||
description="보험료/수익자/피보험자 변경 이력 및 재계산 트리거"
|
||||
extra={
|
||||
<PermissionButton menuCode="CONTRACT_ENDORSEMENTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 변경 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-endorsements 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{recalcCount > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
background: COLOR_WARNING_BG,
|
||||
border: `1px solid ${COLOR_WARNING_BORDER}`,
|
||||
borderRadius: RADIUS.md,
|
||||
marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Tag color="warning" style={{ fontWeight: 700 }}>재계산 필요 {recalcCount}건</Tag>
|
||||
<Text style={{ fontSize: 13, color: GRAY[700] }}>정산 배치 실행 전 확인이 필요합니다.</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'endorsementType', label: '변경유형', groupCode: 'ENDORSEMENT_TYPE', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'ENDORSEMENT_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as EndorsementSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<ContractEndorsementRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 120, pinned: 'right',
|
||||
cellRenderer: (p: { data: ContractEndorsementRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="CONTRACT_ENDORSEMENTS" permCode="APPROVE" size="small" type="primary"
|
||||
disabled={p.data.status === 'PROCESSED'}
|
||||
onClick={() => handleProcess(p.data.endorsementId)}
|
||||
>
|
||||
처리
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="endorsementId"
|
||||
getRowStyle={(params) => {
|
||||
if (params.data?.recalculateRequired && params.data?.status !== 'PROCESSED') {
|
||||
return { background: COLOR_WARNING_BG };
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal title="계약 변경 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="endorsementType" label="변경유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="PREMIUM / INSURED / BENEFICIARY / ETC" />
|
||||
</Form.Item>
|
||||
<Form.Item name="changeDescription" label="변경내용" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endorsementDate" label="변경일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="recalculateRequired" label="재계산 필요" valuePropName="checked">
|
||||
<Checkbox>재계산 필요</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Col, Row, Tabs, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import { kpiApi, CumulativeRow, KpiSearchParam } from '@/api/kpi';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_ERROR, GRAY, SHADOW, RADIUS,
|
||||
} from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString();
|
||||
const fmtM = (v: number) => `${(v / 1_000_000).toFixed(1)}M`;
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 컬럼 (AGENT / ORG 공통 구조)
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
function buildColumns(levelType: 'AGENT' | 'ORG'): ColDef<CumulativeRow>[] {
|
||||
return [
|
||||
{
|
||||
field: 'entityName',
|
||||
headerName: levelType === 'AGENT' ? '설계사명' : '조직명',
|
||||
width: 150, pinned: 'left',
|
||||
},
|
||||
{
|
||||
field: 'orgName',
|
||||
headerName: '소속 조직',
|
||||
width: 140,
|
||||
hide: levelType === 'ORG',
|
||||
},
|
||||
{ field: 'settleMonthCount', headerName: '정산 월수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'cumGross', headerName: '누적 수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'cumChargeback', headerName: '누적 환수', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'cumIncentive', headerName: '누적 시책', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{
|
||||
headerName: '순수익',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
sortable: true,
|
||||
valueGetter: (p) => (p.data?.cumGross ?? 0) - (p.data?.cumChargeback ?? 0),
|
||||
valueFormatter: (p) => fmt(p.value ?? 0),
|
||||
cellStyle: (p) => ({
|
||||
color: p.value > 0 ? COLOR_SUCCESS : p.value < 0 ? COLOR_ERROR : GRAY[500],
|
||||
fontWeight: 600,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 합계행 계산
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
function buildPinned(rows: CumulativeRow[]): Partial<CumulativeRow> | undefined {
|
||||
if (!rows.length) return undefined;
|
||||
return {
|
||||
entityName: '합계',
|
||||
cumGross: rows.reduce((s, r) => s + r.cumGross, 0),
|
||||
cumChargeback: rows.reduce((s, r) => s + r.cumChargeback, 0),
|
||||
cumIncentive: rows.reduce((s, r) => s + r.cumIncentive, 0),
|
||||
cumNet: rows.reduce((s, r) => s + r.cumNet, 0),
|
||||
settleMonthCount: rows.reduce((s, r) => s + r.settleMonthCount, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// Top10 차트
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = [
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, '#F59E0B', '#8B5CF6', '#06B6D4',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16',
|
||||
];
|
||||
|
||||
function Top10Chart({ rows }: { rows: CumulativeRow[] }) {
|
||||
const chartData = useMemo(() =>
|
||||
[...rows]
|
||||
.map((r) => ({
|
||||
name: r.entityName,
|
||||
순수익: Math.round((r.cumGross - r.cumChargeback) / 1_000_000),
|
||||
}))
|
||||
.sort((a, b) => b.순수익 - a.순수익)
|
||||
.slice(0, 10),
|
||||
[rows]);
|
||||
|
||||
return (
|
||||
<ProCard
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>순수익 상위 10</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원 (수수료 - 환수)</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false}
|
||||
tickFormatter={(v) => `${v}M`}
|
||||
/>
|
||||
<YAxis type="category" dataKey="name" width={90} tick={{ fontSize: 11, fill: GRAY[600] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
|
||||
formatter={(v: number) => [`${fmtM(v * 1_000_000)}`, '순수익']}
|
||||
/>
|
||||
<Bar dataKey="순수익" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((_, i) => (
|
||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 탭 패널
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
interface PanelProps {
|
||||
levelType: 'AGENT' | 'ORG';
|
||||
params: KpiSearchParam;
|
||||
}
|
||||
|
||||
function CumulativePanel({ levelType, params }: PanelProps) {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['kpi', 'cumulative', levelType, params],
|
||||
queryFn: () => kpiApi.cumulative({ ...params, levelType }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
const columns = useMemo(() => buildColumns(levelType), [levelType]);
|
||||
const pinnedRow = buildPinned(rows);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon
|
||||
message="API 미응답"
|
||||
description="/api/kpi/cumulative 를 확인하세요."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 요약 카드 3개 */}
|
||||
<Row gutter={20} style={{ marginBottom: 20 }}>
|
||||
{[
|
||||
{ label: '누적 수수료 합계', value: rows.reduce((s, r) => s + r.cumGross, 0), color: COLOR_PRIMARY, bg: '#EBF3FF' },
|
||||
{ label: '누적 환수 합계', value: rows.reduce((s, r) => s + r.cumChargeback, 0), color: COLOR_ERROR, bg: '#FEF0F1' },
|
||||
{ label: '누적 순수익', value: rows.reduce((s, r) => s + r.cumGross - r.cumChargeback, 0), color: COLOR_SUCCESS, bg: '#E8FAF3' },
|
||||
].map((card) => (
|
||||
<Col span={8} key={card.label}>
|
||||
<div style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 8,
|
||||
}}>
|
||||
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{card.label}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
|
||||
<span style={{ fontSize: 30, fontWeight: 700, color: card.color, lineHeight: 1, fontFeatureSettings: "'tnum'" }}>
|
||||
{fmtM(card.value)}
|
||||
</span>
|
||||
<Text style={{ fontSize: 13, color: GRAY[500] }}>원</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Top10Chart rows={rows} />
|
||||
|
||||
<ProCard
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<DataGrid<CumulativeRow>
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
height={500}
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 메인
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
export default function KpiCumulative() {
|
||||
const [activeTab, setActiveTab] = useState<'AGENT' | 'ORG'>('AGENT');
|
||||
const [params, setParams] = useState<KpiSearchParam>({
|
||||
toMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 500,
|
||||
});
|
||||
|
||||
const handleSearch = (values: Record<string, unknown>) => {
|
||||
setParams({
|
||||
toMonth: values.toMonth as string | undefined,
|
||||
orgId: values.orgId as number | undefined,
|
||||
pageSize: 500,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="누계 현황"
|
||||
description="설계사 / 조직별 누적 수수료 · 환수 · 순수익"
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'toMonth', label: '기준 월', span: 6 },
|
||||
]}
|
||||
onSearch={handleSearch}
|
||||
onReset={() => setParams({ toMonth: dayjs().format('YYYYMM'), pageSize: 500 })}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => setActiveTab(k as 'AGENT' | 'ORG')}
|
||||
items={[
|
||||
{ key: 'AGENT', label: '설계사별' },
|
||||
{ key: 'ORG', label: '조직별' },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<CumulativePanel key={activeTab} levelType={activeTab} params={params} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Col, Row, Space, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
IconTrendingUp, IconTrendingDown, IconMinus, IconDownload,
|
||||
} from '@tabler/icons-react';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import { kpiApi, MonthlySummaryRow, KpiSearchParam } from '@/api/kpi';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS,
|
||||
GRAY, SHADOW, RADIUS,
|
||||
} from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 유틸
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString();
|
||||
const fmtM = (v: number) => `${(v / 1_000_000).toFixed(1)}M`;
|
||||
|
||||
function calcPct(curr: number, prev: number): number | null {
|
||||
if (prev === 0) return null;
|
||||
return Math.round(((curr - prev) / Math.abs(prev)) * 1000) / 10;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 서브 컴포넌트 — KPI 카드
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
interface KpiCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
delta?: number | null;
|
||||
deltaLabel?: string;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, unit = '원', delta, deltaLabel, color = COLOR_PRIMARY, bg = '#EBF3FF' }: KpiCardProps) {
|
||||
const renderDelta = () => {
|
||||
if (delta === null || delta === undefined) return null;
|
||||
if (delta > 0) return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_SUCCESS }}>
|
||||
<IconTrendingUp size={12} />+{delta.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
if (delta < 0) return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_ERROR }}>
|
||||
<IconTrendingDown size={12} />{delta.toFixed(1)}%
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, color: GRAY[400] }}>
|
||||
<IconMinus size={12} />변동없음
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
padding: '24px 28px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{label}</Text>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: bg, color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<IconTrendingUp size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 34, fontWeight: 700, color: GRAY[800], lineHeight: 1,
|
||||
fontFeatureSettings: "'tnum'", fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{fmtM(value)}
|
||||
</span>
|
||||
<Text style={{ fontSize: 14, color: GRAY[500] }}>{unit}</Text>
|
||||
</div>
|
||||
{(delta !== null && delta !== undefined) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{renderDelta()}
|
||||
{deltaLabel && <Text style={{ fontSize: 12, color: GRAY[400] }}>{deltaLabel}</Text>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// AG Grid 컬럼 정의
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
const COLUMNS: ColDef<MonthlySummaryRow>[] = [
|
||||
{ field: 'yyyymm', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentCount', headerName: '설계사수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalGross', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalTax', headerName: '총공제(세)', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalNet', headerName: '총지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalChargeback', headerName: '환수', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalIncentive', headerName: '시책', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
];
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 클라이언트 사이드 엑셀 내보내기 (xlsx 없음 — CSV 대체)
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
function exportCsv(rows: MonthlySummaryRow[]) {
|
||||
const headers = ['정산월', '설계사수', '총수수료', '총공제', '총지급', '환수', '시책'];
|
||||
const lines = [
|
||||
headers.join(','),
|
||||
...rows.map((r) =>
|
||||
[r.yyyymm, r.agentCount, r.totalGross, r.totalTax, r.totalNet, r.totalChargeback, r.totalIncentive].join(','),
|
||||
),
|
||||
];
|
||||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `월별정산요약_${dayjs().format('YYYYMMDD')}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 메인 컴포넌트
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
export default function KpiMonthlySummary() {
|
||||
const [params, setParams] = useState<KpiSearchParam>({
|
||||
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
|
||||
toMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 100,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['kpi', 'monthly', params],
|
||||
queryFn: () => kpiApi.monthlySummary(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
// KPI 카드: 최신월 / 전월 / YTD 합산
|
||||
const thisMonth = rows[rows.length - 1];
|
||||
const prevMonth = rows.length >= 2 ? rows[rows.length - 2] : undefined;
|
||||
const ytdGross = rows.reduce((s, r) => s + (r.totalGross ?? 0), 0);
|
||||
const grossDelta = thisMonth && prevMonth
|
||||
? calcPct(thisMonth.totalGross, prevMonth.totalGross)
|
||||
: null;
|
||||
|
||||
// 합계행
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
yyyymm: '합계',
|
||||
agentCount: rows.reduce((s, r) => s + r.agentCount, 0),
|
||||
totalGross: rows.reduce((s, r) => s + r.totalGross, 0),
|
||||
totalTax: rows.reduce((s, r) => s + r.totalTax, 0),
|
||||
totalNet: rows.reduce((s, r) => s + r.totalNet, 0),
|
||||
totalChargeback: rows.reduce((s, r) => s + r.totalChargeback, 0),
|
||||
totalIncentive: rows.reduce((s, r) => s + r.totalIncentive, 0),
|
||||
} as Partial<MonthlySummaryRow> : undefined;
|
||||
|
||||
// 차트 데이터 — 최근 12개월
|
||||
const chartData = useMemo(() =>
|
||||
rows.slice(-12).map((r) => ({
|
||||
month: r.yyyymm.slice(4, 6) + '월',
|
||||
총수수료: Math.round(r.totalGross / 1_000_000),
|
||||
총지급: Math.round(r.totalNet / 1_000_000),
|
||||
환수: Math.round(r.totalChargeback / 1_000_000),
|
||||
})),
|
||||
[rows]);
|
||||
|
||||
const handleSearch = (values: Record<string, unknown>) => {
|
||||
setParams({
|
||||
fromMonth: values.fromMonth as string | undefined,
|
||||
toMonth: values.toMonth as string | undefined,
|
||||
pageSize: 100,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="월별 정산 요약"
|
||||
description="정산월별 수수료 / 공제 / 지급 합계 — Materialized View"
|
||||
extra={
|
||||
<Button
|
||||
icon={<IconDownload size={14} />}
|
||||
onClick={() => rows.length && exportCsv(rows)}
|
||||
disabled={!rows.length}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
CSV 다운로드
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 검색 */}
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
|
||||
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
|
||||
]}
|
||||
onSearch={handleSearch}
|
||||
onReset={() => setParams({ fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 100 })}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon
|
||||
message="API 미응답"
|
||||
description="/api/kpi/monthly-summary 를 확인하세요."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* KPI 카드 3개 */}
|
||||
<Row gutter={20} style={{ marginBottom: 20 }}>
|
||||
<Col span={8}>
|
||||
<KpiCard
|
||||
label="당월 총수수료"
|
||||
value={thisMonth?.totalGross ?? 0}
|
||||
unit="원"
|
||||
delta={grossDelta}
|
||||
deltaLabel="전월比"
|
||||
color={COLOR_PRIMARY}
|
||||
bg="#EBF3FF"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<KpiCard
|
||||
label="당월 총지급"
|
||||
value={thisMonth?.totalNet ?? 0}
|
||||
unit="원"
|
||||
color={COLOR_SUCCESS}
|
||||
bg="#E8FAF3"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<KpiCard
|
||||
label={`YTD 누계 (${dayjs().format('YYYY')})`}
|
||||
value={ytdGross}
|
||||
unit="원"
|
||||
color="#7C3AED"
|
||||
bg="#F3EEFF"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 차트 */}
|
||||
<ProCard
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>최근 12개월 수수료 추이</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
{chartData.length > 0 ? (
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
|
||||
formatter={(v: number) => [`${v}백만`, '']}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
|
||||
<Bar dataKey="총수수료" fill={COLOR_PRIMARY} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="총지급" fill={COLOR_SUCCESS} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="환수" fill={COLOR_ERROR} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<AreaChart data={[]} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
|
||||
<XAxis /><YAxis />
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
|
||||
{/* AG Grid */}
|
||||
<ProCard
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<DataGrid<MonthlySummaryRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={480}
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Col, Row, Select, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import { kpiApi, OrgSummaryRow, KpiSearchParam } from '@/api/kpi';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, GRAY, SHADOW, RADIUS,
|
||||
} from '@/theme/tokens';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString();
|
||||
|
||||
// AG Grid 컬럼
|
||||
const COLUMNS: ColDef<OrgSummaryRow>[] = [
|
||||
{ field: 'orgName', headerName: '조직명', width: 160, pinned: 'left' },
|
||||
{ field: 'orgType', headerName: '조직유형', width: 110 },
|
||||
{ field: 'yyyymm', headerName: '정산월', width: 110 },
|
||||
{ field: 'agentCount', headerName: '설계사수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalGross', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalNet', headerName: '총지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalRecruit', headerName: '모집', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
{ field: 'totalMaintain',headerName: '유지', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
|
||||
];
|
||||
|
||||
// 차트 팔레트
|
||||
const PALETTE = [
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, '#F59E0B', '#8B5CF6', '#06B6D4',
|
||||
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16',
|
||||
];
|
||||
|
||||
export default function KpiOrgSummary() {
|
||||
const [params, setParams] = useState<KpiSearchParam>({
|
||||
fromMonth: dayjs().format('YYYYMM'),
|
||||
toMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 200,
|
||||
});
|
||||
const [selectedOrg, setSelectedOrg] = useState<number | undefined>();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['kpi', 'org', params],
|
||||
queryFn: () => kpiApi.orgSummary({ ...params, orgId: selectedOrg }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
// 합계행
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
orgName: '합계',
|
||||
agentCount: rows.reduce((s, r) => s + r.agentCount, 0),
|
||||
totalGross: rows.reduce((s, r) => s + r.totalGross, 0),
|
||||
totalNet: rows.reduce((s, r) => s + r.totalNet, 0),
|
||||
totalRecruit: rows.reduce((s, r) => s + (r.totalRecruit ?? 0), 0),
|
||||
totalMaintain: rows.reduce((s, r) => s + (r.totalMaintain ?? 0), 0),
|
||||
} as Partial<OrgSummaryRow> : undefined;
|
||||
|
||||
// 조직 목록 (드롭다운)
|
||||
const orgOptions = useMemo(() => {
|
||||
const seen = new Set<number>();
|
||||
return rows
|
||||
.filter((r) => { if (seen.has(r.orgId)) return false; seen.add(r.orgId); return true; })
|
||||
.map((r) => ({ value: r.orgId, label: r.orgName }));
|
||||
}, [rows]);
|
||||
|
||||
// 차트 — 조직별 총수수료 상위 10개
|
||||
const chartData = useMemo(() => {
|
||||
const byOrg: Record<number, { orgName: string; totalGross: number }> = {};
|
||||
for (const r of rows) {
|
||||
if (!byOrg[r.orgId]) byOrg[r.orgId] = { orgName: r.orgName, totalGross: 0 };
|
||||
byOrg[r.orgId].totalGross += r.totalGross;
|
||||
}
|
||||
return Object.values(byOrg)
|
||||
.sort((a, b) => b.totalGross - a.totalGross)
|
||||
.slice(0, 10)
|
||||
.map((x) => ({ name: x.orgName, 수수료: Math.round(x.totalGross / 1_000_000) }));
|
||||
}, [rows]);
|
||||
|
||||
const handleSearch = (values: Record<string, unknown>) => {
|
||||
setParams({
|
||||
fromMonth: values.fromMonth as string | undefined,
|
||||
toMonth: values.toMonth as string | undefined,
|
||||
pageSize: 200,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="조직별 정산 요약"
|
||||
description="조직 단위 월별 수수료 / 설계사 집계"
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
|
||||
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
|
||||
]}
|
||||
onSearch={handleSearch}
|
||||
onReset={() => {
|
||||
setSelectedOrg(undefined);
|
||||
setParams({ fromMonth: dayjs().format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 200 });
|
||||
}}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon
|
||||
message="API 미응답"
|
||||
description="/api/kpi/org-summary 를 확인하세요."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={20} style={{ marginBottom: 20 }}>
|
||||
{/* 좌측: 조직 필터 Select */}
|
||||
<Col span={5}>
|
||||
<ProCard
|
||||
title={<Text style={{ fontSize: 14, fontWeight: 600, color: GRAY[700] }}>조직 선택</Text>}
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="전체 조직"
|
||||
allowClear
|
||||
options={orgOptions}
|
||||
value={selectedOrg}
|
||||
onChange={(v) => setSelectedOrg(v)}
|
||||
showSearch
|
||||
filterOption={(input, opt) =>
|
||||
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>
|
||||
조직 선택 후 검색 버튼 클릭
|
||||
</Text>
|
||||
</div>
|
||||
</ProCard>
|
||||
</Col>
|
||||
|
||||
{/* 우측: 차트 */}
|
||||
<Col span={19}>
|
||||
<ProCard
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>조직별 수수료 비교 (상위 10개)</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" width={80} tick={{ fontSize: 11, fill: GRAY[600] }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
|
||||
formatter={(v: number) => [`${v}백만`, '수수료']}
|
||||
/>
|
||||
<Bar dataKey="수수료" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((_, i) => (
|
||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* AG Grid */}
|
||||
<ProCard
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<DataGrid<OrgSummaryRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={500}
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Col, Row, Typography } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import { kpiApi, RetentionRow, KpiSearchParam } from '@/api/kpi';
|
||||
import {
|
||||
COLOR_PRIMARY, COLOR_SUCCESS, GRAY, SHADOW, RADIUS,
|
||||
} from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const fmtPct = (v: number) => `${v.toFixed(1)}%`;
|
||||
|
||||
// AG Grid 컬럼
|
||||
const COLUMNS: ColDef<RetentionRow>[] = [
|
||||
{ field: 'contractMonth', headerName: '계약 기준월', width: 130, pinned: 'left' },
|
||||
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
||||
{ field: 'totalContracts', headerName: '전체 계약', width: 110, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
|
||||
{ field: 'retained13m', headerName: '13M 유지건', flex: 1, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
|
||||
{ field: 'retained25m', headerName: '25M 유지건', flex: 1, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
|
||||
{
|
||||
field: 'retentionRate13m', headerName: '13M 유지율%', flex: 1, type: 'numericColumn',
|
||||
valueFormatter: (p) => fmtPct(p.value ?? 0),
|
||||
cellStyle: (p) => ({ color: (p.value ?? 0) >= 80 ? COLOR_SUCCESS : (p.value ?? 0) >= 60 ? '#F59E0B' : '#F04452' }),
|
||||
},
|
||||
{
|
||||
field: 'retentionRate25m', headerName: '25M 유지율%', flex: 1, type: 'numericColumn',
|
||||
valueFormatter: (p) => fmtPct(p.value ?? 0),
|
||||
cellStyle: (p) => ({ color: (p.value ?? 0) >= 70 ? COLOR_SUCCESS : (p.value ?? 0) >= 50 ? '#F59E0B' : '#F04452' }),
|
||||
},
|
||||
];
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 상단 카드
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
interface AvgCardProps { label: string; value: number; color: string; bg: string }
|
||||
|
||||
function AvgCard({ label, value, color, bg }: AvgCardProps) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
boxShadow: SHADOW.sm,
|
||||
padding: '24px 28px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}}>
|
||||
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{label}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 34, fontWeight: 700, color: GRAY[800], lineHeight: 1,
|
||||
fontFeatureSettings: "'tnum'",
|
||||
}}>
|
||||
{value.toFixed(1)}
|
||||
</span>
|
||||
<Text style={{ fontSize: 14, color: GRAY[500] }}>%</Text>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4, background: GRAY[100], borderRadius: 999, overflow: 'hidden', marginTop: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', width: `${Math.min(value, 100)}%`,
|
||||
background: color, backgroundColor: bg,
|
||||
borderRadius: 999,
|
||||
backgroundImage: `linear-gradient(90deg, ${color}88, ${color})`,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 메인
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
export default function KpiRetention() {
|
||||
const [params, setParams] = useState<KpiSearchParam>({
|
||||
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
|
||||
toMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 200,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['kpi', 'retention', params],
|
||||
queryFn: () => kpiApi.retention(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
// 평균 유지율
|
||||
const avg13m = rows.length > 0
|
||||
? rows.reduce((s, r) => s + r.retentionRate13m, 0) / rows.length
|
||||
: 0;
|
||||
const avg25m = rows.length > 0
|
||||
? rows.reduce((s, r) => s + r.retentionRate25m, 0) / rows.length
|
||||
: 0;
|
||||
|
||||
// 차트 — 월별 평균 유지율 (보험사 합산 후 평균)
|
||||
const chartData = useMemo(() => {
|
||||
const byMonth: Record<string, { sum13: number; sum25: number; cnt: number }> = {};
|
||||
for (const r of rows) {
|
||||
if (!byMonth[r.contractMonth]) byMonth[r.contractMonth] = { sum13: 0, sum25: 0, cnt: 0 };
|
||||
byMonth[r.contractMonth].sum13 += r.retentionRate13m;
|
||||
byMonth[r.contractMonth].sum25 += r.retentionRate25m;
|
||||
byMonth[r.contractMonth].cnt += 1;
|
||||
}
|
||||
return Object.entries(byMonth)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.slice(-12)
|
||||
.map(([month, v]) => ({
|
||||
month: month.slice(4, 6) + '월',
|
||||
'13M유지율': Math.round((v.sum13 / v.cnt) * 10) / 10,
|
||||
'25M유지율': Math.round((v.sum25 / v.cnt) * 10) / 10,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
// 합계행 (건수 합, 비율은 평균)
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
contractMonth: '집계',
|
||||
totalContracts: rows.reduce((s, r) => s + r.totalContracts, 0),
|
||||
retained13m: rows.reduce((s, r) => s + r.retained13m, 0),
|
||||
retained25m: rows.reduce((s, r) => s + r.retained25m, 0),
|
||||
retentionRate13m: avg13m,
|
||||
retentionRate25m: avg25m,
|
||||
} as Partial<RetentionRow> : undefined;
|
||||
|
||||
const handleSearch = (values: Record<string, unknown>) => {
|
||||
setParams({
|
||||
fromMonth: values.fromMonth as string | undefined,
|
||||
toMonth: values.toMonth as string | undefined,
|
||||
pageSize: 200,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="유지율 현황"
|
||||
description="13개월 / 25개월 계약 유지율 — Materialized View"
|
||||
>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
|
||||
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
|
||||
]}
|
||||
onSearch={handleSearch}
|
||||
onReset={() => setParams({
|
||||
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
|
||||
toMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 200,
|
||||
})}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon
|
||||
message="API 미응답"
|
||||
description="/api/kpi/retention 를 확인하세요."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 카드 2개 */}
|
||||
<Row gutter={20} style={{ marginBottom: 20 }}>
|
||||
<Col span={12}>
|
||||
<AvgCard label="평균 13M 유지율" value={avg13m} color={COLOR_PRIMARY} bg={COLOR_PRIMARY} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<AvgCard label="평균 25M 유지율" value={avg25m} color={COLOR_SUCCESS} bg={COLOR_SUCCESS} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* LineChart */}
|
||||
<ProCard
|
||||
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}>월별 유지율 추이</span>}
|
||||
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>최근 12개월 평균</span>}
|
||||
headerBordered
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 24, left: -8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false}
|
||||
tickFormatter={(v) => `${v}%`} domain={[0, 100]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
|
||||
formatter={(v: number) => [`${v}%`, '']}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
|
||||
<Line
|
||||
type="monotone" dataKey="13M유지율"
|
||||
stroke={COLOR_PRIMARY} strokeWidth={2.5}
|
||||
dot={{ fill: COLOR_PRIMARY, r: 3.5 }} activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone" dataKey="25M유지율"
|
||||
stroke={COLOR_SUCCESS} strokeWidth={2.5}
|
||||
dot={{ fill: COLOR_SUCCESS, r: 3.5 }} activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ProCard>
|
||||
|
||||
{/* AG Grid */}
|
||||
<ProCard
|
||||
style={{
|
||||
background: '#ffffff', borderRadius: RADIUS.lg,
|
||||
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
|
||||
}}
|
||||
>
|
||||
<DataGrid<RetentionRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={480}
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, Switch, Typography, 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 CodeBadge from '@/components/common/CodeBadge';
|
||||
import { withdrawApi, WithdrawRequestRow, WithdrawRequestSaveReq, WithdrawSearchParam } from '@/api/withdraw';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const COLUMNS: ColDef<WithdrawRequestRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110 },
|
||||
{ field: 'requestAmount', headerName: '신청금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'bankCode', headerName: '은행', width: 100 },
|
||||
{ field: 'accountNo', headerName: '계좌번호', width: 160 },
|
||||
{ field: 'accountHolder', headerName: '예금주', width: 110 },
|
||||
{
|
||||
field: 'status', headerName: '출금 상태', width: 120,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="WITHDRAW_STATUS" value={p.value} />,
|
||||
},
|
||||
{
|
||||
field: 'approvalStatus', headerName: '결재 상태', width: 110,
|
||||
cellRenderer: (p: { value?: string }) =>
|
||||
p.value ? <CodeBadge groupCode="APPROVAL_STATUS" value={p.value} /> : <Text style={{ color: GRAY[400], fontSize: 12 }}>-</Text>,
|
||||
},
|
||||
{ field: 'requestedAt', headerName: '신청일', width: 120 },
|
||||
{ field: 'completedAt', headerName: '완료일', width: 120 },
|
||||
];
|
||||
|
||||
export default function WithdrawRequest() {
|
||||
const qc = useQueryClient();
|
||||
const [viewAll, setViewAll] = useState(false);
|
||||
const [params, setParams] = useState<WithdrawSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'),
|
||||
pageSize: 100,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['withdraw-requests', params, viewAll],
|
||||
queryFn: () => withdrawApi.list({ ...params, viewAll }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
requestAmount: rows.reduce((s, r) => s + (r.requestAmount ?? 0), 0),
|
||||
} as Partial<WithdrawRequestRow> : undefined;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as WithdrawRequestSaveReq;
|
||||
try {
|
||||
await withdrawApi.create(values);
|
||||
message.success('출금 신청 완료 — 결재 요청이 자동 생성됩니다');
|
||||
qc.invalidateQueries({ queryKey: ['withdraw-requests'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="출금 신청"
|
||||
description="정산 출금 신청, 결재 진행, 펌뱅킹 상태 조회"
|
||||
extra={
|
||||
<Space>
|
||||
<Space align="center">
|
||||
<Text style={{ fontSize: 13, color: GRAY[500] }}>전체 보기</Text>
|
||||
<Switch checked={viewAll} onChange={setViewAll} size="small" />
|
||||
</Space>
|
||||
<PermissionButton menuCode="WITHDRAW_REQUESTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 출금 신청
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/withdraw-requests 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'WITHDRAW_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as WithdrawSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<WithdrawRequestRow>
|
||||
rows={rows}
|
||||
columns={COLUMNS}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="withdrawId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title="출금 신청"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
okText="신청"
|
||||
cancelText="취소"
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="settleMonth" label="정산월" rules={[{ required: true }]} initialValue={dayjs().format('YYYYMM')}>
|
||||
<Input placeholder="YYYYMM" />
|
||||
</Form.Item>
|
||||
<Form.Item name="requestAmount" label="신청금액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={1000} formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankCode" label="은행코드" rules={[{ required: true }]}>
|
||||
<Input placeholder="004 (국민) / 020 (우리) / ..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true }]}>
|
||||
<Input placeholder="'-' 없이 입력" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountHolder" label="예금주명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Alert } from 'antd';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import api, { unwrap } from '@/api/request';
|
||||
|
||||
interface RiskRow {
|
||||
agentId: number;
|
||||
agentName: string;
|
||||
orgName: string;
|
||||
policyNo: string;
|
||||
companyName: string;
|
||||
recruitMonth: string;
|
||||
monthsElapsed: number;
|
||||
recruitAmount: number;
|
||||
chargebackRate: number;
|
||||
estimatedChargeback: number;
|
||||
riskLevel: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
}
|
||||
|
||||
function fetchRisk() {
|
||||
return unwrap<RiskRow[]>(api.get('/api/report/chargeback-risk'));
|
||||
}
|
||||
|
||||
const riskColor: Record<string, string> = {
|
||||
HIGH: '#f5222d',
|
||||
MEDIUM: '#fa8c16',
|
||||
LOW: '#52c41a',
|
||||
};
|
||||
|
||||
const riskLabel: Record<string, string> = {
|
||||
HIGH: '고위험',
|
||||
MEDIUM: '중위험',
|
||||
LOW: '저위험',
|
||||
};
|
||||
|
||||
const columns: ProColumns<RiskRow>[] = [
|
||||
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' },
|
||||
{ title: '소속', dataIndex: 'orgName', width: 160 },
|
||||
{ title: '증권번호', dataIndex: 'policyNo', width: 130 },
|
||||
{ title: '보험사', dataIndex: 'companyName', width: 110 },
|
||||
{ title: '모집월', dataIndex: 'recruitMonth', width: 90 },
|
||||
{ title: '경과월', dataIndex: 'monthsElapsed', width: 80, align: 'right', render: (_, r) => `${r.monthsElapsed}개월` },
|
||||
{
|
||||
title: '모집수수료', dataIndex: 'recruitAmount', width: 120, align: 'right',
|
||||
render: (_, r) => <MoneyText value={r.recruitAmount} />,
|
||||
},
|
||||
{
|
||||
title: '환수율(%)', dataIndex: 'chargebackRate', width: 90, align: 'right',
|
||||
render: (_, r) => `${r.chargebackRate}%`,
|
||||
},
|
||||
{
|
||||
title: '예상환수액', dataIndex: 'estimatedChargeback', width: 130, align: 'right',
|
||||
render: (_, r) => <MoneyText value={r.estimatedChargeback} />,
|
||||
},
|
||||
{
|
||||
title: '위험등급', dataIndex: 'riskLevel', width: 90, align: 'center', fixed: 'right',
|
||||
render: (_, r) => (
|
||||
<span style={{ color: riskColor[r.riskLevel], fontWeight: 600 }}>
|
||||
{riskLabel[r.riskLevel] ?? r.riskLevel}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChargebackRiskReport() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['report', 'chargeback-risk'],
|
||||
queryFn: fetchRisk,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="환수위험 리포트"
|
||||
description="경과월별 환수 가능성 분석"
|
||||
extra={<ExcelExportButton url="/api/report/chargeback-risk/export" fileName="환수위험리포트" />}
|
||||
>
|
||||
{isError && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="백엔드 미구현 — 곧 추가됩니다"
|
||||
description="/api/report/chargeback-risk API가 아직 준비되지 않았습니다."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProTable<RiskRow>
|
||||
rowKey={(r) => `${r.agentId}-${r.policyNo}`}
|
||||
columns={columns}
|
||||
dataSource={data ?? []}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
options={{ density: false, reload: false }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, DatePicker } from 'antd';
|
||||
import { ProCard, ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import api, { unwrap } from '@/api/request';
|
||||
|
||||
interface OrgRow {
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
orgType: string;
|
||||
agentCount: number;
|
||||
recruitTotal: number;
|
||||
maintainTotal: number;
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
function fetchOrgReport(settleMonth: string) {
|
||||
return unwrap<OrgRow[]>(api.get('/api/report/org', { params: { settleMonth } }));
|
||||
}
|
||||
|
||||
const columns: ProColumns<OrgRow>[] = [
|
||||
{ title: '조직명', dataIndex: 'orgName', width: 180, fixed: 'left' },
|
||||
{ title: '유형', dataIndex: 'orgType', width: 80 },
|
||||
{ title: '설계사 수', dataIndex: 'agentCount', width: 90, align: 'right' },
|
||||
{
|
||||
title: '모집수수료', dataIndex: 'recruitTotal', width: 130, align: 'right',
|
||||
render: (_, r) => <MoneyText value={r.recruitTotal} />,
|
||||
},
|
||||
{
|
||||
title: '유지수수료', dataIndex: 'maintainTotal', width: 130, align: 'right',
|
||||
render: (_, r) => <MoneyText value={r.maintainTotal} />,
|
||||
},
|
||||
{
|
||||
title: '실지급 합계', dataIndex: 'netAmount', width: 140, align: 'right',
|
||||
render: (_, r) => <strong><MoneyText value={r.netAmount} /></strong>,
|
||||
},
|
||||
];
|
||||
|
||||
export default function OrgReport() {
|
||||
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
|
||||
|
||||
const monthStr = settleMonth.format('YYYYMM');
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['report', 'org', monthStr],
|
||||
queryFn: () => fetchOrgReport(monthStr),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="조직별 리포트"
|
||||
description="조직 단위 정산 집계"
|
||||
extra={
|
||||
<ExcelExportButton
|
||||
url="/api/report/org/export"
|
||||
fileName="조직별리포트"
|
||||
params={{ settleMonth: monthStr }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="백엔드 미구현 — 곧 추가됩니다"
|
||||
description="/api/report/org API가 아직 준비되지 않았습니다."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProCard bordered style={{ marginBottom: 16 }}>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={settleMonth}
|
||||
onChange={(d) => d && setSettleMonth(d)}
|
||||
allowClear={false}
|
||||
format="YYYY-MM"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<ProTable<OrgRow>
|
||||
rowKey="orgId"
|
||||
columns={columns}
|
||||
dataSource={data ?? []}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
options={{ density: false, reload: false }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Col, Empty, Row, Spin } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import api, { unwrap } from '@/api/request';
|
||||
|
||||
interface PerfRow {
|
||||
settleMonth: string;
|
||||
agentCount: number;
|
||||
recruitTotal: number;
|
||||
maintainTotal: number;
|
||||
netAmount: number;
|
||||
}
|
||||
|
||||
function fetchPerf(year: string) {
|
||||
return unwrap<PerfRow[]>(api.get('/api/report/performance', { params: { year } }));
|
||||
}
|
||||
|
||||
export default function PerformanceReport() {
|
||||
const [year] = useState(() => dayjs().format('YYYY'));
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['report', 'performance', year],
|
||||
queryFn: () => fetchPerf(year),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="실적 리포트"
|
||||
description="월별 수수료 집계 — 모집/유지/실지급"
|
||||
extra={<ExcelExportButton url="/api/report/performance/export" fileName="실적리포트" params={{ year }} />}
|
||||
>
|
||||
{isError && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="백엔드 미구현 — 곧 추가됩니다"
|
||||
description="/api/report/performance API가 아직 준비되지 않았습니다."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProCard bordered style={{ marginBottom: 16 }}>
|
||||
<Spin spinning={isLoading}>
|
||||
{data && data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={340}>
|
||||
<BarChart data={data} margin={{ top: 16, right: 24, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="settleMonth" />
|
||||
<YAxis tickFormatter={(v: number | string) => `${(Number(v) / 1_000_000).toFixed(0)}백만`} />
|
||||
<Tooltip formatter={(v: number | string) => Number(v).toLocaleString()} />
|
||||
<Legend />
|
||||
<Bar dataKey="recruitTotal" name="모집수수료" fill="#1677ff" />
|
||||
<Bar dataKey="maintainTotal" name="유지수수료" fill="#52c41a" />
|
||||
<Bar dataKey="netAmount" name="실지급" fill="#faad14" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Empty description={isLoading ? '로딩 중...' : '데이터 없음'} style={{ padding: 40 }} />
|
||||
)}
|
||||
</Spin>
|
||||
</ProCard>
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<ProCard bordered>
|
||||
<Row gutter={16}>
|
||||
{data.map((row) => (
|
||||
<Col key={row.settleMonth} xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||
<ProCard size="small" bordered style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{row.settleMonth}</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>설계사: {row.agentCount}명</div>
|
||||
<div style={{ fontSize: 12, color: '#1677ff' }}>
|
||||
모집: {(row.recruitTotal ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#52c41a' }}>
|
||||
유지: {(row.maintainTotal ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
실지급: {(row.netAmount ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</ProCard>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</ProCard>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Space, Tabs, Tag, Typography, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import ApprovalProgress from '@/components/biz/ApprovalProgress';
|
||||
import {
|
||||
approvalApi,
|
||||
ApprovalLineRow, ApprovalRequestRow,
|
||||
ApprovalRequestSaveReq,
|
||||
} from '@/api/approval';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const LINE_COLS: ColDef<ApprovalLineRow>[] = [
|
||||
{ field: 'lineName', headerName: '결재선명', flex: 1 },
|
||||
{ field: 'description', headerName: '설명', flex: 1 },
|
||||
{ field: 'stepCount', headerName: '단계수', width: 80, type: 'numericColumn' },
|
||||
{ field: 'status', headerName: '상태', width: 90 },
|
||||
{ field: 'createdAt', headerName: '생성일', width: 120 },
|
||||
];
|
||||
|
||||
const REQ_COLS: ColDef<ApprovalRequestRow>[] = [
|
||||
{ field: 'title', headerName: '제목', flex: 1 },
|
||||
{ field: 'lineName', headerName: '결재선', width: 140 },
|
||||
{ field: 'requesterName', headerName: '요청자', width: 110 },
|
||||
{ field: 'currentApproverName', headerName: '현재 결재자', width: 130 },
|
||||
{
|
||||
headerName: '진행', width: 120,
|
||||
cellRenderer: (p: { data: ApprovalRequestRow }) => (
|
||||
<span style={{ fontSize: 12 }}>{p.data.currentStep} / {p.data.totalSteps} 단계</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="APPROVAL_STATUS" value={p.value} />,
|
||||
},
|
||||
{ field: 'requestedAt', headerName: '요청일', width: 120 },
|
||||
];
|
||||
|
||||
export default function Approvals() {
|
||||
const qc = useQueryClient();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequestRow | null>(null);
|
||||
const [approveComment, setApproveComment] = useState('');
|
||||
const [createReqOpen, setCreateReqOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: linesData, isLoading: linesLoading, isError } = useQuery({
|
||||
queryKey: ['approval', 'lines'],
|
||||
queryFn: () => approvalApi.lines(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: myReqData, isLoading: myReqLoading } = useQuery({
|
||||
queryKey: ['approval', 'mine'],
|
||||
queryFn: () => approvalApi.myRequests(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: pendingData, isLoading: pendingLoading } = useQuery({
|
||||
queryKey: ['approval', 'pending'],
|
||||
queryFn: () => approvalApi.pendingRequests(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const lines = linesData?.list ?? [];
|
||||
const myRequests = myReqData?.list ?? [];
|
||||
const pendingRequests = pendingData?.list ?? [];
|
||||
|
||||
const openDetail = async (req: ApprovalRequestRow) => {
|
||||
try {
|
||||
const detail = await approvalApi.requestDetail(req.requestId);
|
||||
setSelectedRequest(detail);
|
||||
setDrawerOpen(true);
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleAdvance = async (approved: boolean) => {
|
||||
if (!selectedRequest) return;
|
||||
try {
|
||||
await approvalApi.advance(selectedRequest.requestId, { approved, comment: approveComment });
|
||||
message.success(approved ? '승인 완료' : '반려 완료');
|
||||
qc.invalidateQueries({ queryKey: ['approval'] });
|
||||
setDrawerOpen(false);
|
||||
setApproveComment('');
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleCreateReq = async () => {
|
||||
const values = await form.validateFields() as ApprovalRequestSaveReq;
|
||||
try {
|
||||
await approvalApi.requestCreate(values);
|
||||
message.success('결재 요청 완료');
|
||||
qc.invalidateQueries({ queryKey: ['approval'] });
|
||||
setCreateReqOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="결재 관리"
|
||||
description="결재선 설정, 결재 요청, 결재 처리"
|
||||
extra={
|
||||
<PermissionButton menuCode="APPROVALS" permCode="CREATE" type="primary" onClick={() => setCreateReqOpen(true)}>
|
||||
+ 결재 요청
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/approvals 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="lines"
|
||||
items={[
|
||||
{
|
||||
key: 'lines',
|
||||
label: `결재선 (${lines.length})`,
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ApprovalLineRow>
|
||||
rows={lines}
|
||||
columns={LINE_COLS}
|
||||
loading={linesLoading}
|
||||
height={480}
|
||||
rowKey="lineId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mine',
|
||||
label: `내 결재 요청 (${myRequests.length})`,
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ApprovalRequestRow>
|
||||
rows={myRequests}
|
||||
columns={REQ_COLS}
|
||||
loading={myReqLoading}
|
||||
height={480}
|
||||
rowKey="requestId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: (
|
||||
<span>
|
||||
결재 대기{' '}
|
||||
{pendingRequests.length > 0 && (
|
||||
<Tag style={{ background: COLOR_ERROR, color: '#fff', border: 'none', marginLeft: 4, borderRadius: 99 }}>
|
||||
{pendingRequests.length}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<DataGrid<ApprovalRequestRow>
|
||||
rows={pendingRequests}
|
||||
columns={[
|
||||
...REQ_COLS,
|
||||
{
|
||||
headerName: '상세', width: 80, pinned: 'right',
|
||||
cellRenderer: (p: { data: ApprovalRequestRow }) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(p.data)}>상세</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={pendingLoading}
|
||||
height={480}
|
||||
rowKey="requestId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 상세 Drawer */}
|
||||
<Drawer
|
||||
title={selectedRequest?.title}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={480}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button danger onClick={() => handleAdvance(false)}>반려</Button>
|
||||
<Button type="primary" onClick={() => handleAdvance(true)}>승인</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{selectedRequest && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={20}>
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500] }}>요청자</Text>
|
||||
<div style={{ fontSize: 14, color: GRAY[800], marginTop: 4 }}>{selectedRequest.requesterName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500] }}>내용</Text>
|
||||
<div style={{ fontSize: 14, color: GRAY[800], marginTop: 4, whiteSpace: 'pre-wrap' }}>{selectedRequest.content}</div>
|
||||
</div>
|
||||
|
||||
{selectedRequest.steps && selectedRequest.steps.length > 0 && (
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block', marginBottom: 8 }}>결재 진행 현황</Text>
|
||||
<ApprovalProgress
|
||||
steps={selectedRequest.steps}
|
||||
currentStep={selectedRequest.currentStep}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block', marginBottom: 4 }}>코멘트</Text>
|
||||
<TextArea
|
||||
rows={3}
|
||||
value={approveComment}
|
||||
onChange={(e) => setApproveComment(e.target.value)}
|
||||
placeholder="승인/반려 사유 (선택)"
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 결재 요청 모달 */}
|
||||
<Modal
|
||||
title="결재 요청"
|
||||
open={createReqOpen}
|
||||
onOk={handleCreateReq}
|
||||
onCancel={() => setCreateReqOpen(false)}
|
||||
okText="요청"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="lineId" label="결재선 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true }]}>
|
||||
<TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Checkbox, Form, Input, Modal, Space, Tag, Typography, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import { noticeApi, NoticeRow, NoticeSaveReq, NoticeSearchParam } from '@/api/notice';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const COLUMNS: ColDef<NoticeRow>[] = [
|
||||
{
|
||||
field: 'isPinned', headerName: '고정', width: 70,
|
||||
cellRenderer: (p: { value: boolean }) =>
|
||||
p.value ? <Tag color="error" style={{ margin: 0, fontSize: 11 }}>고정</Tag> : null,
|
||||
},
|
||||
{
|
||||
field: 'noticeType', headerName: '유형', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="NOTICE_TYPE" value={p.value} />,
|
||||
},
|
||||
{ field: 'title', headerName: '제목', flex: 1 },
|
||||
{ field: 'authorName', headerName: '작성자', width: 110 },
|
||||
{ field: 'viewCount', headerName: '조회수', width: 80, type: 'numericColumn' },
|
||||
{ field: 'publishedAt', headerName: '게시일', width: 120 },
|
||||
{ field: 'expiresAt', headerName: '만료일', width: 120 },
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 90,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="NOTICE_STATUS" value={p.value} />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Notices() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<NoticeSearchParam>({ pageSize: 50 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingNotice, setEditingNotice] = useState<NoticeRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['notices', params],
|
||||
queryFn: () => noticeApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingNotice(null); form.resetFields(); setModalOpen(true); };
|
||||
const openEdit = (row: NoticeRow) => { setEditingNotice(row); form.setFieldsValue(row); setModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields() as NoticeSaveReq;
|
||||
try {
|
||||
if (editingNotice) {
|
||||
await noticeApi.update(editingNotice.noticeId, values);
|
||||
message.success('공지 수정 완료');
|
||||
} else {
|
||||
await noticeApi.create(values);
|
||||
message.success('공지 등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['notices'] });
|
||||
setModalOpen(false);
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '공지 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await noticeApi.delete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['notices'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="공지사항 관리"
|
||||
description="시스템 공지사항 등록 및 관리"
|
||||
extra={
|
||||
<PermissionButton menuCode="NOTICES" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 공지 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/notices 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'title', label: '제목', span: 8 },
|
||||
{ type: 'code', name: 'noticeType', label: '유형', groupCode: 'NOTICE_TYPE', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as NoticeSearchParam, pageSize: 50 })}
|
||||
onReset={() => setParams({ pageSize: 50 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<NoticeRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: NoticeRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="NOTICES" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="NOTICES" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.noticeId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="noticeId"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title={editingNotice ? '공지 수정' : '공지 등록'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="noticeType" label="유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="GENERAL / IMPORTANT / SYSTEM" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true }]}>
|
||||
<TextArea rows={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isPinned" label="고정 여부" valuePropName="checked">
|
||||
<Checkbox>상단 고정</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="publishedAt" label="게시일">
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expiresAt" label="만료일">
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,69 @@
|
||||
:root {
|
||||
/* 브랜드 */
|
||||
--ga-primary: #1677FF;
|
||||
--ga-primary-light: #E6F1FB;
|
||||
--ga-primary-dark: #0C447C;
|
||||
/* ── 브랜드 — 토스 블루 ── */
|
||||
--ga-primary: #3182F6;
|
||||
--ga-primary-hover: #1B64DA;
|
||||
--ga-primary-light: #EBF3FF;
|
||||
--ga-primary-dark: #0E4FC4;
|
||||
|
||||
/* 상태 색상 (CodeBadge 등) */
|
||||
--ga-success: #0F6E56;
|
||||
--ga-success-bg: #E1F5EE;
|
||||
--ga-warning: #BA7517;
|
||||
--ga-warning-bg: #FAEEDA;
|
||||
--ga-danger: #E24B4A;
|
||||
--ga-danger-bg: #FCEBEB;
|
||||
--ga-info: #185FA5;
|
||||
--ga-info-bg: #E6F1FB;
|
||||
/* ── 시맨틱 ── */
|
||||
--ga-success: #13B47A;
|
||||
--ga-success-bg: #E8FAF3;
|
||||
--ga-success-border: #A3E6C8;
|
||||
|
||||
/* 금액 */
|
||||
--ga-money-plus: #0F6E56;
|
||||
--ga-money-minus: #E24B4A;
|
||||
--ga-money-zero: #888888;
|
||||
--ga-warning: #F2A024;
|
||||
--ga-warning-bg: #FFF6E6;
|
||||
--ga-warning-border: #F6D08A;
|
||||
|
||||
/* 레이아웃 */
|
||||
--ga-sider-width: 220px;
|
||||
--ga-danger: #F04452;
|
||||
--ga-danger-bg: #FEF0F1;
|
||||
--ga-danger-border: #F9B0B6;
|
||||
|
||||
--ga-info: #3182F6;
|
||||
--ga-info-bg: #EBF3FF;
|
||||
--ga-info-border: #A8CAFE;
|
||||
|
||||
/* ── Neutral Gray — 토스 팔레트 ── */
|
||||
--ga-gray-50: #F9FAFB;
|
||||
--ga-gray-100: #F2F4F6;
|
||||
--ga-gray-200: #E5E8EB;
|
||||
--ga-gray-300: #D1D6DB;
|
||||
--ga-gray-400: #B0B8C1;
|
||||
--ga-gray-500: #8B95A1;
|
||||
--ga-gray-600: #4E5968;
|
||||
--ga-gray-700: #333D4B;
|
||||
--ga-gray-800: #191F28;
|
||||
--ga-gray-900: #191F28;
|
||||
|
||||
/* ── 금액 색상 ── */
|
||||
--ga-money-plus: #13B47A;
|
||||
--ga-money-minus: #F04452;
|
||||
--ga-money-zero: #8B95A1;
|
||||
|
||||
/* ── 레이아웃 ── */
|
||||
--ga-sider-width: 240px;
|
||||
--ga-sider-collapsed-width: 64px;
|
||||
--ga-header-height: 48px;
|
||||
--ga-content-padding: 20px;
|
||||
--ga-page-max-width: 1400px;
|
||||
--ga-header-height: 56px;
|
||||
--ga-content-padding: 24px;
|
||||
--ga-page-max-width: 1440px;
|
||||
|
||||
/* 간격 단위 */
|
||||
--ga-spacing-xs: 4px;
|
||||
--ga-spacing-sm: 8px;
|
||||
--ga-spacing-md: 12px;
|
||||
--ga-spacing-lg: 16px;
|
||||
--ga-spacing-xl: 24px;
|
||||
/* ── Spacing ── */
|
||||
--ga-space-1: 4px;
|
||||
--ga-space-2: 8px;
|
||||
--ga-space-3: 12px;
|
||||
--ga-space-4: 16px;
|
||||
--ga-space-6: 24px;
|
||||
--ga-space-8: 32px;
|
||||
--ga-space-12: 48px;
|
||||
--ga-space-16: 64px;
|
||||
|
||||
/* 그림자 (최소한만 사용) */
|
||||
--ga-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--ga-shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
/* ── Border Radius — 토스 비율 ── */
|
||||
--ga-radius-sm: 8px;
|
||||
--ga-radius-md: 12px;
|
||||
--ga-radius-lg: 16px;
|
||||
--ga-radius-xl: 20px;
|
||||
|
||||
/* ── Shadow — 매우 옅게 합성 ── */
|
||||
--ga-shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
|
||||
--ga-shadow-md: 0 2px 8px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06);
|
||||
--ga-shadow-lg: 0 4px 16px rgba(0,0,0,0.10), 0 16px 48px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* GA 정산시스템 디자인 토큰 — 토스(Toss) 톤 조정
|
||||
* main.tsx ConfigProvider 의 theme 에 주입되며,
|
||||
* styles/variables.css 의 CSS 변수와 동기하여 사용한다.
|
||||
*/
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 색상
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
/** 브랜드 — 토스 시그니처 블루 */
|
||||
export const COLOR_PRIMARY = '#3182F6';
|
||||
export const COLOR_PRIMARY_HOVER = '#1B64DA';
|
||||
export const COLOR_PRIMARY_LIGHT = '#EBF3FF';
|
||||
export const COLOR_PRIMARY_DARK = '#0E4FC4';
|
||||
|
||||
/** 시맨틱 — 토스 팔레트 */
|
||||
export const COLOR_SUCCESS = '#13B47A';
|
||||
export const COLOR_SUCCESS_BG = '#E8FAF3';
|
||||
export const COLOR_SUCCESS_BORDER = '#A3E6C8';
|
||||
|
||||
export const COLOR_WARNING = '#F2A024';
|
||||
export const COLOR_WARNING_BG = '#FFF6E6';
|
||||
export const COLOR_WARNING_BORDER = '#F6D08A';
|
||||
|
||||
export const COLOR_ERROR = '#F04452';
|
||||
export const COLOR_ERROR_BG = '#FEF0F1';
|
||||
export const COLOR_ERROR_BORDER = '#F9B0B6';
|
||||
|
||||
export const COLOR_INFO = '#3182F6';
|
||||
export const COLOR_INFO_BG = '#EBF3FF';
|
||||
export const COLOR_INFO_BORDER = '#A8CAFE';
|
||||
|
||||
/** Neutral Gray — 토스 팔레트 (8단계) */
|
||||
export const GRAY = {
|
||||
50: '#F9FAFB',
|
||||
100: '#F2F4F6',
|
||||
200: '#E5E8EB',
|
||||
300: '#D1D6DB',
|
||||
400: '#B0B8C1',
|
||||
500: '#8B95A1',
|
||||
600: '#4E5968',
|
||||
700: '#333D4B',
|
||||
800: '#191F28',
|
||||
900: '#191F28',
|
||||
} as const;
|
||||
|
||||
/** 금액 색상 */
|
||||
export const COLOR_MONEY_PLUS = COLOR_SUCCESS;
|
||||
export const COLOR_MONEY_MINUS = COLOR_ERROR;
|
||||
export const COLOR_MONEY_ZERO = GRAY[500];
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 타이포그래피
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const FONT_FAMILY = "'Pretendard', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif";
|
||||
|
||||
export const FONT_SIZE = {
|
||||
xs: 11,
|
||||
sm: 13,
|
||||
base: 15, // 본문 기준 — 토스는 14보다 한 단계 키움
|
||||
md: 16,
|
||||
lg: 20,
|
||||
xl: 24,
|
||||
'2xl': 32,
|
||||
} as const;
|
||||
|
||||
export const FONT_WEIGHT = {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
} as const;
|
||||
|
||||
export const LINE_HEIGHT = {
|
||||
tight: 1.3,
|
||||
normal: 1.5,
|
||||
loose: 1.7,
|
||||
} as const;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 공간 (Spacing)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const SPACING = {
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
} as const;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Border Radius — 토스 비율
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const RADIUS = {
|
||||
sm: 8, // 칩, 작은 요소
|
||||
md: 12, // 버튼, 입력창
|
||||
lg: 16, // 카드
|
||||
xl: 20, // 큰 모달
|
||||
full: 9999,
|
||||
} as const;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 그림자 — 매우 옅고 부드럽게 (토스 스타일)
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const SHADOW = {
|
||||
sm: '0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04)',
|
||||
md: '0 2px 8px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06)',
|
||||
lg: '0 4px 16px rgba(0,0,0,0.10), 0 16px 48px rgba(0,0,0,0.08)',
|
||||
} as const;
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// 레이아웃
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
export const LAYOUT = {
|
||||
siderWidth: 240,
|
||||
siderCollapsedWidth: 64,
|
||||
headerHeight: 56,
|
||||
contentPadding: 24,
|
||||
pageMaxWidth: 1440,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user