guardia-signage/frontend/src/components/Crud.tsx
2026-07-05 08:43:20 +09:00

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>
)
}