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:
GA Pro
2026-05-11 00:29:13 +09:00
parent 2136b38e1b
commit cece084631
24 changed files with 2268 additions and 1 deletions
@@ -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>
);
}