feat: enhance SpecialtyPicker to support parent-child relationship for specialties

This commit is contained in:
hamed
2026-06-12 19:32:56 +03:30
parent 762a4794cf
commit 9e8064e101
2 changed files with 80 additions and 20 deletions
+9 -2
View File
@@ -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> },
+71 -18
View File
@@ -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>
);