From 92079d2385748c0b199402eeefe709ff5f2c5c25 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sat, 9 May 2026 22:47:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0(V17)=20+=20=EC=8B=A4=20DB=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC=20+=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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} --- .../main/resources/application-trading_ai.yml | 27 +++ .../main/resources/application-trading_ai.yml | 14 ++ .../db/migration/V17__테스트데이터.sql | 191 ++++++++++++++++++ ga-frontend/src/App.tsx | 36 +++- ga-frontend/src/api/exceptionLedger.ts | 50 +++++ ga-frontend/src/api/payment.ts | 24 +++ ga-frontend/src/api/user.ts | 95 +++++++++ .../src/components/common/DataGrid.tsx | 82 ++++++++ .../src/pages/ledger/ExceptionLedger.tsx | 98 +++++++++ ga-frontend/src/pages/org/AgentDetail.tsx | 64 ++++++ ga-frontend/src/pages/org/AgentForm.tsx | 115 +++++++++++ ga-frontend/src/pages/settle/BatchRun.tsx | 65 ++++++ ga-frontend/src/pages/settle/PaymentList.tsx | 60 ++++++ ga-frontend/src/pages/system/CodeList.tsx | 80 ++++++++ ga-frontend/src/pages/system/RoleList.tsx | 116 +++++++++++ ga-frontend/src/pages/system/UserList.tsx | 84 ++++++++ 16 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 ga-api/src/main/resources/application-trading_ai.yml create mode 100644 ga-batch/src/main/resources/application-trading_ai.yml create mode 100644 ga-common/src/main/resources/db/migration/V17__테스트데이터.sql create mode 100644 ga-frontend/src/api/exceptionLedger.ts create mode 100644 ga-frontend/src/api/payment.ts create mode 100644 ga-frontend/src/api/user.ts create mode 100644 ga-frontend/src/components/common/DataGrid.tsx create mode 100644 ga-frontend/src/pages/ledger/ExceptionLedger.tsx create mode 100644 ga-frontend/src/pages/org/AgentDetail.tsx create mode 100644 ga-frontend/src/pages/org/AgentForm.tsx create mode 100644 ga-frontend/src/pages/settle/BatchRun.tsx create mode 100644 ga-frontend/src/pages/settle/PaymentList.tsx create mode 100644 ga-frontend/src/pages/system/CodeList.tsx create mode 100644 ga-frontend/src/pages/system/RoleList.tsx create mode 100644 ga-frontend/src/pages/system/UserList.tsx 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)}>잠금해제 + )} + + ), + }, + ]} + /> + + ); +}