guardia-cms/frontend/src/pages/uiws/system/CodeManagement.tsx

238 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import {
codeGroupList, codeGroupDetail, createCodeGroup, updateCodeGroup, deleteCodeGroup,
} from '../../../api/uiws'
import {
PageHeader, Panel, SearchBar, Input, Button, DataGrid, Modal, FormField, Spinner, YnBadge, type Column,
} from '../../../components/uiws/ui'
interface CodeGrp extends Record<string, unknown> { grpCd: string; grpNm: string; useYn: string }
interface CodeValue extends Record<string, unknown> { grpCd?: string; codeVal: string; codeNm: string; sortOrd: number; useYn: string }
interface CodeGrpDetail extends CodeGrp { values: CodeValue[] }
function friendlyError(e: any): string {
if (e?.response?.status === 403) return '권한이 없습니다 (SUPERADMIN 전용).'
if (e?.response?.status === 409) return '이미 존재하는 코드입니다.'
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
}
export default function CodeManagement() {
const [groups, setGroups] = useState<CodeGrp[]>([])
const [activeGrp, setActiveGrp] = useState<string>('')
const [detail, setDetail] = useState<CodeGrpDetail | null>(null)
const [keyword, setKeyword] = useState('')
const [loadingGrp, setLoadingGrp] = useState(false)
const [loadingDetail, setLoadingDetail] = useState(false)
const [err, setErr] = useState('')
const [form, setForm] = useState<{ open: boolean; edit: CodeGrpDetail | null }>({ open: false, edit: null })
const loadGroups = async (kw = '') => {
setLoadingGrp(true); setErr('')
try {
const res = await codeGroupList(kw || undefined)
// CMS 백엔드 그룹목록은 PageResponse → data.content
const list: CodeGrp[] = res.data.data?.content ?? []
setGroups(list)
if (!activeGrp && list.length > 0) setActiveGrp(list[0].grpCd)
} catch (e) { setErr(friendlyError(e)) }
finally { setLoadingGrp(false) }
}
useEffect(() => { loadGroups('') }, [])
const loadDetail = async (grp = activeGrp) => {
if (!grp) { setDetail(null); return }
setLoadingDetail(true)
try {
const res = await codeGroupDetail(grp)
const d = res.data.data
setDetail(d ? { ...d, values: d.values ?? [] } : null)
} catch (e) { setErr(friendlyError(e)) }
finally { setLoadingDetail(false) }
}
useEffect(() => { loadDetail() }, [activeGrp])
const removeGroup = async (grpCd: string) => {
if (!window.confirm(`코드그룹 '${grpCd}' 와 하위 코드를 삭제하시겠습니까?`)) return
try {
await deleteCodeGroup(grpCd)
if (activeGrp === grpCd) { setActiveGrp(''); setDetail(null) }
await loadGroups(keyword)
} catch (e) { setErr(friendlyError(e)) }
}
const codeColumns: Column<CodeValue>[] = [
{ key: 'codeVal', header: '코드값', width: 170 },
{ key: 'codeNm', header: '코드명' },
{ key: 'sortOrd', header: '정렬', width: 70, align: 'center' },
{ key: 'useYn', header: '사용', width: 80, align: 'center', render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
]
return (
<div className="uiws-scope">
<PageHeader title="공통코드 관리" subtitle="시스템관리(권한) · 코드그룹/코드값 일괄 CRUD"
actions={<Button onClick={() => setForm({ open: true, edit: null })}>+ </Button>} />
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginBottom: 12 }}>{err}</div>}
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="코드그룹 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => loadGroups(keyword)}></Button>
</SearchBar>
<div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: 16, alignItems: 'start' }}>
<Panel style={{ padding: 0 }}>
<div style={{ padding: '12px 16px', fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>
</div>
{loadingGrp ? <Spinner /> : (
<div>
{groups.length === 0 && <div style={{ padding: 20, fontSize: 13, color: 'var(--uiws-text-faint)' }}> .</div>}
{groups.map(g => (
<div key={g.grpCd} onClick={() => setActiveGrp(g.grpCd)}
style={{
padding: '11px 16px', cursor: 'pointer', fontSize: 13, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderLeft: `3px solid ${activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'transparent'}`,
background: activeGrp === g.grpCd ? 'var(--uiws-primary-soft)' : 'transparent',
}}>
<div>
<div style={{ fontWeight: 600, color: activeGrp === g.grpCd ? 'var(--uiws-primary)' : 'var(--uiws-text)' }}>{g.grpNm}</div>
<div style={{ fontSize: 11, color: 'var(--uiws-text-muted)' }}>{g.grpCd}</div>
</div>
<div style={{ display: 'flex', gap: 6 }} onClick={e => e.stopPropagation()}>
<Button variant="ghost" onClick={() => removeGroup(g.grpCd)}></Button>
</div>
</div>
))}
</div>
)}
</Panel>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--uiws-text)' }}>
{activeGrp && <span style={{ color: 'var(--uiws-text-muted)', fontWeight: 400 }}>({activeGrp})</span>}
</div>
<Button onClick={() => detail && setForm({ open: true, edit: detail })} disabled={!detail}>· </Button>
</div>
{loadingDetail ? <Spinner /> : (
<DataGrid columns={codeColumns} rows={detail?.values ?? []} rowKey={r => `${activeGrp}:${r.codeVal}`}
empty={activeGrp ? '코드값이 없습니다.' : '코드그룹을 선택하세요.'} />
)}
</div>
</div>
{form.open && (
<CodeGroupForm grp={form.edit} onClose={() => setForm({ open: false, edit: null })}
onSaved={async (grpCd) => {
setForm({ open: false, edit: null })
await loadGroups(keyword)
if (grpCd) { setActiveGrp(grpCd); await loadDetail(grpCd) }
else await loadDetail()
}} />
)}
</div>
)
}
function SelectYn({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)' }}>
<option value="Y"></option>
<option value="N"></option>
</select>
)
}
function CodeGroupForm({ grp, onClose, onSaved }: { grp: CodeGrpDetail | null; onClose: () => void; onSaved: (grpCd: string) => void }) {
const editing = !!grp
const [grpCd, setGrpCd] = useState(grp?.grpCd ?? '')
const [grpNm, setGrpNm] = useState(grp?.grpNm ?? '')
const [useYn, setUseYn] = useState(grp?.useYn ?? 'Y')
const [values, setValues] = useState<CodeValue[]>(grp?.values ? grp.values.map(v => ({ ...v })) : [])
const [err, setErr] = useState('')
const addValue = () => setValues(prev => [...prev, { codeVal: '', codeNm: '', sortOrd: prev.length + 1, useYn: 'Y' }])
const updateValue = (idx: number, field: keyof CodeValue, val: string | number) =>
setValues(prev => prev.map((v, i) => (i === idx ? { ...v, [field]: val } : v)))
const removeValue = (idx: number) => setValues(prev => prev.filter((_, i) => i !== idx))
const save = async () => {
setErr('')
if (!editing && !grpCd.trim()) { setErr('그룹코드는 필수입니다.'); return }
if (!grpNm.trim()) { setErr('그룹명은 필수입니다.'); return }
for (const v of values) {
if (!v.codeVal.trim() || !v.codeNm.trim()) { setErr('코드값과 코드명은 필수입니다.'); return }
}
try {
const body = {
grpCd, grpNm, useYn,
values: values.map(v => ({ grpCd, codeVal: v.codeVal, codeNm: v.codeNm, sortOrd: Number(v.sortOrd) || 0, useYn: v.useYn })),
}
editing ? await updateCodeGroup(grpCd, body) : await createCodeGroup(body)
onSaved(grpCd)
} catch (e) { setErr(friendlyError(e)) }
}
const cellInput: React.CSSProperties = {
width: '100%', padding: '6px 8px', borderRadius: 6, fontSize: 12,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)',
}
return (
<Modal title={editing ? '코드그룹·코드값 편집' : '코드그룹 등록'} onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="그룹코드"><Input value={grpCd} onChange={setGrpCd} style={{ width: '100%', opacity: editing ? 0.6 : 1 }} /></FormField>
<FormField label="그룹명"><Input value={grpNm} onChange={setGrpNm} style={{ width: '100%' }} /></FormField>
<FormField label="사용 여부"><SelectYn value={useYn} onChange={setUseYn} /></FormField>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--uiws-text-muted)' }}> ({values.length})</div>
<Button variant="ghost" onClick={addValue}>+ </Button>
</div>
<div style={{ border: '1px solid var(--uiws-border)', borderRadius: 8, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', textAlign: 'left', color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 64, color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 76, color: 'var(--uiws-text-muted)', fontWeight: 600 }}></th>
<th style={{ padding: '7px 8px', width: 44 }} />
</tr>
</thead>
<tbody>
{values.length === 0 ? (
<tr><td colSpan={5} style={{ padding: 16, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</td></tr>
) : values.map((v, idx) => (
<tr key={idx} style={{ borderTop: '1px solid var(--uiws-border)' }}>
<td style={{ padding: '5px 8px' }}>
<input value={v.codeVal} onChange={e => updateValue(idx, 'codeVal', e.target.value)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<input value={v.codeNm} onChange={e => updateValue(idx, 'codeNm', e.target.value)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<input type="number" value={String(v.sortOrd)} onChange={e => updateValue(idx, 'sortOrd', Number(e.target.value) || 0)} style={cellInput} />
</td>
<td style={{ padding: '5px 8px' }}>
<select value={v.useYn} onChange={e => updateValue(idx, 'useYn', e.target.value)} style={cellInput}>
<option value="Y"></option>
<option value="N"></option>
</select>
</td>
<td style={{ padding: '5px 8px', textAlign: 'center' }}>
<button onClick={() => removeValue(idx)}
style={{ background: 'none', border: 'none', color: 'var(--uiws-danger)', cursor: 'pointer', fontSize: 16 }}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13, marginTop: 10 }}>{err}</div>}
</Modal>
)
}