feat: 테스트 데이터(V17) + 실 DB 프로파일 + 프론트 화면 확장

V17 (192.168.0.60:55432/trading_ai/ga 적용 완료):
- insurance_company 5 (삼성/한화/교보/디비/KB)
- product 20 (보험사별 4개)
- organization 7 (본부 1, 지점 3, 팀 3)
- agent 50 (GM 3 / DM 3 / SMGR 6 / SFC 12 / FC 26)
- contract 200 (ACTIVE 190 + LAPSE 10)
- commission_rate 100 (5상품 × 5년차 × 5보험사 = 일부)
  실제 INSERT는 상품 20개 × 1년차 + 20 × 4년차 = 100건
- payout_rule 120 (직급 6 × 보험종류 4 × 연차 5)
- override_rule 10 (FC/SFC/MGR/SMGR/DM 직급 간 오버라이드)
- chargeback_rule 4 (실효월수 구간별 환수율)

운영 DB 프로파일:
- application-trading_ai.yml (ga-api, ga-batch)
- 192.168.0.60:55432 / trading_ai / currentSchema=ga
- Flyway baseline-version=17 (수동 적용 분 인정)

DB 포트 변경 반영: 5432 → 55432 (운영 변경)

프론트 추가 화면:
- DataGrid (AG Grid Community 래퍼; 합계행/셀편집/로딩 오버레이)
- 예외원장 (ExceptionLedger): 승인/반려 액션
- 지급관리 (PaymentList)
- 정산 배치 (BatchRun): 실행 + 진행률 폴링
- 시스템관리:
  * UserList: 비밀번호 초기화 + 잠금 해제
  * RoleList: 좌측 역할 + 우측 권한 매트릭스 (메뉴 × 6권한)
  * CodeList: 좌측 그룹 + 우측 상세 코드, 시스템그룹 경고

설계사 상세/등록 폼:
- AgentDetail: 통계 카드 + 기본정보 + 수정 버튼
- AgentForm: 등록/수정 통합 (id='new' 또는 :id/edit)

