1
This commit is contained in:
@@ -1,80 +1,539 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Col, Empty, Row, Table, Tag, Typography } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card, Col, Empty, Form, Input, InputNumber, Modal,
|
||||
Row, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { commonCodeApi, CommonCodeGroup } from '@/api/user';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { commonCodeApi, CommonCodeGroup, CommonCode } from '@/api/user';
|
||||
|
||||
const { Text } = Typography;
|
||||
const MENU = 'SYSTEM_CODE';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 내부 상태 타입 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type GroupModalState =
|
||||
| { open: false }
|
||||
| { open: true; mode: 'create' }
|
||||
| { open: true; mode: 'edit'; group: CommonCodeGroup };
|
||||
|
||||
type CodeModalState =
|
||||
| { open: false }
|
||||
| { open: true; mode: 'create' }
|
||||
| { open: true; mode: 'edit'; code: CommonCode };
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function CodeList() {
|
||||
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['code', 'groups'],
|
||||
queryFn: () => commonCodeApi.groups(),
|
||||
/* 선택 그룹 & 검색어 */
|
||||
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
/* 모달 상태 */
|
||||
const [groupModal, setGroupModal] = useState<GroupModalState>({ open: false });
|
||||
const [codeModal, setCodeModal] = useState<CodeModalState>({ open: false });
|
||||
|
||||
/* 폼 인스턴스 */
|
||||
const [groupForm] = Form.useForm();
|
||||
const [codeForm] = Form.useForm();
|
||||
|
||||
/* 로딩 상태 */
|
||||
const [groupSaving, setGroupSaving] = useState(false);
|
||||
const [codeSaving, setCodeSaving] = useState(false);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 쿼리 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const { data: groups = [], isFetching: groupLoading } = useQuery({
|
||||
queryKey: ['code', 'groups', keyword],
|
||||
queryFn: () => commonCodeApi.groups(keyword || undefined),
|
||||
});
|
||||
|
||||
const { data: codes = [] } = useQuery({
|
||||
const { data: codes = [], isFetching: codeLoading } = useQuery({
|
||||
queryKey: ['code', 'codes', selected?.groupCode],
|
||||
queryFn: () => commonCodeApi.codes(selected!.groupCode),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 공통 리로드 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['code', 'groups'] });
|
||||
const invalidateCodes = (gc: string) =>
|
||||
qc.invalidateQueries({ queryKey: ['code', 'codes', gc] });
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 모달 열기 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const openGroupCreate = () => {
|
||||
groupForm.resetFields();
|
||||
groupForm.setFieldsValue({ isSystem: 'N', sortOrder: 0 });
|
||||
setGroupModal({ open: true, mode: 'create' });
|
||||
};
|
||||
|
||||
const openGroupEdit = (group: CommonCodeGroup) => {
|
||||
groupForm.resetFields();
|
||||
groupForm.setFieldsValue({
|
||||
groupCode: group.groupCode,
|
||||
groupName: group.groupName,
|
||||
description: group.description,
|
||||
isSystem: group.isSystem,
|
||||
sortOrder: group.sortOrder,
|
||||
});
|
||||
setGroupModal({ open: true, mode: 'edit', group });
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 저장 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const handleGroupSave = async () => {
|
||||
if (!groupModal.open) return;
|
||||
try {
|
||||
setGroupSaving(true);
|
||||
const values = await groupForm.validateFields();
|
||||
|
||||
if (groupModal.mode === 'create') {
|
||||
await commonCodeApi.createGroup(values);
|
||||
message.success('그룹이 추가되었습니다.');
|
||||
} else {
|
||||
const { group } = groupModal;
|
||||
await commonCodeApi.updateGroup(group.groupCode, {
|
||||
groupName: values.groupName,
|
||||
description: values.description,
|
||||
});
|
||||
message.success('그룹이 수정되었습니다.');
|
||||
// 선택된 그룹 이름 동기화
|
||||
if (selected?.groupCode === group.groupCode) {
|
||||
setSelected((prev) =>
|
||||
prev ? { ...prev, groupName: values.groupName, description: values.description } : null,
|
||||
);
|
||||
}
|
||||
await commonCodeApi.refreshCache(group.groupCode);
|
||||
}
|
||||
|
||||
await invalidateGroups();
|
||||
setGroupModal({ open: false });
|
||||
} catch {
|
||||
// validateFields 실패 시 폼 에러 표시 — 별도 처리 불필요
|
||||
} finally {
|
||||
setGroupSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 삭제 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const confirmDeleteGroup = (group: CommonCodeGroup) => {
|
||||
Modal.confirm({
|
||||
title: '그룹 삭제',
|
||||
content: (
|
||||
<span>
|
||||
<b>{group.groupName}</b> 그룹과 하위 코드를 모두 삭제합니다.
|
||||
<br />계속하시겠습니까?
|
||||
</span>
|
||||
),
|
||||
okText: '삭제',
|
||||
okType: 'danger',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await commonCodeApi.deleteGroup(group.groupCode);
|
||||
message.success('그룹이 삭제되었습니다.');
|
||||
if (selected?.groupCode === group.groupCode) setSelected(null);
|
||||
await commonCodeApi.refreshCache(group.groupCode);
|
||||
invalidateGroups();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 모달 열기 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const openCodeCreate = () => {
|
||||
codeForm.resetFields();
|
||||
codeForm.setFieldsValue({ isActive: 'Y', sortOrder: 0 });
|
||||
setCodeModal({ open: true, mode: 'create' });
|
||||
};
|
||||
|
||||
const openCodeEdit = (code: CommonCode) => {
|
||||
codeForm.resetFields();
|
||||
codeForm.setFieldsValue({
|
||||
code: code.code,
|
||||
codeName: code.codeName,
|
||||
codeDesc: code.codeDesc,
|
||||
sortOrder: code.sortOrder,
|
||||
isActive: code.isActive,
|
||||
});
|
||||
setCodeModal({ open: true, mode: 'edit', code });
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 저장 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const handleCodeSave = async () => {
|
||||
if (!codeModal.open || !selected) return;
|
||||
try {
|
||||
setCodeSaving(true);
|
||||
const values = await codeForm.validateFields();
|
||||
|
||||
if (codeModal.mode === 'create') {
|
||||
await commonCodeApi.createCode(selected.groupCode, values);
|
||||
message.success('코드가 추가되었습니다.');
|
||||
} else {
|
||||
await commonCodeApi.updateCode(codeModal.code.codeId, values);
|
||||
message.success('코드가 수정되었습니다.');
|
||||
}
|
||||
|
||||
await commonCodeApi.refreshCache(selected.groupCode);
|
||||
invalidateCodes(selected.groupCode);
|
||||
setCodeModal({ open: false });
|
||||
} catch {
|
||||
// 폼 검증 실패 — 별도 처리 불필요
|
||||
} finally {
|
||||
setCodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 삭제 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const confirmDeleteCode = (code: CommonCode) => {
|
||||
Modal.confirm({
|
||||
title: '코드 삭제',
|
||||
content: (
|
||||
<span>
|
||||
코드 <b>{code.code} ({code.codeName})</b>을(를) 삭제합니다.
|
||||
<br />계속하시겠습니까?
|
||||
</span>
|
||||
),
|
||||
okText: '삭제',
|
||||
okType: 'danger',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await commonCodeApi.deleteCode(code.codeId);
|
||||
message.success('코드가 삭제되었습니다.');
|
||||
if (selected) {
|
||||
await commonCodeApi.refreshCache(selected.groupCode);
|
||||
invalidateCodes(selected.groupCode);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 렌더 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
return (
|
||||
<PageContainer title="공통코드 관리">
|
||||
<Row gutter={16}>
|
||||
{/* ======================================================= */}
|
||||
{/* 왼쪽: 그룹 목록 */}
|
||||
{/* ======================================================= */}
|
||||
<Col span={10}>
|
||||
<Card title="그룹" size="small">
|
||||
<Table
|
||||
<Card
|
||||
size="small"
|
||||
title="코드 그룹"
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openGroupCreate}
|
||||
>
|
||||
그룹 추가
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="READ"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={invalidateGroups}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="그룹코드 / 그룹명 검색"
|
||||
allowClear
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
onSearch={(v) => setKeyword(v)}
|
||||
onChange={(e) => { if (!e.target.value) setKeyword(''); }}
|
||||
/>
|
||||
<Table<CommonCodeGroup>
|
||||
rowKey="groupCode"
|
||||
dataSource={groups}
|
||||
loading={groupLoading}
|
||||
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' : ''}
|
||||
scroll={{ y: 520 }}
|
||||
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: 'groupCode', width: 140, ellipsis: true },
|
||||
{ title: '그룹명', dataIndex: 'groupName', ellipsis: true },
|
||||
{
|
||||
title: '시스템', dataIndex: 'isSystem', width: 80, align: 'center',
|
||||
render: (v) => v === 'Y' ? <Tag color="orange">시스템</Tag> : <Tag>일반</Tag>,
|
||||
title: '유형',
|
||||
dataIndex: 'isSystem',
|
||||
width: 68,
|
||||
align: 'center',
|
||||
render: (v: string) =>
|
||||
v === 'Y' ? (
|
||||
<Tag color="orange" style={{ margin: 0, fontSize: 11 }}>시스템</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0, fontSize: 11 }}>일반</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Space size={2} onClick={(e) => e.stopPropagation()}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="UPDATE"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openGroupEdit(r)}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="DELETE"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={r.isSystem === 'Y'}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDeleteGroup(r)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ======================================================= */}
|
||||
{/* 오른쪽: 코드 목록 */}
|
||||
{/* ======================================================= */}
|
||||
<Col span={14}>
|
||||
<Card title={selected ? `상세 코드 — ${selected.groupName}` : '상세 코드'} size="small">
|
||||
<Card
|
||||
size="small"
|
||||
title={selected ? `코드 목록 — ${selected.groupName}` : '코드 목록'}
|
||||
extra={
|
||||
selected && (
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCodeCreate}
|
||||
>
|
||||
코드 추가
|
||||
</PermissionButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!selected ? (
|
||||
<Empty description="그룹을 선택하세요" />
|
||||
<Empty description="왼쪽에서 그룹을 선택하세요" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<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>
|
||||
<>
|
||||
<Table<CommonCode>
|
||||
rowKey="codeId"
|
||||
dataSource={codes}
|
||||
loading={codeLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 520 }}
|
||||
columns={[
|
||||
{ title: '코드', dataIndex: 'code', width: 120, ellipsis: true },
|
||||
{ title: '코드명', dataIndex: 'codeName', width: 130, ellipsis: true },
|
||||
{ title: '설명', dataIndex: 'codeDesc', ellipsis: true },
|
||||
{ title: '정렬', dataIndex: 'sortOrder', width: 52, align: 'right' },
|
||||
{
|
||||
title: '활성',
|
||||
dataIndex: 'isActive',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (v: string) =>
|
||||
v === 'Y' ? (
|
||||
<Tag color="green" style={{ margin: 0 }}>Y</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0 }}>N</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Space size={2}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="UPDATE"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openCodeEdit(r)}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="DELETE"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDeleteCode(r)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{selected.isSystem === 'Y' && (
|
||||
<Text type="warning" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
시스템 그룹의 코드는 식별자 변경·삭제가 제한됩니다.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* 그룹 추가 / 수정 모달 */}
|
||||
{/* ========================================================= */}
|
||||
<Modal
|
||||
title={groupModal.open && groupModal.mode === 'create' ? '그룹 추가' : '그룹 수정'}
|
||||
open={groupModal.open}
|
||||
onOk={handleGroupSave}
|
||||
onCancel={() => setGroupModal({ open: false })}
|
||||
okText={groupModal.open && groupModal.mode === 'create' ? '추가' : '수정'}
|
||||
cancelText="취소"
|
||||
confirmLoading={groupSaving}
|
||||
destroyOnClose
|
||||
width={480}
|
||||
>
|
||||
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="groupCode"
|
||||
label="그룹코드"
|
||||
rules={[
|
||||
{ required: true, message: '그룹코드를 입력하세요' },
|
||||
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="예) AGENT_STATUS"
|
||||
disabled={groupModal.open && groupModal.mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="groupName"
|
||||
label="그룹명"
|
||||
rules={[{ required: true, message: '그룹명을 입력하세요' }]}
|
||||
>
|
||||
<Input placeholder="예) 설계사 상태" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="설명">
|
||||
<Input.TextArea rows={2} placeholder="그룹에 대한 설명을 입력하세요" />
|
||||
</Form.Item>
|
||||
{groupModal.open && groupModal.mode === 'create' && (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isSystem" label="시스템 여부">
|
||||
<Select options={[{ value: 'Y', label: '시스템' }, { value: 'N', label: '일반' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sortOrder" label="정렬 순서">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* 코드 추가 / 수정 모달 */}
|
||||
{/* ========================================================= */}
|
||||
<Modal
|
||||
title={codeModal.open && codeModal.mode === 'create' ? '코드 추가' : '코드 수정'}
|
||||
open={codeModal.open}
|
||||
onOk={handleCodeSave}
|
||||
onCancel={() => setCodeModal({ open: false })}
|
||||
okText={codeModal.open && codeModal.mode === 'create' ? '추가' : '수정'}
|
||||
cancelText="취소"
|
||||
confirmLoading={codeSaving}
|
||||
destroyOnClose
|
||||
width={460}
|
||||
>
|
||||
<Form form={codeForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="코드값"
|
||||
rules={[
|
||||
{ required: true, message: '코드값을 입력하세요' },
|
||||
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="예) ACTIVE"
|
||||
disabled={codeModal.open && codeModal.mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="codeName"
|
||||
label="코드명"
|
||||
rules={[{ required: true, message: '코드명을 입력하세요' }]}
|
||||
>
|
||||
<Input placeholder="예) 활성" />
|
||||
</Form.Item>
|
||||
<Form.Item name="codeDesc" label="설명">
|
||||
<Input.TextArea rows={2} placeholder="코드에 대한 설명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sortOrder" label="정렬 순서">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isActive" label="활성 여부">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'Y', label: '활성' },
|
||||
{ value: 'N', label: '비활성' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user