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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user