238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|