134 lines
5.8 KiB
TypeScript
134 lines
5.8 KiB
TypeScript
// 제네릭 CRUD 페이지 — 목록/검색/추가/수정/삭제 공통. 각 도메인 페이지에서 columns + form 스펙만 정의.
|
|
import { useState, type ReactNode } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { PageHeader, Toolbar, Table, Modal, Button, Field, TextInput, Select, type Column } from './ui'
|
|
|
|
export interface FormFieldSpec {
|
|
key: string
|
|
label: string
|
|
type?: 'text' | 'number' | 'select' | 'textarea' | 'checkbox' | 'date' | 'time' | 'datetime-local'
|
|
opts?: string[] | { value: string; label: string }[]
|
|
placeholder?: string
|
|
required?: boolean
|
|
}
|
|
|
|
interface CrudProps<T extends Record<string, any>> {
|
|
title: string
|
|
subtitle?: string
|
|
addLabel?: string
|
|
queryKey: any[]
|
|
fetcher: () => Promise<T[]>
|
|
onCreate?: (d: any) => Promise<any>
|
|
onUpdate?: (id: number, d: any) => Promise<any>
|
|
onDelete?: (id: number) => Promise<any>
|
|
columns: Column<T>[]
|
|
form?: FormFieldSpec[]
|
|
defaults?: Record<string, any>
|
|
toolbarExtra?: ReactNode
|
|
rowActions?: (row: T) => ReactNode
|
|
readOnly?: boolean
|
|
}
|
|
|
|
export function CrudPage<T extends Record<string, any>>(p: CrudProps<T>) {
|
|
const qc = useQueryClient()
|
|
const [modal, setModal] = useState<any | null>(null)
|
|
const { data: rows = [], isLoading } = useQuery({ queryKey: p.queryKey, queryFn: p.fetcher })
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: (d: any) => (d.id && p.onUpdate ? p.onUpdate(d.id, d) : p.onCreate!(d)),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: [p.queryKey[0]] }); setModal(null) },
|
|
})
|
|
const delMut = useMutation({
|
|
mutationFn: (id: number) => p.onDelete!(id),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: [p.queryKey[0]] }),
|
|
})
|
|
|
|
const columns: Column<T>[] = [...p.columns]
|
|
if (!p.readOnly && (p.form || p.rowActions)) {
|
|
columns.push({
|
|
key: '__act', header: '', align: 'right',
|
|
render: (row: any) => (
|
|
<div className="flex gap-2 justify-end">
|
|
{p.rowActions?.(row)}
|
|
{p.form && p.onUpdate && (
|
|
<button onClick={() => setModal(row)} className="text-brand hover:text-blue-300" title="수정">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 20h4l10-10-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/></svg>
|
|
</button>
|
|
)}
|
|
{p.onDelete && (
|
|
<button onClick={() => { if (confirm('삭제하시겠습니까?')) delMut.mutate(row.id) }} className="text-red-400 hover:text-red-300" title="삭제">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
),
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title={p.title} subtitle={p.subtitle}
|
|
actions={!p.readOnly && p.form && p.onCreate && (
|
|
<Button icon="plus" onClick={() => setModal({ ...(p.defaults || {}) })}>{p.addLabel || '추가'}</Button>
|
|
)} />
|
|
{p.toolbarExtra && <Toolbar>{p.toolbarExtra}</Toolbar>}
|
|
{isLoading ? <div className="p-8 text-center text-gray-400 text-sm">로딩 중...</div>
|
|
: <Table columns={columns} rows={rows as T[]} rowKey={(r: any) => r.id} />}
|
|
|
|
{modal && p.form && (
|
|
<Modal title={modal.id ? '수정' : (p.addLabel || '추가')} onClose={() => setModal(null)}>
|
|
<CrudForm spec={p.form} initial={modal} onSave={d => saveMut.mutate(d)} saving={saveMut.isPending} />
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function CrudForm({ spec, initial, onSave, saving }: {
|
|
spec: FormFieldSpec[]; initial: any; onSave: (d: any) => void; saving?: boolean
|
|
}) {
|
|
const init: any = { id: initial.id }
|
|
spec.forEach(f => {
|
|
init[f.key] = initial[f.key] ?? (f.type === 'checkbox' ? true : f.type === 'number' ? '' : '')
|
|
})
|
|
const [f, setF] = useState<any>(init)
|
|
const set = (k: string, v: any) => setF((p: any) => ({ ...p, [k]: v }))
|
|
|
|
return (
|
|
<form onSubmit={e => { e.preventDefault(); onSave(f) }}>
|
|
{spec.map(fs => (
|
|
<Field key={fs.key} label={fs.label}>
|
|
{fs.type === 'select' ? (
|
|
<Select className="w-full" value={String(f[fs.key] ?? '')} onChange={v => set(fs.key, v)}>
|
|
<option value="">선택</option>
|
|
{(fs.opts || []).map(o => {
|
|
const val = typeof o === 'string' ? o : o.value
|
|
const lab = typeof o === 'string' ? o : o.label
|
|
return <option key={val} value={val}>{lab}</option>
|
|
})}
|
|
</Select>
|
|
) : fs.type === 'textarea' ? (
|
|
<textarea value={f[fs.key] ?? ''} onChange={e => set(fs.key, e.target.value)} rows={4}
|
|
placeholder={fs.placeholder}
|
|
className="w-full bg-ink border border-edge rounded px-3 py-2 text-sm text-white font-mono outline-none focus:border-brand" />
|
|
) : fs.type === 'checkbox' ? (
|
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
|
<input type="checkbox" checked={!!f[fs.key]} onChange={e => set(fs.key, e.target.checked)} />
|
|
활성
|
|
</label>
|
|
) : (
|
|
<TextInput className="w-full"
|
|
type={fs.type === 'number' ? 'number' : (fs.type === 'date' || fs.type === 'time' || fs.type === 'datetime-local') ? fs.type : 'text'}
|
|
value={String(f[fs.key] ?? '')} onChange={v => set(fs.key, fs.type === 'number' ? (v === '' ? '' : Number(v)) : v)}
|
|
placeholder={fs.placeholder} />
|
|
)}
|
|
</Field>
|
|
))}
|
|
<button type="submit" disabled={saving}
|
|
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium disabled:opacity-50 mt-1">
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|