Files
ga-commission-system/ga-frontend/src/pages/system/CodeList.tsx
T
ysukkyu 88e14edffd 1
2026-05-14 18:14:49 +09:00

540 lines
20 KiB
TypeScript

import { useState } from 'react';
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 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 qc = useQueryClient();
/* 선택 그룹 & 검색어 */
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 = [], 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
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: 520 }}
onRow={(r) => ({
onClick: () => setSelected(r),
style: { cursor: 'pointer' },
})}
rowClassName={(r) =>
r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''
}
columns={[
{ title: '그룹코드', dataIndex: 'groupCode', width: 140, ellipsis: true },
{ title: '그룹명', dataIndex: 'groupName', ellipsis: true },
{
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
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="왼쪽에서 그룹을 선택하세요" style={{ padding: '40px 0' }} />
) : (
<>
<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>
);
}