feat: enhance SpecialtyPicker to support parent-child relationship for specialties
This commit is contained in:
@@ -457,9 +457,16 @@ function SpecialtiesTab() {
|
||||
queryFn: () => api.get<PaginatedResponse<SpecialtyFull>>(`/api/v1/admin/specialties?page=${page}&limit=20&search=${encodeURIComponent(search)}`),
|
||||
});
|
||||
|
||||
const { data: rootData } = useQuery({
|
||||
queryKey: ['admin-specialties-roots'],
|
||||
queryFn: () => api.get<PaginatedResponse<SpecialtyFull>>('/api/v1/admin/specialties?limit=100'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
const parentMap = Object.fromEntries(items.map((s) => [s.id, s.name]));
|
||||
const rootItems = (rootData?.data ?? []).filter((s) => s.parent_id === null);
|
||||
const parentMap = Object.fromEntries((rootData?.data ?? []).map((s) => [s.id, s.name]));
|
||||
|
||||
const { register, handleSubmit, reset, control, formState: { errors } } = useForm<SpecialtyForm>({
|
||||
resolver: zodResolver(specialtySchema),
|
||||
@@ -491,7 +498,7 @@ function SpecialtiesTab() {
|
||||
reset({ name: s.name, weight: String(s.weight), status: String(s.status), parent_id: s.parent_id ?? null });
|
||||
};
|
||||
|
||||
const parentOptions = items.filter((s) => s.parent_id === null).map((s) => ({ value: s.id, label: s.name }));
|
||||
const parentOptions = rootItems.map((s) => ({ value: s.id, label: s.name }));
|
||||
|
||||
const columns: Column<SpecialtyFull>[] = [
|
||||
{ key: 'id', header: 'شناسه', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.id}</span> },
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formatNumber } from '../lib/utils';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SpecialtyOption { id: number; uuid: string; name: string }
|
||||
interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null }
|
||||
|
||||
// ── Schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -47,21 +47,26 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
|
||||
selected: number[]; onChange: (ids: number[]) => void;
|
||||
specialties: SpecialtyOption[];
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const filtered = useMemo(() => specialties.filter(s => s.name.includes(q)), [specialties, q]);
|
||||
const [activeParentId, setActiveParentId] = useState<number | null>(null);
|
||||
|
||||
const parents = useMemo(
|
||||
() => specialties.filter(s => s.parent_id === null),
|
||||
[specialties]
|
||||
);
|
||||
|
||||
const children = useMemo(
|
||||
() => activeParentId !== null ? specialties.filter(s => s.parent_id === activeParentId) : [],
|
||||
[specialties, activeParentId]
|
||||
);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60">
|
||||
<input type="text" value={q} onChange={e => setQ(e.target.value)}
|
||||
placeholder="جستجوی تخصص..."
|
||||
className="cp-input text-sm h-8" />
|
||||
</div>
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
|
||||
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1 bg-slate-50 dark:bg-gray-800/40">
|
||||
{selected.map(id => {
|
||||
const s = specialties.find(x => x.id === id);
|
||||
if (!s) return null;
|
||||
@@ -74,15 +79,63 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{filtered.slice(0, 60).map(s => (
|
||||
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
|
||||
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggle(s.id)}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{filtered.length === 0 && <p className="text-xs text-slate-400 text-center py-4">نتیجهای یافت نشد</p>}
|
||||
|
||||
<div className="flex" style={{ minHeight: 200 }}>
|
||||
{/* Parents column */}
|
||||
<div className="w-1/2 border-l border-slate-100 dark:border-gray-700 overflow-y-auto max-h-64">
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
|
||||
گروه تخصص
|
||||
</div>
|
||||
{parents.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setActiveParentId(p.id)}
|
||||
className="w-full text-right flex items-center gap-2 px-3 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
background: activeParentId === p.id ? 'var(--primary-50, #eff6ff)' : 'transparent',
|
||||
color: activeParentId === p.id ? 'var(--primary-700, #1d4ed8)' : undefined,
|
||||
fontWeight: activeParentId === p.id ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{p.name}</span>
|
||||
{specialties.some(s => s.parent_id === p.id && selected.includes(s.id)) && (
|
||||
<span className="w-2 h-2 rounded-full bg-primary-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">›</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Children column */}
|
||||
<div className="w-1/2 overflow-y-auto max-h-64">
|
||||
{activeParentId === null ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500 text-center px-4">
|
||||
یک گروه تخصص را از سمت راست انتخاب کنید
|
||||
</div>
|
||||
) : children.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500">
|
||||
زیرمجموعهای یافت نشد
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
|
||||
انتخاب تخصص
|
||||
</div>
|
||||
{children.map(s => (
|
||||
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(s.id)}
|
||||
onChange={() => toggle(s.id)}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user