From 4e008527b92c565f15a774bd3288744c1be3a743 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sun, 10 May 2026 22:43:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=20+=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 헤더 깨짐 수정 (MainLayout): - logo 를 div 박스 대신 단순 img(/vite.svg) 로 (ProLayout 기본 슬롯 호환) - avatarProps.render 의 chevron 제거 → ProLayout 기본 dom 만 Dropdown 트리거 - token.header 의 잘못된 키(colorBgRightActionsItemHover) 제거, heightLayoutHeader 56 으로 통일 - actionsRender 의 정산월 영역을 Space + 라벨 조합으로 정리 등록/수정 모달 (DrawerForm / ModalForm): - AgentList: DrawerForm (680px) — 설계사명 / 사번 / 소속(Select) / 직급 / 전화 / 이메일 / 주민번호(암호화) / 은행 / 계좌(암호화) / 입사일, 수정 시 detail API 자동 fetch + initialValues 주입, 액션 컬럼: 수정/퇴사 (Modal.confirm) - ContractList: ModalForm (680px) — 증권번호 / 설계사(Select) / 상품(Select) / 계약자 / 피보험자 / 보험료 / 납입주기 / 계약일 / 효력발생일 toolBarRender 등록 버튼 추가 - ExceptionLedger: ModalForm (620px) — 설계사 / 정산월 / 예외코드 / 금액 / 증권번호 / 보험사 / 내용 toolBarRender 등록 버튼 추가, 예외코드 옵션은 ruleApi 에서 동적 로드 api/contract.ts: ContractSaveReq + create/update 추가 검증: tsc -b 통과 / dev 서버 HMR 정상 반영 Co-Authored-By: Claude Opus 4.7 (1M context) --- ga-frontend/src/api/contract.ts | 16 ++ ga-frontend/src/layouts/MainLayout.tsx | 37 +---- .../src/pages/ledger/ExceptionLedger.tsx | 70 ++++++++- ga-frontend/src/pages/org/AgentList.tsx | 143 +++++++++++++++--- .../src/pages/product/ContractList.tsx | 96 ++++++++++-- 5 files changed, 301 insertions(+), 61 deletions(-) diff --git a/ga-frontend/src/api/contract.ts b/ga-frontend/src/api/contract.ts index 8c10a22..77aef5c 100644 --- a/ga-frontend/src/api/contract.ts +++ b/ga-frontend/src/api/contract.ts @@ -33,8 +33,24 @@ export interface ContractSearchParam { endDate?: string; } +export interface ContractSaveReq { + agentId: number; + productId: number; + policyNo: string; + contractorName?: string; + insuredName?: string; + premium?: number; + payCycle?: string; + payPeriod?: number; + coveragePeriod?: number; + contractDate?: string; + effectiveDate?: string; +} + export const contractApi = { list: (p: ContractSearchParam) => unwrap>(api.get('/api/contracts', { params: p })), detail: (id: number) => unwrap(api.get(`/api/contracts/${id}`)), + create: (req: ContractSaveReq) => unwrap(api.post('/api/contracts', req)), + update: (id: number, req: ContractSaveReq) => unwrap(api.put(`/api/contracts/${id}`, req)), }; diff --git a/ga-frontend/src/layouts/MainLayout.tsx b/ga-frontend/src/layouts/MainLayout.tsx index a45cc34..f32cf97 100644 --- a/ga-frontend/src/layouts/MainLayout.tsx +++ b/ga-frontend/src/layouts/MainLayout.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { ProLayout, type MenuDataItem } from '@ant-design/pro-components'; -import { Avatar, Badge, Button, DatePicker, Dropdown, Space } from 'antd'; +import { Badge, Button, DatePicker, Dropdown, Space } from 'antd'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { IconBell, IconLogout, IconUser, IconChevronDown } from '@tabler/icons-react'; +import { IconBell, IconLogout, IconUser } from '@tabler/icons-react'; import dayjs from 'dayjs'; import { menuApi, type MenuResp } from '@/api/menu'; import { authApi } from '@/api/auth'; @@ -46,16 +46,7 @@ export default function MainLayout() { return ( GA - } + logo="/vite.svg" layout="mix" navTheme="light" contentWidth="Fluid" @@ -73,29 +64,22 @@ export default function MainLayout() { breadcrumbRender={(routers = []) => routers} onMenuHeaderClick={() => navigate('/')} token={{ - // ProLayout 디자인 토큰 — antd pro 컬러 시스템 sider: { colorMenuBackground: '#ffffff', colorTextMenuSelected: '#185FA5', colorBgMenuItemSelected: '#E6F1FB', colorTextMenuItemHover: '#1677FF', - colorMenuItemDivider: '#f0f0f0', }, header: { colorBgHeader: '#ffffff', colorHeaderTitle: '#185FA5', - colorBgRightActionsItemHover: '#f5f7fa', - heightLayoutHeader: 48, + heightLayoutHeader: 56, }, bgLayout: '#f5f7fa', - pageContainer: { - paddingBlockPageContainerContent: 16, - paddingInlinePageContainerContent: 20, - }, }} actionsRender={() => [ - - 정산월 + + 정산월 , @@ -111,11 +95,9 @@ export default function MainLayout() { , ]} avatarProps={{ - src: undefined, size: 'small', title: auth.userName ?? auth.loginId ?? '관리자', icon: , - style: { background: 'linear-gradient(135deg, #1677FF 0%, #185FA5 100%)' }, render: (_props, dom) => ( - - {dom} - - + {dom} ), }} diff --git a/ga-frontend/src/pages/ledger/ExceptionLedger.tsx b/ga-frontend/src/pages/ledger/ExceptionLedger.tsx index 473cd58..3cb8131 100644 --- a/ga-frontend/src/pages/ledger/ExceptionLedger.tsx +++ b/ga-frontend/src/pages/ledger/ExceptionLedger.tsx @@ -1,17 +1,31 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { Modal, Space, message } from 'antd'; -import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; +import { + ModalForm, ProForm, ProFormDigit, ProFormSelect, ProFormText, ProFormTextArea, + ProTable, type ActionType, type ProColumns, +} from '@ant-design/pro-components'; +import { useQuery } from '@tanstack/react-query'; 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 { useCommonCodes } from '@/hooks/useCommonCodes'; -import { exceptionApi, ExceptionLedgerResp } from '@/api/exceptionLedger'; +import { exceptionApi, ExceptionLedgerResp, ExceptionLedgerSaveReq } from '@/api/exceptionLedger'; +import { agentApi } from '@/api/agent'; +import { ruleApi } from '@/api/rule'; export default function ExceptionLedger() { const actionRef = useRef(); + const [open, setOpen] = useState(false); const { data: approveCodes = [] } = useCommonCodes('APPROVE_STATUS'); + const { data: agents } = useQuery({ + queryKey: ['agents', 'all'], queryFn: () => agentApi.list({ pageSize: 999 }), enabled: open, + }); + const { data: excCodes = [] } = useQuery({ + queryKey: ['rules', 'exceptions'], queryFn: ruleApi.exceptionCodes, enabled: open, + }); + const onApprove = (id: number, status: 'APPROVED' | 'REJECTED') => Modal.confirm({ title: status === 'APPROVED' ? '승인하시겠습니까?' : '반려하시겠습니까?', @@ -22,6 +36,15 @@ export default function ExceptionLedger() { }, }); + const onSubmit = async (v: any) => { + const req: ExceptionLedgerSaveReq = { ...v }; + await exceptionApi.create(req); + message.success('등록 완료. 승인 대기 상태입니다.'); + setOpen(false); + actionRef.current?.reload(); + return true; + }; + const columns: ProColumns[] = [ { title: '정산월', dataIndex: 'settleMonth', width: 90, valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' } }, { @@ -78,8 +101,49 @@ export default function ExceptionLedger() { }} search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }} options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + setOpen(true)}>+ 예외금액 등록, + ]} dateFormatter="string" /> + + + + ({ value: a.agentId, label: `${a.agentName} (${a.licenseNo ?? '-'})` })) ?? []} + /> + + + + ({ value: c.exceptionCode, label: `${c.exceptionName} (${c.direction})` }))} + /> + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }} + /> + + + + + + + ); } diff --git a/ga-frontend/src/pages/org/AgentList.tsx b/ga-frontend/src/pages/org/AgentList.tsx index 1066f86..ccd9c0a 100644 --- a/ga-frontend/src/pages/org/AgentList.tsx +++ b/ga-frontend/src/pages/org/AgentList.tsx @@ -1,19 +1,72 @@ -import { useRef } from 'react'; -import { Space } from 'antd'; -import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; +import { useRef, useState } from 'react'; +import { Modal, Space, message } from 'antd'; +import { + DrawerForm, ProForm, ProFormDatePicker, ProFormSelect, ProFormText, + ProTable, type ActionType, type ProColumns, +} from '@ant-design/pro-components'; import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; import CodeBadge from '@/components/common/CodeBadge'; import MoneyText from '@/components/common/MoneyText'; import PermissionButton from '@/components/common/PermissionButton'; import ExcelExportButton from '@/components/common/ExcelExportButton'; import PageContainer from '@/components/common/PageContainer'; import { useCommonCodes } from '@/hooks/useCommonCodes'; -import { agentApi, AgentResp, AgentSearchParam } from '@/api/agent'; +import { agentApi, AgentResp, AgentSaveReq, AgentSearchParam } from '@/api/agent'; +import { orgApi } from '@/api/org'; export default function AgentList() { const navigate = useNavigate(); const actionRef = useRef(); const { data: statusCodes = [] } = useCommonCodes('AGENT_STATUS'); + const { data: bankCodes = [] } = useCommonCodes('BANK_CODE'); + + const [drawer, setDrawer] = useState<{ open: boolean; editId?: number }>({ open: false }); + + const { data: orgList = [] } = useQuery({ + queryKey: ['orgs', 'list'], + queryFn: orgApi.list, + }); + const orgOptions = orgList.map((o) => ({ value: o.orgId, label: `${o.orgName}` })); + const gradeOptions = [1, 2, 3, 4, 5, 6].map((id) => ({ value: id, label: `직급 #${id}` })); + + // 수정 시 상세 조회 + const { data: editing } = useQuery({ + queryKey: ['agent', 'detail', drawer.editId], + queryFn: () => agentApi.detail(drawer.editId!), + enabled: !!drawer.editId, + }); + + const onSubmit = async (values: any) => { + const req: AgentSaveReq = { + ...values, + joinDate: values.joinDate ? dayjs(values.joinDate).format('YYYY-MM-DD') : undefined, + }; + if (drawer.editId) { + await agentApi.update(drawer.editId, req); + message.success('수정 완료'); + } else { + await agentApi.create(req); + message.success('등록 완료'); + } + setDrawer({ open: false }); + actionRef.current?.reload(); + return true; + }; + + const onDelete = (id: number) => { + Modal.confirm({ + title: '설계사 삭제', + content: '관련 계약/원장이 있으면 실패합니다. 진행하시겠습니까?', + okType: 'danger', + onOk: async () => { + await agentApi.changeStatus(id, 'LEAVE'); + message.success('퇴사 처리 완료'); + actionRef.current?.reload(); + }, + }); + }; const columns: ProColumns[] = [ { @@ -42,6 +95,15 @@ export default function AgentList() { render: (_, r) => , }, { title: '입사일', dataIndex: 'joinDate', width: 110, search: false }, + { + title: '액션', valueType: 'option', width: 140, fixed: 'right', + render: (_, r) => [ + setDrawer({ open: true, editId: r.agentId })}>수정, + onDelete(r.agentId)}>퇴사, + ], + }, { title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, fieldProps: { placeholder: '설계사명' }, @@ -57,37 +119,78 @@ export default function AgentList() { actionRef={actionRef} rowKey="agentId" columns={columns} - scroll={{ x: 1200 }} + scroll={{ x: 1300 }} request={async (params) => { const { current, pageSize, ...rest } = params as any; const res = await agentApi.list({ ...rest, pageNum: current, pageSize }); return { data: res.list, total: res.total, success: true }; }} pagination={{ - pageSize: 20, - showSizeChanger: true, - pageSizeOptions: ['20', '50', '100'], + pageSize: 20, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'], showTotal: (total) => `총 ${total.toLocaleString()}건`, }} - search={{ - labelWidth: 'auto', - searchText: '검색', - resetText: '초기화', - collapsed: false, - collapseRender: false, - }} + search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }} options={{ density: false, fullScreen: true, reload: true, setting: true }} toolBarRender={() => [ , - navigate('/org/agents/new')} - >+ 설계사 등록, + setDrawer({ open: true })}>+ 설계사 등록, ]} dateFormatter="string" headerTitle={설계사 목록} /> + + {/* 등록/수정 Drawer */} + !o && setDrawer({ open: false })} + width={680} + layout="vertical" + initialValues={drawer.editId && editing ? { + ...editing, + joinDate: editing.joinDate ? dayjs(editing.joinDate) : null, + } : undefined} + // 수정으로 전환 시 form 강제 갱신 + key={drawer.editId ?? 'new'} + onFinish={onSubmit} + submitter={{ searchConfig: { submitText: drawer.editId ? '수정' : '등록', resetText: '취소' } }} + drawerProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + + + + + + + + + + + + ({ value: c.code, label: c.codeName }))} + /> + + + + ); } diff --git a/ga-frontend/src/pages/product/ContractList.tsx b/ga-frontend/src/pages/product/ContractList.tsx index b34d347..c496563 100644 --- a/ga-frontend/src/pages/product/ContractList.tsx +++ b/ga-frontend/src/pages/product/ContractList.tsx @@ -1,19 +1,55 @@ -import { ProTable, type ProColumns } from '@ant-design/pro-components'; +import { useRef, useState } from 'react'; +import { message } from 'antd'; +import { + ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText, + ProTable, type ActionType, type ProColumns, +} from '@ant-design/pro-components'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; 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 { useCommonCodes } from '@/hooks/useCommonCodes'; -import { contractApi, ContractResp, ContractSearchParam } from '@/api/contract'; +import { contractApi, ContractResp, ContractSaveReq, ContractSearchParam } from '@/api/contract'; +import { productApi } from '@/api/product'; +import { agentApi } from '@/api/agent'; export default function ContractList() { + const actionRef = useRef(); + const [open, setOpen] = useState(false); const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS'); + const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE'); + + // 등록 모달용 - 상품/설계사 옵션 + const { data: products } = useQuery({ + queryKey: ['products', 'all'], + queryFn: () => productApi.list({ pageSize: 999 }), + enabled: open, + }); + const { data: agents } = useQuery({ + queryKey: ['agents', 'all'], + queryFn: () => agentApi.list({ pageSize: 999 }), + enabled: open, + }); + + const onSubmit = async (v: any) => { + const req: ContractSaveReq = { + ...v, + contractDate: v.contractDate ? dayjs(v.contractDate).format('YYYY-MM-DD') : undefined, + effectiveDate: v.effectiveDate ? dayjs(v.effectiveDate).format('YYYY-MM-DD') : undefined, + }; + await contractApi.create(req); + message.success('등록 완료'); + setOpen(false); + actionRef.current?.reload(); + return true; + }; const columns: ProColumns[] = [ - { - title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, - fieldProps: { placeholder: '계약자/증권번호' }, - }, + { title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, + fieldProps: { placeholder: '계약자/증권번호' } }, { title: '보험종류', dataIndex: 'insuranceType', width: 100, valueType: 'select', valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])), @@ -43,6 +79,7 @@ export default function ContractList() { return ( + actionRef={actionRef} rowKey="contractId" columns={columns} scroll={{ x: 1300 }} @@ -52,15 +89,56 @@ export default function ContractList() { return { data: res.list, total: res.total, success: true }; }} pagination={{ - pageSize: 20, - showSizeChanger: true, - pageSizeOptions: ['20', '50', '100'], + pageSize: 20, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'], showTotal: (total) => `총 ${total.toLocaleString()}건`, }} search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }} options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + setOpen(true)}>+ 계약 등록, + ]} dateFormatter="string" /> + + + + + ({ value: a.agentId, label: `${a.agentName} (${a.licenseNo ?? '-'})` })) ?? []} + /> + + ({ value: p.productId, label: `${p.productName} — ${p.companyName}` })) ?? []} + /> + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }} /> + ({ value: c.code, label: c.codeName }))} + /> + + + + + + ); }