라우터:
- /org/agents/{new|:id|:id/edit}
- /ledger/exception, /payments, /settle/batch
- /system/{users|roles|codes}
This commit is contained in:
GA Pro
2026-05-09 22:47:55 +09:00
parent 98eb231950
commit 92079d2385
16 changed files with 1197 additions and 4 deletions
@@ -0,0 +1,27 @@
# 실제 운영 PostgreSQL (192.168.0.60/trading_ai/ga schema) 연결용 프로파일
# 사용: ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
spring:
datasource:
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: ${DB_USER:kyu}
password: ${DB_PASSWORD:7895123}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
flyway:
# ga schema 에는 이미 V1~V17 수동 적용 완료. 추가 마이그레이션이 생기면 baseline 부터 다시 시작.
enabled: true
schemas: ga
default-schema: ga
baseline-on-migrate: true
baseline-version: 17 # 현재 적용된 최신 버전. 새 V18+ 만 적용
baseline-description: "Initial baseline (V1-V17 applied manually on 2026-05-09)"
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
@@ -0,0 +1,14 @@
# 실제 운영 PostgreSQL 연결용 프로파일 (ga-batch)
spring:
datasource:
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: ${DB_USER:kyu}
password: ${DB_PASSWORD:7895123}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 5
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
@@ -0,0 +1,191 @@
-- V17: 테스트 데이터 (보험사 5 / 상품 20 / 조직 7 / 설계사 50 / 계약 200 / 수수료규정)
-- 운영 데이터 적재 전 시연/검증용
-- ============ 보험사 5 ============
INSERT INTO insurance_company (company_code, company_name, company_type, biz_no, contact_name, contact_phone, contact_email)
VALUES
('SAMSUNG', '삼성생명', 'LIFE', '101-81-22000', '김삼성', '02-1588-1000', 'contact@samsung.com'),
('HANWHA', '한화생명', 'LIFE', '104-81-23456', '이한화', '02-789-5000', 'contact@hanwha.com'),
('KYOBO', '교보생명', 'LIFE', '110-81-12345', '박교보', '02-2151-2000', 'contact@kyobo.com'),
('DBLIFE', '디비생명', 'NONLIFE', '108-81-50101', '최디비', '02-3011-3000', 'contact@dblife.com'),
('KB', 'KB손해보험', 'NONLIFE', '105-81-99999', '정케이비','02-2080-1114','contact@kb.com');
-- ============ 상품 20 (보험사별 4개) ============
INSERT INTO product (company_id, product_code, product_name, insurance_type, product_group, pay_period_type, launch_date)
SELECT c.company_id, p.product_code, p.product_name, p.insurance_type, p.product_group, p.pay_period_type, '2023-01-01'
FROM insurance_company c
CROSS JOIN (VALUES
('TERM10', '정기보험 10년', 'TERM', 'TERM', 'MONTHLY'),
('WHOLE100', '종신보험 100세', 'WHOLE_LIFE', 'WHOLE', 'MONTHLY'),
('SAVINGS5', '저축보험 5년', 'SAVINGS', 'SAVE', 'MONTHLY'),
('HEALTHCARE', '건강보험', 'HEALTH', 'HEALTH','MONTHLY')
) AS p(product_code, product_name, insurance_type, product_group, pay_period_type);
-- ============ 조직 7 (본부 1 + 지점 3 + 팀 3) ============
INSERT INTO organization (org_id, parent_org_id, org_name, org_type, org_level, region, effective_from, is_active)
VALUES
(1001, NULL, '본사', 'HQ', 1, '서울', '2020-01-01', 'Y'),
(1002, 1001, '서울지점', 'BR', 2, '서울', '2020-01-01', 'Y'),
(1003, 1001, '경기지점', 'BR', 2, '경기', '2020-01-01', 'Y'),
(1004, 1001, '부산지점', 'BR', 2, '부산', '2020-01-01', 'Y'),
(1005, 1002, '서울1팀', 'TM', 3, '서울', '2020-01-01', 'Y'),
(1006, 1002, '서울2팀', 'TM', 3, '서울', '2020-01-01', 'Y'),
(1007, 1003, '경기1팀', 'TM', 3, '경기', '2020-01-01', 'Y');
-- agent SERIAL 시퀀스 보존을 위해 organization sequence 보정
SELECT setval('organization_org_id_seq', (SELECT MAX(org_id) FROM organization));
-- ============ 설계사 50 (팀당 ~16명) ============
-- 본부장 3 / 지사장 3 / 매니저 6 / 시니어FC 12 / FC 26
INSERT INTO agent (org_id, grade_id, agent_name, license_no, phone, email, status, join_date)
SELECT
CASE
WHEN g.grade_level >= 5 THEN 1001
WHEN g.grade_level = 4 THEN (ARRAY[1002,1003,1004])[((s-1) % 3) + 1]
ELSE (ARRAY[1005,1006,1007])[((s-1) % 3) + 1]
END AS org_id,
g.grade_id,
'설계사' || LPAD(s::text, 3, '0') AS agent_name,
'L' || LPAD(s::text, 6, '0') AS license_no,
'010-' || LPAD((1000 + s)::text, 4, '0') || '-' || LPAD(s::text, 4, '0'),
'agent' || s || '@ga.com',
'ACTIVE',
DATE '2023-01-01' + (s % 365)
FROM generate_series(1, 50) AS s
CROSS JOIN LATERAL (
SELECT grade_id, grade_level FROM grade
WHERE grade_level = CASE
WHEN s <= 3 THEN 6 -- GM 본부장
WHEN s <= 6 THEN 5 -- DM 지사장
WHEN s <= 12 THEN 4 -- SMGR 시니어매니저
WHEN s <= 24 THEN 2 -- SFC 시니어FC
ELSE 1 -- FC
END
LIMIT 1
) g;
-- ============ 수수료 규정 ============
-- commission_rate: 상품별 1년차 (모집)
INSERT INTO commission_rate (product_id, commission_year, rate_pct, effective_from, version, created_at)
SELECT product_id, 1,
CASE insurance_type
WHEN 'TERM' THEN 60.0000
WHEN 'WHOLE_LIFE' THEN 80.0000
WHEN 'SAVINGS' THEN 8.0000
WHEN 'HEALTH' THEN 70.0000
END,
'2023-01-01', 1, NOW()
FROM product;
-- 2~5년차 유지수수료 (점감)
INSERT INTO commission_rate (product_id, commission_year, rate_pct, effective_from, version, created_at)
SELECT p.product_id, y.year,
CASE p.insurance_type
WHEN 'TERM' THEN 60.0000 * POWER(0.5, y.year - 1)
WHEN 'WHOLE_LIFE' THEN 80.0000 * POWER(0.4, y.year - 1)
WHEN 'SAVINGS' THEN 8.0000 * POWER(0.5, y.year - 1)
WHEN 'HEALTH' THEN 70.0000 * POWER(0.4, y.year - 1)
END,
'2023-01-01', 1, NOW()
FROM product p
CROSS JOIN (VALUES (2),(3),(4),(5)) AS y(year);
-- payout_rule: 직급별 × 보험종류별 × 1년차 지급율
INSERT INTO payout_rule (grade_id, insurance_type, commission_year, payout_pct, effective_from, version, created_at)
SELECT g.grade_id, t.insurance_type, 1,
-- 직급이 높을수록 본인 지급율은 낮아지고 오버라이드로 받음 (단순 모델)
CASE g.grade_level
WHEN 1 THEN 70.0000 -- FC
WHEN 2 THEN 75.0000 -- SFC
WHEN 3 THEN 60.0000 -- MGR
WHEN 4 THEN 50.0000 -- SMGR
WHEN 5 THEN 30.0000 -- DM
WHEN 6 THEN 20.0000 -- GM
END,
'2023-01-01', 1, NOW()
FROM grade g
CROSS JOIN (VALUES ('TERM'),('WHOLE_LIFE'),('SAVINGS'),('HEALTH')) AS t(insurance_type);
-- 2~5년차 유지지급율
INSERT INTO payout_rule (grade_id, insurance_type, commission_year, payout_pct, effective_from, version, created_at)
SELECT g.grade_id, t.insurance_type, y.year,
CASE g.grade_level
WHEN 1 THEN 60.0000
WHEN 2 THEN 65.0000
WHEN 3 THEN 50.0000
WHEN 4 THEN 40.0000
WHEN 5 THEN 25.0000
WHEN 6 THEN 15.0000
END,
'2023-01-01', 1, NOW()
FROM grade g
CROSS JOIN (VALUES ('TERM'),('WHOLE_LIFE'),('SAVINGS'),('HEALTH')) AS t(insurance_type)
CROSS JOIN (VALUES (2),(3),(4),(5)) AS y(year);
-- override_rule: 상위 직급 → 하위 직급 오버라이드
INSERT INTO override_rule (from_grade, to_grade, override_pct, calc_type, effective_from)
SELECT lower.grade_id, upper.grade_id, pct, 'DIRECT', '2023-01-01'
FROM (VALUES
-- (하위 직급명, 상위 직급명, 오버라이드율 %)
('FC', 'MGR', 10.0000),
('FC', 'SMGR', 5.0000),
('FC', 'DM', 3.0000),
('SFC', 'MGR', 10.0000),
('SFC', 'SMGR', 5.0000),
('SFC', 'DM', 3.0000),
('MGR', 'SMGR', 10.0000),
('MGR', 'DM', 5.0000),
('SMGR', 'DM', 8.0000),
('DM', 'GM', 5.0000)
) AS r(low_name, up_name, pct)
JOIN grade lower ON lower.grade_name = r.low_name
JOIN grade upper ON upper.grade_name = r.up_name;
-- chargeback_rule: 실효월수별 환수율 (전 보험사 공통)
INSERT INTO chargeback_rule (company_code, insurance_type, lapse_month_from, lapse_month_to, cb_rate, effective_from)
VALUES
(NULL, NULL, 0, 3, 100.0000, '2023-01-01'), -- 3개월 이내 실효: 전액 환수
(NULL, NULL, 4, 6, 80.0000, '2023-01-01'),
(NULL, NULL, 7, 12, 50.0000, '2023-01-01'),
(NULL, NULL, 13, 24, 20.0000, '2023-01-01');
-- ============ 계약 200 ============
INSERT INTO contract (agent_id, product_id, policy_no, contractor_name, insured_name,
premium, pay_cycle, pay_period, coverage_period, status, contract_date, effective_date, created_at)
SELECT
a.agent_id,
p.product_id,
'POL-' || LPAD(s::text, 8, '0'),
'고객' || LPAD(s::text, 4, '0'),
'피보험' || LPAD(s::text, 4, '0'),
-- 보험료: 5만 ~ 50만 (5천원 단위)
(50 + (s % 91) * 5) * 1000,
'MONTHLY',
20, 30,
-- 95% 활성 / 5% 실효
CASE WHEN s % 20 = 0 THEN 'LAPSE' ELSE 'ACTIVE' END,
-- 계약일: 최근 6개월 내 분포
CURRENT_DATE - ((s * 3) % 180),
CURRENT_DATE - ((s * 3) % 180),
NOW()
FROM generate_series(1, 200) AS s
CROSS JOIN LATERAL (
SELECT agent_id FROM agent
ORDER BY (agent_id * s) % 100
LIMIT 1
) a
CROSS JOIN LATERAL (
SELECT product_id FROM product
ORDER BY (product_id * s) % 80
LIMIT 1
) p;
-- 실효 계약에는 lapse_date 채움
UPDATE contract SET lapse_date = contract_date + INTERVAL '4 months'
WHERE status = 'LAPSE';
-- ============ company_profile (수동 업로드 모드) ============
INSERT INTO company_profile (company_code, data_format, receive_method, file_encoding, header_row, data_start_row, is_active)
SELECT company_code, 'EXCEL', 'MANUAL', 'UTF-8', 1, 2, 'Y'
FROM insurance_company
ON CONFLICT (company_code) DO NOTHING;
+28
View File
@@ -2,10 +2,19 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout'; import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard'; import Dashboard from '@/pages/Dashboard';
import AgentList from '@/pages/org/AgentList'; import AgentList from '@/pages/org/AgentList';
import AgentDetail from '@/pages/org/AgentDetail';
import AgentForm from '@/pages/org/AgentForm';
import ContractList from '@/pages/product/ContractList'; import ContractList from '@/pages/product/ContractList';
import RecruitLedger from '@/pages/ledger/RecruitLedger'; import RecruitLedger from '@/pages/ledger/RecruitLedger';
import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
import SettleList from '@/pages/settle/SettleList'; import SettleList from '@/pages/settle/SettleList';
import PaymentList from '@/pages/settle/PaymentList';
import BatchRun from '@/pages/settle/BatchRun';
import UserList from '@/pages/system/UserList';
import RoleList from '@/pages/system/RoleList';
import CodeList from '@/pages/system/CodeList';
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
@@ -18,10 +27,29 @@ export default function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}> <Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{/* 조직 / 설계사 */}
<Route path="org/agents" element={<AgentList />} /> <Route path="org/agents" element={<AgentList />} />
<Route path="org/agents/new" element={<AgentForm />} />
<Route path="org/agents/:id" element={<AgentDetail />} />
<Route path="org/agents/:id/edit" element={<AgentForm />} />
{/* 계약 */}
<Route path="contracts" element={<ContractList />} /> <Route path="contracts" element={<ContractList />} />
{/* 원장 */}
<Route path="ledger/recruit" element={<RecruitLedger />} /> <Route path="ledger/recruit" element={<RecruitLedger />} />
<Route path="ledger/exception" element={<ExceptionLedger />} />
{/* 정산 / 지급 / 배치 */}
<Route path="settle" element={<SettleList />} /> <Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="payments" element={<PaymentList />} />
{/* 시스템관리 */}
<Route path="system/users" element={<UserList />} />
<Route path="system/roles" element={<RoleList />} />
<Route path="system/codes" element={<CodeList />} />
</Route> </Route>
</Routes> </Routes>
); );
+50
View File
@@ -0,0 +1,50 @@
import api, { PageResponse, unwrap } from './request';
export interface ExceptionLedgerResp {
ledgerId: number;
agentId: number;
agentName: string;
orgName?: string;
exceptionCode: string;
exceptionName?: string;
direction: 'PLUS' | 'MINUS';
policyNo?: string;
companyCode?: string;
insuranceType?: string;
amount: number;
taxAmount?: number;
settleMonth: string;
description?: string;
approveStatus: string;
approveStatusName?: string;
approverId?: number;
approverName?: string;
approveDate?: string;
recurringYn?: string;
recurringLeft?: number;
status: string;
}
export interface ExceptionLedgerSaveReq {
agentId: number;
exceptionCode: string;
settleMonth: string;
amount: number;
policyNo?: string;
companyCode?: string;
insuranceType?: string;
description?: string;
recurringYn?: string;
recurringMonths?: number;
}
export const exceptionApi = {
list: (p: { settleMonth?: string; agentId?: number; status?: string; pageNum?: number; pageSize?: number }) =>
unwrap<PageResponse<ExceptionLedgerResp>>(api.get('/api/ledger/exception', { params: p })),
detail: (id: number) =>
unwrap<ExceptionLedgerResp>(api.get(`/api/ledger/exception/${id}`)),
create: (req: ExceptionLedgerSaveReq) =>
unwrap<number>(api.post('/api/ledger/exception', req)),
approve: (id: number, status: 'APPROVED' | 'REJECTED') =>
unwrap<void>(api.put(`/api/ledger/exception/${id}/approve`, { status })),
};
+24
View File
@@ -0,0 +1,24 @@
import api, { PageResponse, unwrap } from './request';
export interface PaymentResp {
paymentId: number;
settleId: number;
agentId: number;
agentName: string;
bankCode?: string;
bankName?: string;
accountNo?: string;
payAmount: number;
payDate?: string;
payStatus: string;
payStatusName?: string;
failReason?: string;
settleMonth: string;
}
export const paymentApi = {
list: (p: { settleMonth?: string; agentId?: number; status?: string; pageNum?: number; pageSize?: number }) =>
unwrap<PageResponse<PaymentResp>>(api.get('/api/payments', { params: p })),
updateStatus: (paymentId: number, status: string, failReason?: string) =>
unwrap<void>(api.put(`/api/payments/${paymentId}/status`, { status, failReason })),
};
+95
View File
@@ -0,0 +1,95 @@
import api, { PageResponse, unwrap } from './request';
export interface UserResp {
userId: number;
loginId: string;
userName: string;
email?: string;
phone?: string;
agentId?: number;
agentName?: string;
orgName?: string;
status: string;
failCount?: number;
lastLoginAt?: string;
createdAt?: string;
roleCodes?: string[];
}
export interface UserSaveReq {
loginId: string;
userName: string;
email?: string;
phone?: string;
agentId?: number;
status?: string;
password?: string;
roleIds?: number[];
}
export const userApi = {
list: (p: { searchKeyword?: string; status?: string; pageNum?: number; pageSize?: number }) =>
unwrap<PageResponse<UserResp>>(api.get('/api/system/users', { params: p })),
detail: (id: number) => unwrap<UserResp>(api.get(`/api/system/users/${id}`)),
create: (req: UserSaveReq) => unwrap<number>(api.post('/api/system/users', req)),
update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)),
resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)),
unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)),
};
export interface RoleVO {
roleId: number;
roleCode: string;
roleName: string;
roleDesc?: string;
roleLevel: number;
isSystem: string;
isActive: string;
}
export const roleApi = {
list: () => unwrap<RoleVO[]>(api.get('/api/system/roles')),
detail: (id: number) => unwrap<RoleVO>(api.get(`/api/system/roles/${id}`)),
permissions: (id: number) =>
unwrap<Array<{ menuId: number; permCode: string; isGranted: string }>>(api.get(`/api/system/roles/${id}/permissions`)),
create: (vo: Partial<RoleVO>) => unwrap<number>(api.post('/api/system/roles', vo)),
update: (id: number, vo: Partial<RoleVO>) => unwrap<void>(api.put(`/api/system/roles/${id}`, vo)),
savePermissions: (id: number, perms: Array<{ menuId: number; permCode: string; isGranted: string }>) =>
unwrap<void>(api.put(`/api/system/roles/${id}/permissions`, perms)),
};
export interface CommonCodeGroup {
groupCode: string;
groupName: string;
description?: string;
isSystem: string;
isActive: string;
sortOrder: number;
}
export interface CommonCode {
codeId: number;
groupCode: string;
code: string;
codeName: string;
codeDesc?: string;
sortOrder: number;
isActive: string;
}
export const commonCodeApi = {
groups: (keyword?: string) =>
unwrap<CommonCodeGroup[]>(api.get('/api/common/codes/groups', { params: { keyword } })),
codes: (groupCode: string) =>
unwrap<CommonCode[]>(api.get(`/api/common/codes/groups/${groupCode}/codes`)),
createGroup: (vo: Partial<CommonCodeGroup>) =>
unwrap<void>(api.post('/api/common/codes/groups', vo)),
updateGroup: (groupCode: string, vo: Partial<CommonCodeGroup>) =>
unwrap<void>(api.put(`/api/common/codes/groups/${groupCode}`, vo)),
createCode: (groupCode: string, vo: Partial<CommonCode>) =>
unwrap<void>(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)),
updateCode: (codeId: number, vo: Partial<CommonCode>) =>
unwrap<void>(api.put(`/api/common/codes/codes/${codeId}`, vo)),
deleteCode: (codeId: number) =>
unwrap<void>(api.delete(`/api/common/codes/codes/${codeId}`)),
};
@@ -0,0 +1,82 @@
import { useEffect, useMemo, useRef } from 'react';
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 '@ag-grid-community/styles/ag-grid.css';
import '@ag-grid-community/styles/ag-theme-quartz.css';
ModuleRegistry.registerModules([ClientSideRowModelModule]);
interface Props<T> {
rows: T[] | undefined;
columns: ColDef<T>[];
loading?: boolean;
height?: number | string;
/** 합계행 데이터 */
pinnedBottomRow?: Partial<T>;
/** 셀 편집 가능 시 콜백 */
onCellChanged?: (row: T, field: string, newValue: unknown) => void;
rowKey?: keyof T;
}
/**
* AG Grid Community 클라이언트사이드 래퍼.
* - 100건 이상 / 셀 편집 / 합계행이 필요한 화면용
* - 서버사이드는 별도 SSRM 모듈 필요 (Community 한정 → enterprise만 지원)
* 여기서는 현실적인 절충안으로 클라이언트사이드 + 외부 페이징 사용
*/
export default function DataGrid<T extends Record<string, unknown>>({
rows,
columns,
loading,
height = 600,
pinnedBottomRow,
onCellChanged,
rowKey,
}: Props<T>) {
const apiRef = useRef<GridApi<T> | null>(null);
const defaultColDef = useMemo<ColDef>(
() => ({
sortable: true,
filter: true,
resizable: true,
suppressMovable: false,
}),
[],
);
const onGridReady = (e: GridReadyEvent<T>) => {
apiRef.current = e.api;
};
useEffect(() => {
if (apiRef.current) {
if (loading) apiRef.current.showLoadingOverlay();
else apiRef.current.hideOverlay();
}
}, [loading]);
return (
<div className="ag-theme-quartz" style={{ height, width: '100%' }}>
<AgGridReact<T>
rowData={rows}
columnDefs={columns}
defaultColDef={defaultColDef}
animateRows
rowSelection="single"
getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined}
pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined}
onCellValueChanged={(e) => {
if (onCellChanged && e.data) {
onCellChanged(e.data, String(e.colDef.field), e.newValue);
}
}}
onGridReady={onGridReady}
loadingOverlayComponent={() => <span> ...</span>}
noRowsOverlayComponent={() => <span> </span>}
/>
</div>
);
}
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Table, Space, message, Modal } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import { exceptionApi, ExceptionLedgerResp } from '@/api/exceptionLedger';
export default function ExceptionLedger() {
const qc = useQueryClient();
const [param, setParam] = useState<{ settleMonth?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const { data, isLoading } = useQuery({
queryKey: ['ledger', 'exception', param],
queryFn: () => exceptionApi.list(param),
});
const refresh = () => qc.invalidateQueries({ queryKey: ['ledger', 'exception'] });
const onApprove = (id: number, status: 'APPROVED' | 'REJECTED') =>
Modal.confirm({
title: status === 'APPROVED' ? '승인하시겠습니까?' : '반려하시겠습니까?',
onOk: async () => {
await exceptionApi.approve(id, status);
message.success(status === 'APPROVED' ? '승인 완료' : '반려 완료');
refresh();
},
});
return (
<PageContainer title="예외금액 원장">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '승인상태', groupCode: 'APPROVE_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
<Table<ExceptionLedgerResp>
rowKey="ledgerId"
loading={isLoading}
dataSource={data?.list}
size="small"
scroll={{ x: 1400 }}
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '예외코드', dataIndex: 'exceptionName', width: 120 },
{
title: '구분', dataIndex: 'direction', width: 80,
render: (v) => <CodeBadge groupCode="EXCEPTION_DIRECTION" value={v} />,
},
{
title: '금액', dataIndex: 'amount', width: 130, align: 'right',
render: (v, r) => <MoneyText value={r.direction === 'MINUS' ? -Math.abs(v) : v} />,
},
{ title: '내용', dataIndex: 'description', ellipsis: true },
{ title: '재반복', dataIndex: 'recurringLeft', width: 90, align: 'center',
render: (v, r) => r.recurringYn === 'Y' ? `${v}회 남음` : '-' },
{
title: '승인상태', dataIndex: 'approveStatus', width: 100,
render: (v) => <CodeBadge groupCode="APPROVE_STATUS" value={v} />,
},
{ title: '승인자', dataIndex: 'approverName', width: 100 },
{ title: '승인일시', dataIndex: 'approveDate', width: 160 },
{
title: '액션', dataIndex: 'ledgerId', width: 180, fixed: 'right',
render: (id, r) =>
r.approveStatus === 'PENDING' ? (
<Space>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
type="primary" onClick={() => onApprove(id, 'APPROVED')}>
</PermissionButton>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
danger onClick={() => onApprove(id, 'REJECTED')}>
</PermissionButton>
</Space>
) : null,
},
]}
/>
</PageContainer>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Button, Card, Col, Descriptions, Row, Space, Statistic } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import { agentApi } from '@/api/agent';
export default function AgentDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
queryKey: ['agent', 'detail', id],
queryFn: () => agentApi.detail(Number(id)),
enabled: !!id,
});
return (
<PageContainer
title={data ? `설계사 — ${data.agentName}` : '설계사 상세'}
extra={
<Space>
<PermissionButton menuCode="ORG_AGENT" permCode="UPDATE" type="primary"
onClick={() => navigate(`/org/agents/${id}/edit`)}>
</PermissionButton>
<Button onClick={() => navigate(-1)}></Button>
</Space>
}
>
{isLoading || !data ? null : (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}><Card><Statistic title="계약 수" value={data.contractCount ?? 0} suffix="건" /></Card></Col>
<Col span={6}>
<Card>
<Statistic title="누적 수수료" valueRender={() =>
<MoneyText value={data.totalCommission} />
} />
</Card>
</Col>
<Col span={6}><Card><Statistic title="소속" value={data.orgName ?? '-'} /></Card></Col>
<Col span={6}><Card><Statistic title="직급" value={data.gradeName ?? '-'} /></Card></Col>
</Row>
<Card title="기본 정보">
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="설계사명">{data.agentName}</Descriptions.Item>
<Descriptions.Item label="사번">{data.licenseNo ?? '-'}</Descriptions.Item>
<Descriptions.Item label="전화번호">{data.phone ?? '-'}</Descriptions.Item>
<Descriptions.Item label="이메일">{data.email ?? '-'}</Descriptions.Item>
<Descriptions.Item label="입사일">{data.joinDate ?? '-'}</Descriptions.Item>
<Descriptions.Item label="상태">
<CodeBadge groupCode="AGENT_STATUS" value={data.status} />
</Descriptions.Item>
</Descriptions>
</Card>
</>
)}
</PageContainer>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect } from 'react';
import { Button, Card, Col, DatePicker, Form, Input, message, Row, Space } from 'antd';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import CodeSelect from '@/components/common/CodeSelect';
import { agentApi, AgentSaveReq } from '@/api/agent';
export default function AgentForm() {
const { id } = useParams<{ id: string }>();
const isEdit = id && id !== 'new';
const navigate = useNavigate();
const [form] = Form.useForm();
const { data } = useQuery({
queryKey: ['agent', 'detail', id],
queryFn: () => agentApi.detail(Number(id)),
enabled: !!isEdit,
});
useEffect(() => {
if (data) {
form.setFieldsValue({
...data,
joinDate: data.joinDate ? dayjs(data.joinDate) : null,
});
}
}, [data, form]);
const onFinish = async (values: any) => {
const req: AgentSaveReq = {
...values,
joinDate: values.joinDate ? values.joinDate.format('YYYY-MM-DD') : undefined,
};
try {
if (isEdit) {
await agentApi.update(Number(id), req);
message.success('수정 완료');
} else {
await agentApi.create(req);
message.success('등록 완료');
}
navigate('/org/agents');
} catch {
// 인터셉터에서 처리
}
};
return (
<PageContainer title={isEdit ? '설계사 수정' : '설계사 등록'}>
<Card>
<Form form={form} layout="vertical" onFinish={onFinish}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="설계사명" name="agentName" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="사번" name="licenseNo">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="소속 ID" name="orgId" rules={[{ required: true }]}>
<Input type="number" placeholder="조직 ID" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="직급 ID" name="gradeId" rules={[{ required: true }]}>
<Input type="number" placeholder="직급 ID" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="전화번호" name="phone">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="이메일" name="email" rules={[{ type: 'email' }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="주민번호" name="residentNo" extra="저장 시 자동 암호화">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="은행" name="bankCode">
<CodeSelect groupCode="BANK_CODE" />
</Form.Item>
</Col>
<Col span={18}>
<Form.Item label="계좌번호" name="accountNo" extra="저장 시 자동 암호화">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="입사일" name="joinDate">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">{isEdit ? '수정' : '등록'}</Button>
<Button onClick={() => navigate(-1)}></Button>
</Space>
</Form>
</Card>
</PageContainer>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Button, Card, DatePicker, Descriptions, message, Progress, Space } from 'antd';
import { useQuery } from '@tanstack/react-query';
import dayjs, { Dayjs } from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import { batchApi } from '@/api/settle';
export default function BatchRun() {
const [settleMonth, setSettleMonth] = useState<Dayjs | null>(dayjs());
const { data: status, refetch } = useQuery({
queryKey: ['batch', 'status'],
queryFn: batchApi.status,
refetchInterval: (q) => {
const s = (q.state.data as any)?.status;
return s === 'STARTED' || s === 'RUNNING' ? 3000 : false;
},
});
const onRun = async () => {
if (!settleMonth) { message.warning('정산월을 선택하세요'); return; }
try {
await batchApi.run(settleMonth.format('YYYYMM'));
message.success('배치 실행 요청됨');
refetch();
} catch {
// 인터셉터에서 처리
}
};
return (
<PageContainer title="정산 배치 실행">
<Card style={{ marginBottom: 16 }}>
<Space>
<DatePicker picker="month" value={settleMonth} onChange={setSettleMonth} />
<Button type="primary" onClick={onRun}> </Button>
<Button onClick={() => refetch()}> </Button>
</Space>
</Card>
{status && (
<Card title="최근 배치 상태">
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="작업명">{status.jobName}</Descriptions.Item>
<Descriptions.Item label="정산월">{status.settleMonth}</Descriptions.Item>
<Descriptions.Item label="상태"><CodeBadge groupCode="SETTLE_STATUS" value={status.status} /></Descriptions.Item>
<Descriptions.Item label="현재 Step">{status.currentStep ?? '-'}</Descriptions.Item>
<Descriptions.Item label="시작 시각">{status.startedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="종료 시각">{status.finishedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="소요 시간">{status.durationSec ? `${status.durationSec}` : '-'}</Descriptions.Item>
<Descriptions.Item label="에러">{status.errorMessage ?? '-'}</Descriptions.Item>
</Descriptions>
{(status.progressPct ?? 0) > 0 && (
<Progress
percent={status.progressPct}
status={status.status === 'FAILED' ? 'exception' : status.status === 'COMPLETED' ? 'success' : 'active'}
style={{ marginTop: 16 }}
/>
)}
</Card>
)}
</PageContainer>
);
}
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { paymentApi, PaymentResp } from '@/api/payment';
export default function PaymentList() {
const [param, setParam] = useState<{ settleMonth?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const { data, isLoading } = useQuery({
queryKey: ['payment', 'list', param],
queryFn: () => paymentApi.list(param),
});
return (
<PageContainer title="지급 관리">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '지급상태', groupCode: 'PAYMENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
<Table<PaymentResp>
rowKey="paymentId"
loading={isLoading}
dataSource={data?.list}
size="small"
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '은행', dataIndex: 'bankName', width: 100 },
{ title: '계좌', dataIndex: 'accountNo', width: 180 },
{
title: '지급액', dataIndex: 'payAmount', width: 140, align: 'right',
render: (v) => <strong><MoneyText value={v} /></strong>,
},
{ title: '지급일', dataIndex: 'payDate', width: 110 },
{
title: '상태', dataIndex: 'payStatus', width: 100,
render: (v) => <CodeBadge groupCode="PAYMENT_STATUS" value={v} />,
},
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true },
]}
/>
</PageContainer>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { Card, Col, Empty, Row, Table, Tag, Typography } from 'antd';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import { commonCodeApi, CommonCodeGroup } from '@/api/user';
const { Text } = Typography;
export default function CodeList() {
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
const { data: groups = [] } = useQuery({
queryKey: ['code', 'groups'],
queryFn: () => commonCodeApi.groups(),
});
const { data: codes = [] } = useQuery({
queryKey: ['code', 'codes', selected?.groupCode],
queryFn: () => commonCodeApi.codes(selected!.groupCode),
enabled: !!selected,
});
return (
<PageContainer title="공통코드 관리">
<Row gutter={16}>
<Col span={10}>
<Card title="그룹" size="small">
<Table
rowKey="groupCode"
dataSource={groups}
size="small"
pagination={false}
scroll={{ y: 600 }}
onRow={(r) => ({ onClick: () => setSelected(r), style: { cursor: 'pointer' } })}
rowClassName={(r) => r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''}
columns={[
{ title: '그룹코드', dataIndex: 'groupCode', width: 160 },
{ title: '그룹명', dataIndex: 'groupName' },
{
title: '시스템', dataIndex: 'isSystem', width: 80, align: 'center',
render: (v) => v === 'Y' ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
]}
/>
</Card>
</Col>
<Col span={14}>
<Card title={selected ? `상세 코드 — ${selected.groupName}` : '상세 코드'} size="small">
{!selected ? (
<Empty description="그룹을 선택하세요" />
) : (
<Table
rowKey="codeId"
dataSource={codes}
size="small"
pagination={false}
scroll={{ y: 600 }}
columns={[
{ title: '코드', dataIndex: 'code', width: 120 },
{ title: '코드명', dataIndex: 'codeName' },
{ title: '설명', dataIndex: 'codeDesc' },
{ title: '정렬', dataIndex: 'sortOrder', width: 60, align: 'right' },
{
title: '활성', dataIndex: 'isActive', width: 70, align: 'center',
render: (v) => v === 'Y' ? <Tag color="green">Y</Tag> : <Tag>N</Tag>,
},
]}
/>
)}
{selected?.isSystem === 'Y' && (
<Text type="warning" style={{ display: 'block', marginTop: 8 }}>
/ .
</Text>
)}
</Card>
</Col>
</Row>
</PageContainer>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useState } from 'react';
import { Card, Checkbox, Col, Empty, message, Row, Space, Table, Typography, Button } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import { roleApi, RoleVO } from '@/api/user';
import { menuApi, MenuResp } from '@/api/menu';
const { Title } = Typography;
const PERMS = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'EXPORT'] as const;
type PermKey = string;
export default function RoleList() {
const qc = useQueryClient();
const [selected, setSelected] = useState<RoleVO | null>(null);
const [granted, setGranted] = useState<Set<PermKey>>(new Set());
const { data: roles = [] } = useQuery({ queryKey: ['role', 'list'], queryFn: roleApi.list });
const { data: tree = [] } = useQuery({ queryKey: ['menu', 'tree'], queryFn: menuApi.tree });
const flatPages = flattenPages(tree);
const selectRole = async (r: RoleVO) => {
setSelected(r);
const perms = await roleApi.permissions(r.roleId);
const set = new Set<PermKey>();
perms.forEach((p) => p.isGranted === 'Y' && set.add(`${p.menuId}:${p.permCode}`));
setGranted(set);
};
const toggle = (menuId: number, permCode: string) => {
const key = `${menuId}:${permCode}`;
const next = new Set(granted);
next.has(key) ? next.delete(key) : next.add(key);
setGranted(next);
};
const save = async () => {
if (!selected) return;
const list = Array.from(granted).map((k) => {
const [menuId, permCode] = k.split(':');
return { menuId: Number(menuId), permCode, isGranted: 'Y' };
});
await roleApi.savePermissions(selected.roleId, list);
message.success('권한 매트릭스 저장 완료');
qc.invalidateQueries({ queryKey: ['menu'] });
};
return (
<PageContainer title="역할 / 권한">
<Row gutter={16}>
<Col span={8}>
<Card title="역할 목록" size="small">
<Table<RoleVO>
rowKey="roleId"
dataSource={roles}
size="small"
pagination={false}
onRow={(r) => ({ onClick: () => selectRole(r), style: { cursor: 'pointer' } })}
rowClassName={(r) => r.roleId === selected?.roleId ? 'ant-table-row-selected' : ''}
columns={[
{ title: '코드', dataIndex: 'roleCode', width: 120 },
{ title: '이름', dataIndex: 'roleName' },
{ title: '레벨', dataIndex: 'roleLevel', width: 60, align: 'right' },
]}
/>
</Card>
</Col>
<Col span={16}>
<Card
title={selected ? `권한 매트릭스 — ${selected.roleName}` : '권한 매트릭스'}
size="small"
extra={selected && <Button type="primary" onClick={save}></Button>}
>
{!selected ? (
<Empty description="역할을 선택하세요" />
) : (
<Table
size="small"
pagination={false}
rowKey="menuId"
dataSource={flatPages}
scroll={{ y: 500 }}
columns={[
{ title: '메뉴', dataIndex: 'menuName', width: 200 },
{ title: '코드', dataIndex: 'menuCode', width: 160 },
...PERMS.map((p) => ({
title: p, key: p, width: 80, align: 'center' as const,
render: (_: unknown, m: MenuResp) => (
<Checkbox
checked={granted.has(`${m.menuId}:${p}`)}
onChange={() => toggle(m.menuId, p)}
/>
),
})),
]}
/>
)}
</Card>
</Col>
</Row>
</PageContainer>
);
}
function flattenPages(nodes: MenuResp[]): MenuResp[] {
const out: MenuResp[] = [];
const visit = (ns: MenuResp[]) => {
for (const n of ns) {
if (n.menuType === 'PAGE') out.push(n);
if (n.children?.length) visit(n.children);
}
};
visit(nodes);
return out;
}
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { Table, Space, message, Modal } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import PermissionButton from '@/components/common/PermissionButton';
import { userApi, UserResp } from '@/api/user';
export default function UserList() {
const qc = useQueryClient();
const [param, setParam] = useState<{ searchKeyword?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const { data, isLoading } = useQuery({
queryKey: ['user', 'list', param],
queryFn: () => userApi.list(param),
});
const refresh = () => qc.invalidateQueries({ queryKey: ['user'] });
const onReset = (id: number) =>
Modal.confirm({
title: '비밀번호 초기화',
content: '이 사용자의 비밀번호를 임시 비밀번호로 재설정합니다. 진행하시겠습니까?',
onOk: async () => { await userApi.resetPassword(id); message.success('초기화 완료'); refresh(); },
});
const onUnlock = (id: number) =>
Modal.confirm({
title: '계정 잠금 해제',
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
});
return (
<PageContainer title="사용자 관리">
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '아이디/이름' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
<Table<UserResp>
rowKey="userId"
loading={isLoading}
dataSource={data?.list}
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '로그인 ID', dataIndex: 'loginId', width: 120 },
{ title: '이름', dataIndex: 'userName', width: 120 },
{ title: '이메일', dataIndex: 'email', width: 200 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '연결 설계사', dataIndex: 'agentName', width: 100 },
{
title: '상태', dataIndex: 'status', width: 100,
render: (v) => <CodeBadge groupCode="AGENT_STATUS" value={v} />,
},
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right' },
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160 },
{
title: '액션', width: 220, fixed: 'right',
render: (_, r) => (
<Space>
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
onClick={() => onReset(r.userId)}></PermissionButton>
{r.status === 'LOCKED' && (
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small" type="primary"
onClick={() => onUnlock(r.userId)}></PermissionButton>
)}
</Space>
),
},
]}
/>
</PageContainer>
);
}