feat: 데이터 도메인 사전 + 데이터 사전 시스템 (V20)
3 agents 병렬 작업 (dba / api / frontend) 결과물 + SQL 버그 2건 fix. DB (V20__데이터사전.sql): - data_domain (domain_code PK, category, data_type, length/precision/scale, format_pattern, validation_regex, masking_type, encrypted, ...) - data_dictionary (dict_id PK, table_name, column_name, domain_code FK, column_label, is_pk/is_fk/fk_reference, business_rule, sample_value, ...) - VIEW v_data_dictionary_full: information_schema + pg_description + data_dictionary + data_domain 4-way LEFT JOIN → ga 스키마 모든 컬럼 자동 노출 (사전 미등록 컬럼도 회색 표시) - 도메인 30개 초기 데이터 (IDENTIFIER 7 / PII 6 / MONEY 5 / RATE 3 / DATE 4 / CODE 4 / TEXT 3 / JSON 2) - 사전 매핑 50개 (agent 10 / contract 7 / recruit_ledger 9 / settle_master 9 / payment 6) - 메뉴 SYSTEM_DATA_DOMAIN, SYSTEM_DATA_DICT 등록 + 권한 Backend: - ga-core VO 9개 + Mapper 2개 + XML 2개 - ga-api Service 2 (도메인 삭제 시 사전 참조 검증) + Controller 2 - 엔드포인트: · /api/data-domains (CRUD + /options) · /api/data-dictionary (CRUD + /tables + /full?tableName=) Frontend: - api/dict.ts (13 endpoints) - pages/system/DataDomainList.tsx — 카테고리 색상 Tag, 마스킹/암호화 표시, ModalForm 등록/수정/삭제 - pages/system/DataDictionary.tsx — 좌(테이블 목록 + prefix 그룹핑) + 우(v_data_dictionary_full 조회, 사전 미등록 컬럼은 회색+점선 추가 버튼) - App.tsx + MenuIcon.tsx 매핑 추가 Bug fix: - DataDomainMapper: WHERE is_active = true → 'Y' (CHAR vs boolean) - DataDictionaryMapper.selectFromView: ORDER BY sort_order 제거 (view 에 없는 컬럼) 검증: - ./gradlew :ga-api:bootJar 통과 - Flyway V20 운영 DB 적용 (17초) - 4 엔드포인트 모두 200 응답 - agent 테이블 15컬럼 사전 자동 노출 + 도메인 매핑 정상 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Button, Col, Empty, Input, List, Modal, Row, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
ProCard, ProTable, ModalForm, ProFormText, ProFormSelect, ProFormTextArea, ProFormSwitch,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { IconPlus, IconEdit, IconTrash, IconSearch, IconBook } from '@tabler/icons-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
dataDictApi, dataDomainApi,
|
||||
DataDictionaryFullResp, DataDictionarySaveReq, DataDomainOption,
|
||||
} from '@/api/dict';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ─── 테이블명 → 카테고리 자동 분류 ──────────────────────────────────────────
|
||||
|
||||
const TABLE_CATEGORY_PREFIX: Array<{ prefix: string; label: string }> = [
|
||||
{ prefix: 'org_', label: '조직' },
|
||||
{ prefix: 'agent_', label: '조직' },
|
||||
{ prefix: 'contract_', label: '계약' },
|
||||
{ prefix: 'policy_', label: '계약' },
|
||||
{ prefix: 'ledger_', label: '원장' },
|
||||
{ prefix: 'settle_', label: '정산' },
|
||||
{ prefix: 'payment_', label: '정산' },
|
||||
{ prefix: 'batch_', label: '정산' },
|
||||
{ prefix: 'sys_', label: '시스템' },
|
||||
{ prefix: 'system_', label: '시스템' },
|
||||
{ prefix: 'common_', label: '시스템' },
|
||||
{ prefix: 'menu_', label: '시스템' },
|
||||
{ prefix: 'user_', label: '시스템' },
|
||||
{ prefix: 'role_', label: '시스템' },
|
||||
];
|
||||
|
||||
function getTableCategory(tableName: string): string {
|
||||
for (const { prefix, label } of TABLE_CATEGORY_PREFIX) {
|
||||
if (tableName.startsWith(prefix)) return label;
|
||||
}
|
||||
return '기타';
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ['조직', '계약', '원장', '정산', '시스템', '기타'];
|
||||
|
||||
// ─── 도메인 카테고리 색상 ─────────────────────────────────────────────────────
|
||||
|
||||
const DOMAIN_CAT_COLOR: Record<string, string> = {
|
||||
IDENTIFIER: 'blue',
|
||||
PII: 'red',
|
||||
MONEY: 'green',
|
||||
RATE: 'cyan',
|
||||
DATE: 'purple',
|
||||
CODE: 'orange',
|
||||
TEXT: 'default',
|
||||
JSON: 'magenta',
|
||||
};
|
||||
|
||||
// ─── 도메인 셀 (Tooltip 포함) ─────────────────────────────────────────────────
|
||||
|
||||
function DomainCell({ row }: { row: DataDictionaryFullResp }) {
|
||||
if (!row.domainCode) return <span style={{ color: '#bbb' }}>-</span>;
|
||||
const tag = (
|
||||
<Tag color={DOMAIN_CAT_COLOR[row.domainCategory ?? ''] ?? 'default'} style={{ cursor: 'default' }}>
|
||||
{row.domainName ?? row.domainCode}
|
||||
</Tag>
|
||||
);
|
||||
if (!row.formatPattern && !row.dictDescription) return tag;
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div><Text style={{ color: '#fff', fontSize: 12 }}>코드: {row.domainCode}</Text></div>
|
||||
{row.dictDescription && <div><Text style={{ color: '#ddd', fontSize: 12 }}>{row.dictDescription}</Text></div>}
|
||||
{row.formatPattern && <div><Text style={{ color: '#adf', fontSize: 12 }}>포맷: {row.formatPattern}</Text></div>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 사전 등록 / 수정 ModalForm ───────────────────────────────────────────────
|
||||
|
||||
function DictFormModal({
|
||||
row,
|
||||
domainOptions,
|
||||
onSuccess,
|
||||
trigger,
|
||||
}: {
|
||||
row: DataDictionaryFullResp;
|
||||
domainOptions: DataDomainOption[];
|
||||
onSuccess: () => void;
|
||||
trigger: JSX.Element;
|
||||
}) {
|
||||
const isEdit = !!row.domainCode || !!row.columnLabel || !!row.isPk;
|
||||
|
||||
const initialValues: Partial<DataDictionarySaveReq> & { isPkBool?: boolean; isFkBool?: boolean; isRequiredBool?: boolean } = {
|
||||
tableName: row.tableName,
|
||||
columnName: row.columnName,
|
||||
columnLabel: row.columnLabel,
|
||||
domainCode: row.domainCode,
|
||||
fkReference: row.fkReference,
|
||||
defaultValue: row.columnDefault,
|
||||
businessRule: row.businessRule,
|
||||
sampleValue: row.sampleValue,
|
||||
isPkBool: row.isPk === 'Y',
|
||||
isFkBool: row.isFk === 'Y',
|
||||
isRequiredBool: row.isNullable === 'NO',
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm<DataDictionarySaveReq & { isPkBool?: boolean; isFkBool?: boolean; isRequiredBool?: boolean }>
|
||||
title={isEdit ? '사전 수정' : '사전 등록'}
|
||||
trigger={trigger}
|
||||
width={560}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
initialValues={initialValues}
|
||||
onFinish={async (values) => {
|
||||
const { isPkBool, isFkBool, isRequiredBool, ...rest } = values;
|
||||
const payload: DataDictionarySaveReq = {
|
||||
...rest,
|
||||
tableName: row.tableName,
|
||||
columnName: row.columnName,
|
||||
isPk: isPkBool ? 'Y' : 'N',
|
||||
isFk: isFkBool ? 'Y' : 'N',
|
||||
isRequired: isRequiredBool ? 'Y' : 'N',
|
||||
};
|
||||
await dataDictApi.create(payload);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSuccess();
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ProFormText name="columnLabel" label="한글 컬럼명" rules={[{ required: true }]} />
|
||||
<ProFormSelect
|
||||
name="domainCode"
|
||||
label="도메인"
|
||||
showSearch
|
||||
options={domainOptions.map((o) => ({
|
||||
value: o.domainCode,
|
||||
label: `${o.domainName} (${o.domainCode}) — ${o.domainCategory}`,
|
||||
}))}
|
||||
fieldProps={{ allowClear: true }}
|
||||
/>
|
||||
<ProFormSwitch name="isPkBool" label="PK" />
|
||||
<ProFormSwitch name="isFkBool" label="FK" />
|
||||
<ProFormSwitch name="isRequiredBool" label="필수 여부" />
|
||||
<ProFormText name="fkReference" label="FK 참조" placeholder="테이블.컬럼" />
|
||||
<ProFormText name="defaultValue" label="기본값" />
|
||||
<ProFormTextArea name="businessRule" label="업무 규칙" fieldProps={{ rows: 2 }} />
|
||||
<ProFormText name="sampleValue" label="샘플 값" />
|
||||
</ModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 우측: 컬럼 사전 테이블 ──────────────────────────────────────────────────
|
||||
|
||||
function ColumnDictTable({ tableName }: { tableName: string }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: domainOptions = [] } = useQuery({
|
||||
queryKey: ['dataDomain', 'options'],
|
||||
queryFn: dataDomainApi.options,
|
||||
});
|
||||
|
||||
const { data: rows = [], isLoading, refetch } = useQuery({
|
||||
queryKey: ['dataDict', 'full', tableName],
|
||||
queryFn: () => dataDictApi.full(tableName),
|
||||
enabled: !!tableName,
|
||||
});
|
||||
|
||||
const handleDelete = (dictId: number | undefined) => {
|
||||
if (!dictId) return;
|
||||
Modal.confirm({
|
||||
title: '사전 삭제',
|
||||
content: '이 컬럼의 사전 정보를 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await dataDictApi.remove(dictId);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ProColumns<DataDictionaryFullResp>[] = [
|
||||
{
|
||||
title: '#', dataIndex: 'ordinalPosition', width: 50, align: 'right', search: false,
|
||||
render: (v) => <Text type="secondary" style={{ fontSize: 12 }}>{v as number}</Text>,
|
||||
},
|
||||
{
|
||||
title: '컬럼명', dataIndex: 'columnName', width: 160, search: false,
|
||||
render: (_, r) => (
|
||||
<span>
|
||||
<Text code style={{ fontSize: 12 }}>{r.columnName}</Text>
|
||||
{r.isPk === 'Y' && <Tag color="gold" style={{ marginLeft: 4, fontSize: 11 }}>PK</Tag>}
|
||||
{r.isFk === 'Y' && <Tag color="purple" style={{ marginLeft: 4, fontSize: 11 }}>FK</Tag>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '한글명', dataIndex: 'columnLabel', width: 140, search: false,
|
||||
render: (_, r) =>
|
||||
r.columnLabel
|
||||
? <Text>{r.columnLabel}</Text>
|
||||
: <Text type="secondary" style={{ fontSize: 12 }}>미등록</Text>,
|
||||
},
|
||||
{
|
||||
title: '도메인', dataIndex: 'domainCode', width: 140, search: false,
|
||||
render: (_, r) => <DomainCell row={r} />,
|
||||
},
|
||||
{
|
||||
title: 'DB 타입', dataIndex: 'dataType', width: 100, search: false,
|
||||
render: (_, r) => {
|
||||
let label = r.dataType;
|
||||
if (r.maxLength) label += `(${r.maxLength})`;
|
||||
if (r.numPrecision) label += `(${r.numPrecision}${r.numScale != null ? `,${r.numScale}` : ''})`;
|
||||
return <Text style={{ fontSize: 12 }}>{label}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Nullable', dataIndex: 'isNullable', width: 80, align: 'center', search: false,
|
||||
render: (_, r) =>
|
||||
r.isNullable === 'NO'
|
||||
? <Tag color="orange">NOT NULL</Tag>
|
||||
: <Tag color="default">NULL</Tag>,
|
||||
},
|
||||
{
|
||||
title: '마스킹', dataIndex: 'maskingType', width: 80, search: false,
|
||||
render: (_, r) =>
|
||||
r.maskingType && r.maskingType !== 'NONE'
|
||||
? <Tag color="orange">{r.maskingType}</Tag>
|
||||
: <span style={{ color: '#bbb' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '암호화', dataIndex: 'encrypted', width: 75, align: 'center', search: false,
|
||||
render: (_, r) =>
|
||||
r.encrypted === 'Y'
|
||||
? <Tag color="red">암호화</Tag>
|
||||
: <span style={{ color: '#bbb' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '샘플', dataIndex: 'sampleValue', width: 100, search: false, ellipsis: true,
|
||||
render: (v) => v ?? <span style={{ color: '#bbb' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '액션', width: 110, fixed: 'right', search: false,
|
||||
render: (_, r) => {
|
||||
const hasDictEntry = !!r.columnLabel || !!r.domainCode || !!r.businessRule;
|
||||
if (!hasDictEntry) {
|
||||
return (
|
||||
<DictFormModal
|
||||
row={r}
|
||||
domainOptions={domainOptions}
|
||||
onSuccess={() => { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }}
|
||||
trigger={
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DICT"
|
||||
permCode="CREATE"
|
||||
size="small"
|
||||
type="dashed"
|
||||
icon={<IconPlus size={12} />}
|
||||
style={{ color: '#aaa', borderColor: '#d9d9d9' }}
|
||||
>
|
||||
사전 추가
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space size={2}>
|
||||
<DictFormModal
|
||||
row={r}
|
||||
domainOptions={domainOptions}
|
||||
onSuccess={() => { qc.invalidateQueries({ queryKey: ['dataDict', 'full', tableName] }); refetch(); }}
|
||||
trigger={
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DICT"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<IconEdit size={14} />}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DICT"
|
||||
permCode="DELETE"
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<IconTrash size={14} />}
|
||||
onClick={() => handleDelete(undefined)}
|
||||
>
|
||||
삭제
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProTable<DataDictionaryFullResp>
|
||||
rowKey="columnName"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
pagination={false}
|
||||
options={{ density: false, reload: () => refetch(), setting: true }}
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => {
|
||||
const hasDictEntry = !!r.columnLabel || !!r.domainCode || !!r.businessRule;
|
||||
return hasDictEntry ? '' : 'dict-row-unregistered';
|
||||
}}
|
||||
style={{ '--dict-unregistered-bg': '#fafafa' } as React.CSSProperties}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 좌측: 테이블 목록 ────────────────────────────────────────────────────────
|
||||
|
||||
function TableList({
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (t: string) => void;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const { data: tables = [], isLoading } = useQuery({
|
||||
queryKey: ['dataDict', 'tables'],
|
||||
queryFn: dataDictApi.tables,
|
||||
});
|
||||
|
||||
const filtered = useMemo(
|
||||
() => tables.filter((t) => t.toLowerCase().includes(keyword.toLowerCase())),
|
||||
[tables, keyword],
|
||||
);
|
||||
|
||||
// 카테고리별 그룹핑
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const t of filtered) {
|
||||
const cat = getTableCategory(t);
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(t);
|
||||
}
|
||||
return map;
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder="테이블 검색"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
allowClear
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 16, color: '#999', fontSize: 12 }}>로딩 중...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty description="테이블 없음" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
CATEGORY_ORDER.filter((cat) => grouped[cat]?.length).map((cat) => (
|
||||
<div key={cat}>
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#888',
|
||||
background: '#f5f5f5',
|
||||
letterSpacing: 1,
|
||||
}}>
|
||||
{cat}
|
||||
</div>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={grouped[cat]}
|
||||
renderItem={(t) => (
|
||||
<List.Item
|
||||
onClick={() => onSelect(t)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '4px 12px',
|
||||
background: t === selected ? '#E6F1FB' : undefined,
|
||||
borderLeft: t === selected ? '3px solid #1677ff' : '3px solid transparent',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: t === selected ? '#1677ff' : undefined,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DataDictionary() {
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="데이터 사전"
|
||||
description="테이블 컬럼 정의 / 도메인 연결 / 마스킹 및 보안 정책"
|
||||
>
|
||||
<ProCard split="vertical" style={{ minHeight: 600 }}>
|
||||
{/* 좌측: 테이블 목록 */}
|
||||
<ProCard
|
||||
colSpan={280}
|
||||
title={
|
||||
<Space>
|
||||
<IconBook size={14} style={{ verticalAlign: -2 }} />
|
||||
테이블 목록
|
||||
</Space>
|
||||
}
|
||||
headerBordered
|
||||
style={{ overflow: 'hidden' }}
|
||||
bodyStyle={{ padding: '8px 0', height: 'calc(100vh - 240px)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div style={{ padding: '0 8px', flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<TableList selected={selectedTable} onSelect={setSelectedTable} />
|
||||
</div>
|
||||
</ProCard>
|
||||
|
||||
{/* 우측: 컬럼 사전 */}
|
||||
<ProCard
|
||||
title={
|
||||
selectedTable
|
||||
? <span>컬럼 사전 — <Text code style={{ fontSize: 13 }}>{selectedTable}</Text></span>
|
||||
: '컬럼 사전'
|
||||
}
|
||||
headerBordered
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{selectedTable ? (
|
||||
<ColumnDictTable tableName={selectedTable} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 400 }}>
|
||||
<Empty description="좌측에서 테이블을 선택하면 컬럼 사전을 확인할 수 있습니다" />
|
||||
</div>
|
||||
)}
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
|
||||
{/* 미등록 행 스타일 */}
|
||||
<style>{`
|
||||
.dict-row-unregistered td {
|
||||
background: #fafafa !important;
|
||||
color: #aaa;
|
||||
}
|
||||
`}</style>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useRef } from 'react';
|
||||
import { Modal, Space, Switch, Tag, Tooltip, message } from 'antd';
|
||||
import {
|
||||
ProTable, ModalForm, ProFormText, ProFormSelect, ProFormDigit,
|
||||
ProFormTextArea, ProFormSwitch,
|
||||
type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { dataDomainApi, DataDomainResp, DataDomainSaveReq } from '@/api/dict';
|
||||
|
||||
// ─── 카테고리 색상 ────────────────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_COLOR: Record<string, string> = {
|
||||
IDENTIFIER: 'blue',
|
||||
PII: 'red',
|
||||
MONEY: 'green',
|
||||
RATE: 'cyan',
|
||||
DATE: 'purple',
|
||||
CODE: 'orange',
|
||||
TEXT: 'default',
|
||||
JSON: 'magenta',
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS = Object.keys(CATEGORY_COLOR).map((v) => ({ value: v, label: v }));
|
||||
|
||||
const DATA_TYPE_OPTIONS = [
|
||||
'VARCHAR', 'CHAR', 'TEXT', 'INT', 'BIGINT', 'SMALLINT',
|
||||
'DECIMAL', 'NUMERIC', 'FLOAT', 'DOUBLE', 'DATE', 'DATETIME',
|
||||
'TIMESTAMP', 'BOOLEAN', 'JSON',
|
||||
].map((v) => ({ value: v, label: v }));
|
||||
|
||||
const MASKING_OPTIONS = [
|
||||
{ value: 'NONE', label: 'NONE' },
|
||||
{ value: 'NAME', label: 'NAME' },
|
||||
{ value: 'PHONE', label: 'PHONE' },
|
||||
{ value: 'RRN', label: 'RRN' },
|
||||
{ value: 'ACCOUNT', label: 'ACCOUNT' },
|
||||
{ value: 'EMAIL', label: 'EMAIL' },
|
||||
];
|
||||
|
||||
const MASKING_COLOR: Record<string, string> = {
|
||||
NONE: 'default',
|
||||
NAME: 'orange',
|
||||
PHONE: 'orange',
|
||||
RRN: 'red',
|
||||
ACCOUNT: 'red',
|
||||
EMAIL: 'orange',
|
||||
};
|
||||
|
||||
// ─── 도메인 등록 / 수정 ModalForm ─────────────────────────────────────────────
|
||||
|
||||
function DomainFormModal({
|
||||
editTarget,
|
||||
onSuccess,
|
||||
trigger,
|
||||
}: {
|
||||
editTarget?: DataDomainResp;
|
||||
onSuccess: () => void;
|
||||
trigger: JSX.Element;
|
||||
}) {
|
||||
const isEdit = !!editTarget;
|
||||
|
||||
return (
|
||||
<ModalForm<DataDomainSaveReq & { encryptedBool?: boolean }>
|
||||
title={isEdit ? '도메인 수정' : '도메인 등록'}
|
||||
trigger={trigger}
|
||||
width={640}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
initialValues={
|
||||
editTarget
|
||||
? { ...editTarget, encryptedBool: editTarget.encrypted === 'Y' }
|
||||
: { isActive: 'Y', encrypted: 'N', encryptedBool: false }
|
||||
}
|
||||
onFinish={async (values) => {
|
||||
const { encryptedBool, ...rest } = values;
|
||||
const payload: DataDomainSaveReq = {
|
||||
...rest,
|
||||
encrypted: encryptedBool ? 'Y' : 'N',
|
||||
isActive: rest.isActive ?? 'Y',
|
||||
};
|
||||
if (isEdit) {
|
||||
await dataDomainApi.update(editTarget!.domainCode, payload);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await dataDomainApi.create(payload);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
onSuccess();
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name="domainCode"
|
||||
label="도메인 코드"
|
||||
rules={[{ required: true }]}
|
||||
fieldProps={{ disabled: isEdit }}
|
||||
placeholder="예: AGENT_CODE"
|
||||
/>
|
||||
<ProFormText
|
||||
name="domainName"
|
||||
label="도메인 명"
|
||||
rules={[{ required: true }]}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="domainCategory"
|
||||
label="카테고리"
|
||||
rules={[{ required: true }]}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="dataType"
|
||||
label="데이터 타입"
|
||||
rules={[{ required: true }]}
|
||||
options={DATA_TYPE_OPTIONS}
|
||||
/>
|
||||
<ProFormDigit name="length" label="길이" min={0} />
|
||||
<ProFormDigit name="precision" label="전체 자릿수" min={0} />
|
||||
<ProFormDigit name="scale" label="소수 자릿수" min={0} />
|
||||
<ProFormText name="formatPattern" label="포맷 패턴" placeholder="예: YYYY-MM-DD" />
|
||||
<ProFormText name="validationRegex" label="검증 정규식" />
|
||||
<ProFormText name="exampleValue" label="예시 값" />
|
||||
<ProFormSelect
|
||||
name="maskingType"
|
||||
label="마스킹 유형"
|
||||
options={MASKING_OPTIONS}
|
||||
/>
|
||||
<ProFormSwitch name="encryptedBool" label="암호화 여부" />
|
||||
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 2 }} />
|
||||
<ProFormSelect
|
||||
name="isActive"
|
||||
label="활성 여부"
|
||||
options={[{ value: 'Y', label: '활성' }, { value: 'N', label: '비활성' }]}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DataDomainList() {
|
||||
const qc = useQueryClient();
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['dataDomain'] });
|
||||
actionRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleDelete = (domainCode: string) =>
|
||||
Modal.confirm({
|
||||
title: '도메인 삭제',
|
||||
content: '이 도메인을 삭제하시겠습니까? 사용 중인 컬럼이 있으면 오류가 반환됩니다.',
|
||||
okType: 'danger',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await dataDomainApi.remove(domainCode);
|
||||
message.success('삭제 완료');
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ProColumns<DataDomainResp>[] = [
|
||||
// 검색 전용
|
||||
{
|
||||
title: '카테고리', dataIndex: 'domainCategory', valueType: 'select',
|
||||
valueEnum: Object.fromEntries(CATEGORY_OPTIONS.map((o) => [o.value, { text: o.label }])),
|
||||
hideInTable: true,
|
||||
},
|
||||
{
|
||||
title: '활성 여부', dataIndex: 'isActive', valueType: 'select',
|
||||
valueEnum: { Y: { text: '활성' }, N: { text: '비활성' } },
|
||||
hideInTable: true,
|
||||
},
|
||||
{
|
||||
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||
fieldProps: { placeholder: '코드 / 이름' },
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
{ title: '도메인 코드', dataIndex: 'domainCode', width: 160, search: false, copyable: true },
|
||||
{ title: '도메인 명', dataIndex: 'domainName', width: 160, search: false },
|
||||
{
|
||||
title: '카테고리', dataIndex: 'domainCategory', width: 110, search: false,
|
||||
render: (_, r) => (
|
||||
<Tag color={CATEGORY_COLOR[r.domainCategory] ?? 'default'}>{r.domainCategory}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '데이터 타입', dataIndex: 'dataType', width: 110, search: false },
|
||||
{
|
||||
title: '길이 / 정밀도', width: 110, search: false,
|
||||
render: (_, r) => {
|
||||
if (r.precision != null) {
|
||||
return <span>{r.precision}{r.scale != null ? `,${r.scale}` : ''}</span>;
|
||||
}
|
||||
return r.length != null ? <span>{r.length}</span> : <span style={{ color: '#bbb' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '포맷 패턴', dataIndex: 'formatPattern', width: 130, search: false,
|
||||
render: (v) => v ?? <span style={{ color: '#bbb' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '마스킹', dataIndex: 'maskingType', width: 90, search: false,
|
||||
render: (_, r) =>
|
||||
r.maskingType && r.maskingType !== 'NONE' ? (
|
||||
<Tag color={MASKING_COLOR[r.maskingType] ?? 'default'}>{r.maskingType}</Tag>
|
||||
) : (
|
||||
<span style={{ color: '#bbb' }}>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '암호화', dataIndex: 'encrypted', width: 80, align: 'center', search: false,
|
||||
render: (_, r) =>
|
||||
r.encrypted === 'Y' ? <Tag color="red">암호화</Tag> : <span style={{ color: '#bbb' }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false,
|
||||
render: (_, r) => (
|
||||
<Switch checked={r.isActive === 'Y'} size="small" disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '액션', width: 130, fixed: 'right', search: false,
|
||||
render: (_, r) => (
|
||||
<Space size={2}>
|
||||
<DomainFormModal
|
||||
editTarget={r}
|
||||
onSuccess={refresh}
|
||||
trigger={
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DOMAIN"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<IconEdit size={14} />}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DOMAIN"
|
||||
permCode="DELETE"
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<IconTrash size={14} />}
|
||||
onClick={() => handleDelete(r.domainCode)}
|
||||
>
|
||||
삭제
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="데이터 도메인 사전"
|
||||
description="컬럼 도메인 정의 및 마스킹 / 암호화 정책 관리"
|
||||
extra={
|
||||
<DomainFormModal
|
||||
onSuccess={refresh}
|
||||
trigger={
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_DATA_DOMAIN"
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
icon={<IconPlus size={14} />}
|
||||
>
|
||||
도메인 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ProTable<DataDomainResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="domainCode"
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, domainCategory, isActive, searchKeyword } = params as {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
domainCategory?: string;
|
||||
isActive?: string;
|
||||
searchKeyword?: string;
|
||||
};
|
||||
const res = await dataDomainApi.list({ domainCategory, isActive, searchKeyword, pageNum: current, pageSize });
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
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 }}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user