동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
+69
View File
@@ -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 />} />
+50
View File
@@ -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}`)),
};
+101
View File
@@ -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)),
};
+39
View File
@@ -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}`)),
};
+230
View File
@@ -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 }),
),
};
+53
View File
@@ -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}`)),
};
+53
View File
@@ -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}`)),
};
+102
View File
@@ -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 }),
),
};
+49
View File
@@ -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}`)),
};
+48
View File
@@ -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}`)),
};
+27
View File
@@ -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')),
};
+70
View File
@@ -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}`)),
};
+45
View File
@@ -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>
);
}
+37 -17
View File
@@ -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>
+106 -31
View File
@@ -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
View File
@@ -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;
}
+137 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+118 -19
View File
@@ -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>
);
}
+249
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+261
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+61 -31
View File
@@ -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);
}
+129
View File
@@ -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;