diff --git a/ga-api/src/main/resources/application-trading_ai.yml b/ga-api/src/main/resources/application-trading_ai.yml
new file mode 100644
index 0000000..7ce0cca
--- /dev/null
+++ b/ga-api/src/main/resources/application-trading_ai.yml
@@ -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}
diff --git a/ga-batch/src/main/resources/application-trading_ai.yml b/ga-batch/src/main/resources/application-trading_ai.yml
new file mode 100644
index 0000000..ed54483
--- /dev/null
+++ b/ga-batch/src/main/resources/application-trading_ai.yml
@@ -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}
diff --git a/ga-common/src/main/resources/db/migration/V17__테스트데이터.sql b/ga-common/src/main/resources/db/migration/V17__테스트데이터.sql
new file mode 100644
index 0000000..2dcbd3e
--- /dev/null
+++ b/ga-common/src/main/resources/db/migration/V17__테스트데이터.sql
@@ -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;
diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx
index 8b15052..22848e5 100644
--- a/ga-frontend/src/App.tsx
+++ b/ga-frontend/src/App.tsx
@@ -2,10 +2,19 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
+
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 RecruitLedger from '@/pages/ledger/RecruitLedger';
+import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
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 }) {
const token = localStorage.getItem('accessToken');
@@ -18,10 +27,29 @@ export default function App() {
} />
}>
} />
- } />
- } />
- } />
- } />
+
+ {/* 조직 / 설계사 */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* 계약 */}
+ } />
+
+ {/* 원장 */}
+ } />
+ } />
+
+ {/* 정산 / 지급 / 배치 */}
+ } />
+ } />
+ } />
+
+ {/* 시스템관리 */}
+ } />
+ } />
+ } />
);
diff --git a/ga-frontend/src/api/exceptionLedger.ts b/ga-frontend/src/api/exceptionLedger.ts
new file mode 100644
index 0000000..8791ee0
--- /dev/null
+++ b/ga-frontend/src/api/exceptionLedger.ts
@@ -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>(api.get('/api/ledger/exception', { params: p })),
+ detail: (id: number) =>
+ unwrap(api.get(`/api/ledger/exception/${id}`)),
+ create: (req: ExceptionLedgerSaveReq) =>
+ unwrap(api.post('/api/ledger/exception', req)),
+ approve: (id: number, status: 'APPROVED' | 'REJECTED') =>
+ unwrap(api.put(`/api/ledger/exception/${id}/approve`, { status })),
+};
diff --git a/ga-frontend/src/api/payment.ts b/ga-frontend/src/api/payment.ts
new file mode 100644
index 0000000..103ece1
--- /dev/null
+++ b/ga-frontend/src/api/payment.ts
@@ -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>(api.get('/api/payments', { params: p })),
+ updateStatus: (paymentId: number, status: string, failReason?: string) =>
+ unwrap(api.put(`/api/payments/${paymentId}/status`, { status, failReason })),
+};
diff --git a/ga-frontend/src/api/user.ts b/ga-frontend/src/api/user.ts
new file mode 100644
index 0000000..3522251
--- /dev/null
+++ b/ga-frontend/src/api/user.ts
@@ -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>(api.get('/api/system/users', { params: p })),
+ detail: (id: number) => unwrap(api.get(`/api/system/users/${id}`)),
+ create: (req: UserSaveReq) => unwrap(api.post('/api/system/users', req)),
+ update: (id: number, req: UserSaveReq) => unwrap(api.put(`/api/system/users/${id}`, req)),
+ resetPassword: (id: number) => unwrap(api.put(`/api/system/users/${id}/reset-password`)),
+ unlock: (id: number) => unwrap(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(api.get('/api/system/roles')),
+ detail: (id: number) => unwrap(api.get(`/api/system/roles/${id}`)),
+ permissions: (id: number) =>
+ unwrap>(api.get(`/api/system/roles/${id}/permissions`)),
+ create: (vo: Partial) => unwrap(api.post('/api/system/roles', vo)),
+ update: (id: number, vo: Partial) => unwrap(api.put(`/api/system/roles/${id}`, vo)),
+ savePermissions: (id: number, perms: Array<{ menuId: number; permCode: string; isGranted: string }>) =>
+ unwrap(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(api.get('/api/common/codes/groups', { params: { keyword } })),
+ codes: (groupCode: string) =>
+ unwrap(api.get(`/api/common/codes/groups/${groupCode}/codes`)),
+ createGroup: (vo: Partial) =>
+ unwrap(api.post('/api/common/codes/groups', vo)),
+ updateGroup: (groupCode: string, vo: Partial) =>
+ unwrap(api.put(`/api/common/codes/groups/${groupCode}`, vo)),
+ createCode: (groupCode: string, vo: Partial) =>
+ unwrap(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)),
+ updateCode: (codeId: number, vo: Partial) =>
+ unwrap(api.put(`/api/common/codes/codes/${codeId}`, vo)),
+ deleteCode: (codeId: number) =>
+ unwrap(api.delete(`/api/common/codes/codes/${codeId}`)),
+};
diff --git a/ga-frontend/src/components/common/DataGrid.tsx b/ga-frontend/src/components/common/DataGrid.tsx
new file mode 100644
index 0000000..8281cf4
--- /dev/null
+++ b/ga-frontend/src/components/common/DataGrid.tsx
@@ -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 {
+ rows: T[] | undefined;
+ columns: ColDef[];
+ loading?: boolean;
+ height?: number | string;
+ /** 합계행 데이터 */
+ pinnedBottomRow?: Partial;
+ /** 셀 편집 가능 시 콜백 */
+ onCellChanged?: (row: T, field: string, newValue: unknown) => void;
+ rowKey?: keyof T;
+}
+
+/**
+ * AG Grid Community 클라이언트사이드 래퍼.
+ * - 100건 이상 / 셀 편집 / 합계행이 필요한 화면용
+ * - 서버사이드는 별도 SSRM 모듈 필요 (Community 한정 → enterprise만 지원)
+ * 여기서는 현실적인 절충안으로 클라이언트사이드 + 외부 페이징 사용
+ */
+export default function DataGrid>({
+ rows,
+ columns,
+ loading,
+ height = 600,
+ pinnedBottomRow,
+ onCellChanged,
+ rowKey,
+}: Props) {
+ const apiRef = useRef | null>(null);
+
+ const defaultColDef = useMemo(
+ () => ({
+ sortable: true,
+ filter: true,
+ resizable: true,
+ suppressMovable: false,
+ }),
+ [],
+ );
+
+ const onGridReady = (e: GridReadyEvent) => {
+ apiRef.current = e.api;
+ };
+
+ useEffect(() => {
+ if (apiRef.current) {
+ if (loading) apiRef.current.showLoadingOverlay();
+ else apiRef.current.hideOverlay();
+ }
+ }, [loading]);
+
+ return (
+
+
+ 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={() => 불러오는 중...}
+ noRowsOverlayComponent={() => 데이터가 없습니다}
+ />
+
+ );
+}
diff --git a/ga-frontend/src/pages/ledger/ExceptionLedger.tsx b/ga-frontend/src/pages/ledger/ExceptionLedger.tsx
new file mode 100644
index 0000000..ae228f0
--- /dev/null
+++ b/ga-frontend/src/pages/ledger/ExceptionLedger.tsx
@@ -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 (
+
+ setParam({ ...param, ...v, pageNum: 1 })}
+ onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
+ />
+
+
+ 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) => ,
+ },
+ {
+ title: '금액', dataIndex: 'amount', width: 130, align: 'right',
+ render: (v, r) => ,
+ },
+ { 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) => ,
+ },
+ { title: '승인자', dataIndex: 'approverName', width: 100 },
+ { title: '승인일시', dataIndex: 'approveDate', width: 160 },
+ {
+ title: '액션', dataIndex: 'ledgerId', width: 180, fixed: 'right',
+ render: (id, r) =>
+ r.approveStatus === 'PENDING' ? (
+
+ onApprove(id, 'APPROVED')}>
+ 승인
+
+ onApprove(id, 'REJECTED')}>
+ 반려
+
+
+ ) : null,
+ },
+ ]}
+ />
+
+ );
+}
diff --git a/ga-frontend/src/pages/org/AgentDetail.tsx b/ga-frontend/src/pages/org/AgentDetail.tsx
new file mode 100644
index 0000000..7e6a630
--- /dev/null
+++ b/ga-frontend/src/pages/org/AgentDetail.tsx
@@ -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 (
+
+ navigate(`/org/agents/${id}/edit`)}>
+ 수정
+
+
+
+ }
+ >
+ {isLoading || !data ? null : (
+ <>
+
+
+
+
+
+
+ } />
+
+
+
+
+
+
+
+
+ {data.agentName}
+ {data.licenseNo ?? '-'}
+ {data.phone ?? '-'}
+ {data.email ?? '-'}
+ {data.joinDate ?? '-'}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/ga-frontend/src/pages/org/AgentForm.tsx b/ga-frontend/src/pages/org/AgentForm.tsx
new file mode 100644
index 0000000..36bee5c
--- /dev/null
+++ b/ga-frontend/src/pages/org/AgentForm.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/ga-frontend/src/pages/settle/BatchRun.tsx b/ga-frontend/src/pages/settle/BatchRun.tsx
new file mode 100644
index 0000000..5f798e5
--- /dev/null
+++ b/ga-frontend/src/pages/settle/BatchRun.tsx
@@ -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());
+
+ 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 (
+
+
+
+
+
+
+
+
+
+ {status && (
+
+
+ {status.jobName}
+ {status.settleMonth}
+
+ {status.currentStep ?? '-'}
+ {status.startedAt ?? '-'}
+ {status.finishedAt ?? '-'}
+ {status.durationSec ? `${status.durationSec}초` : '-'}
+ {status.errorMessage ?? '-'}
+
+ {(status.progressPct ?? 0) > 0 && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/ga-frontend/src/pages/settle/PaymentList.tsx b/ga-frontend/src/pages/settle/PaymentList.tsx
new file mode 100644
index 0000000..110298a
--- /dev/null
+++ b/ga-frontend/src/pages/settle/PaymentList.tsx
@@ -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 (
+
+ setParam({ ...param, ...v, pageNum: 1 })}
+ onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
+ />
+
+
+ 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) => ,
+ },
+ { title: '지급일', dataIndex: 'payDate', width: 110 },
+ {
+ title: '상태', dataIndex: 'payStatus', width: 100,
+ render: (v) => ,
+ },
+ { title: '실패사유', dataIndex: 'failReason', ellipsis: true },
+ ]}
+ />
+
+ );
+}
diff --git a/ga-frontend/src/pages/system/CodeList.tsx b/ga-frontend/src/pages/system/CodeList.tsx
new file mode 100644
index 0000000..454bd19
--- /dev/null
+++ b/ga-frontend/src/pages/system/CodeList.tsx
@@ -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(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 (
+
+
+
+
+ ({ 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' ? 시스템 : 일반,
+ },
+ ]}
+ />
+
+
+
+
+ {!selected ? (
+
+ ) : (
+ v === 'Y' ? Y : N,
+ },
+ ]}
+ />
+ )}
+ {selected?.isSystem === 'Y' && (
+
+ ⚠ 시스템 그룹의 코드는 식별자 변경/삭제가 제한됩니다.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ga-frontend/src/pages/system/RoleList.tsx b/ga-frontend/src/pages/system/RoleList.tsx
new file mode 100644
index 0000000..71afd65
--- /dev/null
+++ b/ga-frontend/src/pages/system/RoleList.tsx
@@ -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(null);
+ const [granted, setGranted] = useState>(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();
+ 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 (
+
+
+
+
+
+ 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' },
+ ]}
+ />
+
+
+
+ 저장}
+ >
+ {!selected ? (
+
+ ) : (
+ ({
+ title: p, key: p, width: 80, align: 'center' as const,
+ render: (_: unknown, m: MenuResp) => (
+ toggle(m.menuId, p)}
+ />
+ ),
+ })),
+ ]}
+ />
+ )}
+
+
+
+
+ );
+}
+
+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;
+}
diff --git a/ga-frontend/src/pages/system/UserList.tsx b/ga-frontend/src/pages/system/UserList.tsx
new file mode 100644
index 0000000..f94cc50
--- /dev/null
+++ b/ga-frontend/src/pages/system/UserList.tsx
@@ -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 (
+
+ setParam({ ...param, ...v, pageNum: 1 })}
+ onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
+ />
+
+
+ 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) => ,
+ },
+ { title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right' },
+ { title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160 },
+ {
+ title: '액션', width: 220, fixed: 'right',
+ render: (_, r) => (
+
+ onReset(r.userId)}>비번초기화
+ {r.status === 'LOCKED' && (
+ onUnlock(r.userId)}>잠금해제
+ )}
+
+ ),
+ },
+ ]}
+ />
+
+ );
+}