159 lines
8.5 KiB
TypeScript
159 lines
8.5 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { codeList, codeGroupGet, codeCreate, codeUpdate, codeDelete } from '../../../api/uiws'
|
||
import { PageHeader, SearchBar, Panel, Button, Modal, FormField, Input, Spinner, Pagination, YnBadge, DataGrid, type Column } from '../../../components/uiws/ui'
|
||
import AdminGate from './AdminGate'
|
||
|
||
/**
|
||
* 2.7 코드(공통코드) 관리 — /api/system/codes. 그룹 목록(PageResponse) + 상세(그룹+코드값 목록) 편집.
|
||
* 그룹 단위 저장(values 포함). RBAC 변경 ADMIN/MANAGER(403 + AdminGate). 색상 토큰만.
|
||
*/
|
||
type CodeGrp = { grpCd: string; grpNm: string; useYn: string }
|
||
type CodeValue = { grpCd: string; codeVal: string; codeNm: string; sortOrd: number | null; useYn: string }
|
||
type CodeGrpDetail = CodeGrp & { values: CodeValue[] }
|
||
const PSIZE = 20
|
||
|
||
function friendlyError(e: any): string {
|
||
if (e?.response?.status === 403) return '권한이 없습니다 (관리자 전용).'
|
||
if (e?.response?.status === 409) return '이미 존재하는 코드 그룹입니다.'
|
||
return e?.response?.data?.message || '요청을 처리하지 못했습니다.'
|
||
}
|
||
|
||
export default function CodeManagement() {
|
||
const [rows, setRows] = useState<CodeGrp[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [page, setPage] = useState(0)
|
||
const [kw, setKw] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [open, setOpen] = useState(false)
|
||
const [editing, setEditing] = useState(false)
|
||
const [form, setForm] = useState<CodeGrpDetail>({ grpCd: '', grpNm: '', useYn: 'Y', values: [] })
|
||
const [err, setErr] = useState('')
|
||
|
||
async function load(p = page, keyword = kw) {
|
||
setLoading(true)
|
||
try {
|
||
const res = await codeList({ keyword: keyword || undefined, page: p, size: PSIZE })
|
||
setRows(res.data?.data?.content || [])
|
||
setTotal(res.data?.data?.total || 0)
|
||
} catch { setRows([]); setTotal(0) } finally { setLoading(false) }
|
||
}
|
||
useEffect(() => { load(0, '') }, [])
|
||
|
||
function openCreate() {
|
||
setForm({ grpCd: '', grpNm: '', useYn: 'Y', values: [] }); setEditing(false); setErr(''); setOpen(true)
|
||
}
|
||
async function openEdit(g: CodeGrp) {
|
||
setErr('')
|
||
try {
|
||
const res = await codeGroupGet(g.grpCd)
|
||
const d = res.data?.data
|
||
setForm({ grpCd: d.grpCd, grpNm: d.grpNm, useYn: d.useYn, values: d.values || [] })
|
||
setEditing(true); setOpen(true)
|
||
} catch (e) { alert(friendlyError(e)) }
|
||
}
|
||
|
||
function addValue() {
|
||
setForm(f => ({ ...f, values: [...f.values, { grpCd: f.grpCd, codeVal: '', codeNm: '', sortOrd: f.values.length + 1, useYn: 'Y' }] }))
|
||
}
|
||
function setVal(i: number, patch: Partial<CodeValue>) {
|
||
setForm(f => ({ ...f, values: f.values.map((v, idx) => idx === i ? { ...v, ...patch } : v) }))
|
||
}
|
||
function removeValue(i: number) {
|
||
setForm(f => ({ ...f, values: f.values.filter((_, idx) => idx !== i) }))
|
||
}
|
||
|
||
async function save() {
|
||
setErr('')
|
||
if (!form.grpCd.trim() || !form.grpNm.trim()) { setErr('그룹 코드와 이름은 필수입니다.'); return }
|
||
const body = { ...form, values: form.values.map(v => ({ ...v, grpCd: form.grpCd })) }
|
||
try {
|
||
if (editing) await codeUpdate(form.grpCd, body)
|
||
else await codeCreate(body)
|
||
setOpen(false); load()
|
||
} catch (e) { setErr(friendlyError(e)) }
|
||
}
|
||
|
||
async function remove(grpCd: string) {
|
||
if (!confirm(`코드 그룹 '${grpCd}' 을(를) 삭제할까요?`)) return
|
||
try { await codeDelete(grpCd); load() } catch (e) { alert(friendlyError(e)) }
|
||
}
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / PSIZE))
|
||
const columns: Column<CodeGrp & Record<string, unknown>>[] = [
|
||
{ key: 'grpCd', header: '그룹 코드', render: r => <span className="font-mono">{r.grpCd}</span> },
|
||
{ key: 'grpNm', header: '그룹명' },
|
||
{ key: 'useYn', header: '사용', width: 80, render: r => <YnBadge yn={r.useYn} yes="사용" no="미사용" /> },
|
||
{ key: 'act', header: '', width: 64, align: 'right', render: r => (
|
||
<button onClick={e => { e.stopPropagation(); remove(r.grpCd) }} className="text-xs" style={{ color: 'var(--uiws-danger)' }}>삭제</button>
|
||
) },
|
||
]
|
||
|
||
return (
|
||
<AdminGate>
|
||
<div className="uiws-scope">
|
||
<PageHeader title="공통코드 관리" subtitle="코드 그룹 및 코드값 정의"
|
||
actions={<Button onClick={openCreate}>그룹 추가</Button>} />
|
||
|
||
<SearchBar>
|
||
<Input value={kw} onChange={setKw} placeholder="그룹코드/그룹명 검색" style={{ flex: 1, minWidth: 220 }} />
|
||
<Button onClick={() => { setPage(0); load(0, kw) }}>검색</Button>
|
||
</SearchBar>
|
||
|
||
{loading ? <Spinner /> : (
|
||
<>
|
||
<DataGrid columns={columns} rows={rows as (CodeGrp & Record<string, unknown>)[]} rowKey={r => r.grpCd}
|
||
onRowClick={r => openEdit(r)} empty="등록된 코드 그룹이 없습니다." />
|
||
<Pagination page={page} totalPages={totalPages} onChange={p => { setPage(p); load(p) }} />
|
||
</>
|
||
)}
|
||
|
||
{open && (
|
||
<Modal title={editing ? '코드 그룹 수정' : '코드 그룹 추가'} onClose={() => setOpen(false)}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||
<FormField label="그룹 코드"><Input value={form.grpCd} onChange={v => setForm({ ...form, grpCd: v })} placeholder="예: ALARM_TYPE" style={{ width: '100%' }} /></FormField>
|
||
<FormField label="그룹명"><Input value={form.grpNm} onChange={v => setForm({ ...form, grpNm: v })} style={{ width: '100%' }} /></FormField>
|
||
</div>
|
||
<FormField label="사용 여부"><Input value={form.useYn} onChange={v => setForm({ ...form, useYn: v })} placeholder="Y / N" /></FormField>
|
||
|
||
<div style={{ marginTop: 8, marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: 'var(--uiws-text-muted)' }}>코드값</span>
|
||
<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 }}>
|
||
<thead>
|
||
<tr style={{ background: 'var(--uiws-surface-2)', color: 'var(--uiws-text-muted)' }}>
|
||
<th style={{ padding: '7px 8px', textAlign: 'left' }}>코드값</th>
|
||
<th style={{ padding: '7px 8px', textAlign: 'left' }}>코드명</th>
|
||
<th style={{ padding: '7px 8px', textAlign: 'left', width: 60 }}>순서</th>
|
||
<th style={{ padding: '7px 8px', textAlign: 'left', width: 50 }}>사용</th>
|
||
<th style={{ width: 36 }} />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{form.values.length === 0 ? (
|
||
<tr><td colSpan={5} style={{ padding: 14, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>코드값이 없습니다.</td></tr>
|
||
) : form.values.map((v, i) => (
|
||
<tr key={i} style={{ borderTop: '1px solid var(--uiws-border)' }}>
|
||
<td style={{ padding: 4 }}><Input value={v.codeVal} onChange={x => setVal(i, { codeVal: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
|
||
<td style={{ padding: 4 }}><Input value={v.codeNm} onChange={x => setVal(i, { codeNm: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
|
||
<td style={{ padding: 4 }}><Input type="number" value={String(v.sortOrd ?? '')} onChange={x => setVal(i, { sortOrd: x === '' ? null : Number(x) })} style={{ width: '100%', padding: '6px 8px' }} /></td>
|
||
<td style={{ padding: 4 }}><Input value={v.useYn} onChange={x => setVal(i, { useYn: x })} style={{ width: '100%', padding: '6px 8px' }} /></td>
|
||
<td style={{ textAlign: 'center' }}><button onClick={() => removeValue(i)} style={{ background: 'none', border: 'none', color: 'var(--uiws-danger)', cursor: 'pointer' }}>×</button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{err && <p className="text-xs" style={{ color: 'var(--uiws-danger)', marginTop: 8 }}>{err}</p>}
|
||
<div className="flex justify-end gap-2 pt-3">
|
||
<Button variant="ghost" onClick={() => setOpen(false)}>취소</Button>
|
||
<Button onClick={save}>저장</Button>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
</AdminGate>
|
||
)
|
||
}
|