Files
clinicpro/assets/admin/pages/CategoriesPage.tsx
T
hamed 06ab875693 feat: enhance RepresentationsPage with searchable city filter and form improvements
- Updated RepresentationsPage to use SearchableSelect for city filtering.
- Modified form handling to use Controller from react-hook-form for city selection.
- Improved city filter handling to support null values.
- Added city_id to Representation interface in types.
- Implemented uploadLogo endpoint in CategoryController for logo uploads.
- Added logo_url field to Category entity and updated related services.
- Created SearchableSelect component for better user experience in selecting options.
- Added migration to include logo_url in categories table.
2026-06-10 13:02:47 +03:30

791 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
CheckCircleIcon, XCircleIcon, PhotoIcon, XMarkIcon,
} from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Category, CategoryBundle, Representation } from '../types';
import { useAuthStore } from '../stores/authStore';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { Column } from '../components/ui/DataTable';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
// ── Tab config ──────────────────────────────────────────────────────────────
interface TabConfig {
key: CategoryBundle;
label: string;
icon: React.ElementType;
color: string;
description: string;
hasParent: boolean;
parentBundle?: CategoryBundle;
parentLabel?: string;
hasLogo: boolean;
hasCityFields: boolean;
hasWeight: boolean;
}
const TABS: TabConfig[] = [
{
key: 'state',
label: 'استان‌ها',
icon: MapPinIcon,
color: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10',
description: 'مدیریت استان‌ها',
hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true,
},
{
key: 'city',
label: 'شهرها',
icon: BuildingOffice2Icon,
color: 'text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-400/10',
description: 'مدیریت شهرها و اطلاعات تفصیلی هر شهر',
hasParent: true, parentBundle: 'state', parentLabel: 'استان',
hasLogo: false, hasCityFields: true, hasWeight: true,
},
{
key: 'specially_doctor',
label: 'تخصص‌های پزشکی',
icon: HeartIcon,
color: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-400/10',
description: 'تخصص‌ها و زیرتخصص‌های پزشکی',
hasParent: true, parentBundle: 'specially_doctor', parentLabel: 'تخصص والد',
hasLogo: false, hasCityFields: false, hasWeight: true,
},
{
key: 'doctor_services',
label: 'خدمات پزشک',
icon: WrenchScrewdriverIcon,
color: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10',
description: 'خدمات قابل ارائه توسط پزشکان',
hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true,
},
{
key: 'insurance_type',
label: 'بیمه پایه',
icon: ShieldCheckIcon,
color: 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-400/10',
description: 'انواع بیمه‌های پایه درمانی',
hasParent: false, hasLogo: true, hasCityFields: false, hasWeight: false,
},
{
key: 'supplementary_insurance',
label: 'بیمه تکمیلی',
icon: ShieldCheckIcon,
color: 'text-cyan-600 dark:text-cyan-400 bg-cyan-50 dark:bg-cyan-400/10',
description: 'انواع بیمه‌های تکمیلی درمانی',
hasParent: false, hasLogo: true, hasCityFields: false, hasWeight: false,
},
{
key: 'tag',
label: 'تگ‌های بلاگ',
icon: TagIcon,
color: 'text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-400/10',
description: 'تگ‌های مورد استفاده در مطالب بلاگ',
hasParent: false, hasLogo: false, hasCityFields: false, hasWeight: true,
},
];
// ── Zod schema ──────────────────────────────────────────────────────────────
const baseSchema = z.object({
label: z.string().min(1, 'نام الزامی است'),
status: z.string().optional(),
weight: z.string().optional(),
parent_id: z.number().nullable().optional(),
representation_id: z.number().nullable().optional(),
title: z.string().optional(),
contact_phone: z.string().optional(),
email: z.string().optional(),
description: z.string().optional(),
slogan: z.string().optional(),
domain: z.string().optional(),
keywords: z.string().optional(),
footer_description: z.string().optional(),
footer_disclaimer: z.string().optional(),
});
type FormData = z.infer<typeof baseSchema>;
// ── Logo uploader ────────────────────────────────────────────────────────────
function LogoUploadField({
value,
onChange,
}: {
value: string | null;
onChange: (url: string | null) => void;
}) {
const [uploading, setUploading] = useState(false);
const token = useAuthStore((s) => s.token);
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const res = await fetch('/api/v1/admin/category/upload-logo', {
method: 'POST',
headers: {
'Content-Disposition': `attachment; filename="${file.name}"`,
'Content-Type': 'application/octet-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: file,
});
const json = await res.json();
if (json.success) {
onChange(json.data.url);
toast.success('لگو آپلود شد');
} else {
toast.error(json.errors?.[0]?.message ?? 'خطا در آپلود');
}
} catch {
toast.error('خطا در آپلود تصویر');
} finally {
setUploading(false);
e.target.value = '';
}
};
return (
<div className="flex items-center gap-3">
{value ? (
<div className="relative group">
<img
src={value}
alt="logo"
className="w-16 h-16 object-contain rounded-xl border border-slate-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-1"
/>
<button
type="button"
onClick={() => onChange(null)}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 hover:bg-red-600 rounded-full text-white flex items-center justify-center shadow transition-colors"
>
<XMarkIcon className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-16 h-16 rounded-xl border-2 border-dashed border-slate-300 dark:border-gray-600 flex items-center justify-center bg-slate-50 dark:bg-gray-800">
<PhotoIcon className="w-6 h-6 text-slate-300 dark:text-gray-600" />
</div>
)}
<label className={`cursor-pointer cp-btn-secondary text-sm ${uploading ? 'opacity-60 pointer-events-none' : ''}`}>
{uploading ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
</svg>
در حال آپلود...
</span>
) : (
<span className="flex items-center gap-2">
<PhotoIcon className="w-4 h-4" />
{value ? 'تغییر لگو' : 'انتخاب لگو'}
</span>
)}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
disabled={uploading}
/>
</label>
<p className="text-xs text-slate-400 dark:text-slate-500">JPG، PNG یا WebP حداکثر ۵ مگابایت</p>
</div>
);
}
// ── Helper to load parent list ──────────────────────────────────────────────
function useBundleList(bundle: CategoryBundle | undefined) {
return useQuery({
queryKey: ['categories', bundle],
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${bundle}`),
enabled: !!bundle,
staleTime: 60_000,
});
}
// ── Dynamic form fields ─────────────────────────────────────────────────────
interface FormFieldsProps {
register: ReturnType<typeof useForm<FormData>>['register'];
control: ReturnType<typeof useForm<FormData>>['control'];
errors: ReturnType<typeof useForm<FormData>>['formState']['errors'];
tab: TabConfig;
parentOptions: { value: number; label: string }[];
representationOptions: { value: number; label: string }[];
parentLoading: boolean;
representationLoading: boolean;
logoUrl: string | null;
onLogoChange: (url: string | null) => void;
}
function CategoryFormFields({
register, control, errors, tab,
parentOptions, representationOptions,
parentLoading, representationLoading,
logoUrl, onLogoChange,
}: FormFieldsProps) {
return (
<div className="space-y-4">
{/* Logo upload (insurance bundles) */}
{tab.hasLogo && (
<div>
<label className="cp-label">لگو</label>
<LogoUploadField value={logoUrl} onChange={onLogoChange} />
</div>
)}
{/* Label */}
<div>
<label className="cp-label">نام <span className="text-red-500">*</span></label>
<input {...register('label')} className="cp-input h-11" placeholder="نام دسته‌بندی" />
{errors.label && <p className="text-red-500 text-xs mt-1">{errors.label.message}</p>}
</div>
{/* Parent selector */}
{tab.hasParent && (
<div>
<label className="cp-label">{tab.parentLabel}</label>
<Controller
name="parent_id"
control={control}
render={({ field }) => (
<SearchableSelect
options={parentOptions}
value={field.value ?? null}
onChange={(val) => field.onChange(val as number | null)}
placeholder="-- بدون والد --"
isClearable
isLoading={parentLoading}
noOptionsMessage="موردی یافت نشد"
/>
)}
/>
</div>
)}
{/* Representation selector (city only) */}
{tab.key === 'city' && (
<div>
<label className="cp-label">نماینده</label>
<Controller
name="representation_id"
control={control}
render={({ field }) => (
<SearchableSelect
options={representationOptions}
value={field.value ?? null}
onChange={(val) => field.onChange(val as number | null)}
placeholder="انتخاب نماینده..."
isClearable
isLoading={representationLoading}
noOptionsMessage="هیچ نماینده‌ای یافت نشد"
/>
)}
/>
</div>
)}
{/* Title (city) */}
{tab.key === 'city' && (
<div>
<label className="cp-label">عنوان صفحه</label>
<input {...register('title')} className="cp-input h-11" placeholder="عنوان نمایشی" />
</div>
)}
{/* City-specific fields */}
{tab.hasCityFields && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="cp-label">تلفن تماس</label>
<input {...register('contact_phone')} className="cp-input h-10" dir="ltr" placeholder="021xxxxxxxx" />
</div>
<div>
<label className="cp-label">ایمیل</label>
<input {...register('email')} type="email" className="cp-input h-10" dir="ltr" placeholder="city@example.com" />
</div>
</div>
<div>
<label className="cp-label">دامنه</label>
<input {...register('domain')} className="cp-input h-10" dir="ltr" placeholder="city.example.com" />
</div>
<div>
<label className="cp-label">شعار (slogan)</label>
<input {...register('slogan')} className="cp-input h-10" placeholder="شعار شهر" />
</div>
<div>
<label className="cp-label">کلیدواژه‌ها</label>
<input {...register('keywords')} className="cp-input h-10" placeholder="کلیدواژه، کلیدواژه، ..." />
</div>
<div>
<label className="cp-label">توضیحات</label>
<textarea {...register('description')} rows={3} className="cp-textarea" placeholder="توضیحات شهر" />
</div>
<div>
<label className="cp-label">توضیحات فوتر</label>
<textarea {...register('footer_description')} rows={2} className="cp-textarea" placeholder="متن فوتر شهر" />
</div>
<div>
<label className="cp-label">سلب مسئولیت فوتر</label>
<textarea {...register('footer_disclaimer')} rows={2} className="cp-textarea" placeholder="متن سلب مسئولیت" />
</div>
</>
)}
{/* Weight */}
{tab.hasWeight && (
<div>
<label className="cp-label">ترتیب نمایش (weight)</label>
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
</div>
)}
{/* Status */}
<div>
<label className="cp-label">وضعیت</label>
<Controller
name="status"
control={control}
render={({ field }) => (
<SearchableSelect
options={[
{ value: '1', label: 'فعال' },
{ value: '0', label: 'غیرفعال' },
]}
value={field.value ?? '1'}
onChange={(val) => field.onChange(val ?? '1')}
isClearable={false}
/>
)}
/>
</div>
</div>
);
}
// ── Main Page ───────────────────────────────────────────────────────────────
export default function CategoriesPage() {
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<CategoryBundle>('state');
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Category | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const tab = TABS.find((t) => t.key === activeTab)!;
// Main list
const { data, isLoading } = useQuery({
queryKey: ['categories', activeTab],
queryFn: () => api.get<ApiResponse<{ data: Category[] }>>(`/api/v1/categorys/${activeTab}`),
});
// Parent list (state for city, specially_doctor for sub-specialties)
const parentBundle = tab.hasParent ? tab.parentBundle : undefined;
const parentQuery = useBundleList(parentBundle);
const parentRaw: Category[] = (parentQuery.data?.data as any)?.data
?? parentQuery.data?.data?.data
?? [];
const parentOptions = parentRaw.map((p) => ({ value: p.id, label: p.label }));
// Build parent ID → label map for table display
const parentMap = React.useMemo(
() => Object.fromEntries(parentRaw.map((p) => [p.id, p.label])),
[parentRaw],
);
// Representations list (only needed for city tab)
const representationsQuery = useQuery({
queryKey: ['representations', 'for-select'],
queryFn: () =>
api.get<PaginatedResponse<Representation>>('/api/v1/admin/representations?limit=200'),
enabled: activeTab === 'city',
staleTime: 2 * 60_000,
});
const representationOptions = (representationsQuery.data?.data ?? []).map((r) => ({
value: r.id,
label: r.full_name + (r.mobile_number ? ` — ${r.mobile_number}` : ''),
}));
const representationMap = React.useMemo(
() => Object.fromEntries(
(representationsQuery.data?.data ?? []).map((r) => [r.id, r.full_name])
),
[representationsQuery.data],
);
const { register, handleSubmit, reset, control, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(baseSchema),
defaultValues: { status: '1', weight: '0' },
});
const closeAdd = () => {
setAddOpen(false);
setLogoUrl(null);
reset({ status: '1', weight: '0' });
};
const closeEdit = () => {
setEditTarget(null);
setLogoUrl(null);
reset({ status: '1', weight: '0' });
};
const createMutation = useMutation({
mutationFn: (d: FormData) =>
api.post<ApiResponse<Category>>('/api/v1/category', buildPayload(d, activeTab, logoUrl)),
onSuccess: () => {
toast.success('دسته‌بندی اضافه شد');
closeAdd();
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
},
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: ({ id, d }: { id: number; d: FormData }) =>
api.patch<ApiResponse<Category>>(`/api/v1/category/${id}`, buildPayload(d, activeTab, logoUrl)),
onSuccess: () => {
toast.success('دسته‌بندی بروزرسانی شد');
closeEdit();
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (c: Category) => api.delete<ApiResponse<null>>(`/api/v1/category/${c.id}`),
onSuccess: () => {
toast.success('دسته‌بندی حذف شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['categories', activeTab] });
},
onError: (err: Error) => toast.error(err.message),
});
const openEdit = (c: Category) => {
setEditTarget(c);
setLogoUrl(c.logo_url ?? null);
reset({
label: c.label ?? '',
status: String(c.status ?? 1),
weight: String(c.weight ?? 0),
parent_id: c.parent_id ?? null,
representation_id: c.representation_id ?? null,
title: c.title ?? '',
contact_phone: c.contact_phone ?? '',
email: c.email ?? '',
description: c.description ?? '',
slogan: c.slogan ?? '',
domain: c.domain ?? '',
keywords: c.keywords ?? '',
footer_description: c.footer_description ?? '',
footer_disclaimer: c.footer_disclaimer ?? '',
});
};
const handleTabChange = (key: CategoryBundle) => {
setActiveTab(key);
setSearch('');
};
const allItems: Category[] = (data?.data as any)?.data ?? (data?.data as any) ?? [];
const filtered = search
? allItems.filter((c) => (c.label ?? '').toLowerCase().includes(search.toLowerCase()))
: allItems;
// ── Per-bundle columns ──────────────────────────────────────────────────
const baseColumns: Column<Category>[] = [
{
key: 'id',
header: 'شناسه',
className: 'w-16',
render: (c) => <span className="text-xs text-slate-400 dark:text-slate-500 font-mono">{c.id}</span>,
},
{
key: 'label',
header: 'نام',
render: (c) => (
<div className="flex items-center gap-2.5">
{tab.hasLogo && c.logo_url && (
<img src={c.logo_url} alt={c.label ?? ''} className="w-8 h-8 object-contain rounded-lg border border-slate-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-0.5 shrink-0" />
)}
{tab.hasLogo && !c.logo_url && (
<div className="w-8 h-8 rounded-lg border border-dashed border-slate-200 dark:border-gray-600 flex items-center justify-center shrink-0">
<PhotoIcon className="w-4 h-4 text-slate-300 dark:text-gray-600" />
</div>
)}
<div>
<span className="font-medium text-slate-800 dark:text-slate-100">{c.label}</span>
{c.title && c.title !== c.label && (
<p className="text-xs text-slate-400 dark:text-slate-500 truncate max-w-[160px]">{c.title}</p>
)}
</div>
</div>
),
},
];
const parentCol: Column<Category> = {
key: 'parent_id',
header: tab.parentLabel ?? 'والد',
render: (c) => c.parent_id
? <span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-2 py-0.5 rounded-full">{parentMap[c.parent_id] ?? `#${c.parent_id}`}</span>
: <span className="text-slate-300 dark:text-slate-600"></span>,
};
const representationCol: Column<Category> = {
key: 'representation_id',
header: 'نماینده',
render: (c) => c.representation_id
? <span className="text-xs text-slate-600 dark:text-slate-300">{representationMap[c.representation_id] ?? `#${c.representation_id}`}</span>
: <span className="text-slate-300 dark:text-slate-600"></span>,
};
const cityExtraCols: Column<Category>[] = [
{
key: 'domain',
header: 'دامنه',
render: (c) => c.domain
? <span className="text-xs font-mono text-slate-500 dark:text-slate-400">{c.domain}</span>
: <span className="text-slate-300 dark:text-slate-600"></span>,
},
{
key: 'contact_phone',
header: 'تلفن',
render: (c) => c.contact_phone
? <span className="text-xs font-mono" dir="ltr">{c.contact_phone}</span>
: <span className="text-slate-300 dark:text-slate-600"></span>,
},
];
const weightCol: Column<Category> = {
key: 'weight',
header: 'ترتیب',
className: 'w-20',
render: (c) => <span className="text-xs text-slate-400 dark:text-slate-500">{c.weight}</span>,
};
const statusCol: Column<Category> = {
key: 'status',
header: 'وضعیت',
className: 'w-24',
render: (c) => c.status === 1
? (
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-400/10 px-2 py-0.5 rounded-full">
<CheckCircleIcon className="w-3 h-3" /> فعال
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-400/10 px-2 py-0.5 rounded-full">
<XCircleIcon className="w-3 h-3" /> غیرفعال
</span>
),
};
let columns: Column<Category>[] = [...baseColumns];
if (tab.hasParent) columns = [...columns, parentCol];
if (tab.key === 'city') columns = [...columns, representationCol, ...cityExtraCols];
if (tab.hasWeight) columns = [...columns, weightCol];
columns = [...columns, statusCol];
const modalSize = tab.hasCityFields ? 'lg' : 'sm';
const formSharedProps = {
register, control, errors, tab,
parentOptions,
representationOptions,
parentLoading: parentQuery.isLoading,
representationLoading: representationsQuery.isLoading,
logoUrl,
onLogoChange: setLogoUrl,
};
return (
<div>
<PageHeader
title="دسته‌بندی‌ها"
description={tab.description}
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دسته‌بندی‌ها' }]}
action={
<button onClick={() => { reset({ status: '1', weight: '0' }); setLogoUrl(null); setAddOpen(true); }} className="cp-btn-primary">
<PlusIcon className="w-4 h-4" />
افزودن {tab.label.slice(0, -1) || tab.label}
</button>
}
/>
<div className="cp-card overflow-hidden">
{/* ── Tab bar ── */}
<div className="flex overflow-x-auto border-b border-slate-200 dark:border-gray-700 px-2 pt-2 gap-1 scrollbar-none">
{TABS.map((t) => {
const Icon = t.icon;
const isActive = t.key === activeTab;
return (
<button
key={t.key}
onClick={() => handleTabChange(t.key)}
className={`flex items-center gap-2 px-3.5 py-2.5 text-sm font-medium rounded-t-xl whitespace-nowrap border-b-2 -mb-px transition-all ${
isActive
? 'border-primary-600 text-primary-600 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-400/5'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-gray-800/50'
}`}
>
<Icon className="w-4 h-4 shrink-0" />
{t.label}
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full transition-colors ${
isActive
? 'bg-primary-100 dark:bg-primary-400/20 text-primary-700 dark:text-primary-300'
: 'bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400'
}`}>
{isLoading ? '…' : allItems.length}
</span>
</button>
);
})}
</div>
{/* ── Bundle info bar ── */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-slate-100 dark:border-gray-700/50 bg-slate-50/50 dark:bg-gray-800/30">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${tab.color.split(' ').slice(0, 2).join(' ')} bg-opacity-10`}>
<tab.icon className={`w-4 h-4 ${tab.color.split(' ')[0]}`} />
</div>
<div>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">{tab.label}</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">{tab.description}</p>
</div>
<div className="mr-auto flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="font-mono bg-slate-100 dark:bg-gray-700 px-2 py-0.5 rounded text-[11px]">{activeTab}</span>
{!isLoading && <span>{allItems.length} مورد</span>}
</div>
</div>
{/* ── Table ── */}
<div className="p-5">
<DataTable<Category>
columns={columns}
data={filtered}
loading={isLoading}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder={`جستجو در ${tab.label}...`}
emptyMessage={`هیچ ${tab.label.slice(0, -1) || tab.label}ی یافت نشد`}
actions={(cat) => (
<>
<button onClick={() => openEdit(cat)} className="cp-action-edit" title="ویرایش">
<PencilIcon className="w-4 h-4" />
</button>
<button onClick={() => setDeleteTarget(cat)} className="cp-action-delete" title="حذف">
<TrashIcon className="w-4 h-4" />
</button>
</>
)}
/>
</div>
</div>
{/* ── Add Modal ── */}
<Modal
open={addOpen}
title={`افزودن — ${tab.label}`}
size={modalSize}
onClose={closeAdd}
footer={
<>
<button onClick={closeAdd} className="cp-btn-secondary">لغو</button>
<button form="cat-add-form" type="submit" disabled={createMutation.isPending} className="cp-btn-primary">
{createMutation.isPending ? 'در حال ذخیره...' : 'افزودن'}
</button>
</>
}
>
<form id="cat-add-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
<CategoryFormFields {...formSharedProps} />
</form>
</Modal>
{/* ── Edit Modal ── */}
<Modal
open={!!editTarget}
title={`ویرایش — ${editTarget?.label ?? ''}`}
size={modalSize}
onClose={closeEdit}
footer={
<>
<button onClick={closeEdit} className="cp-btn-secondary">لغو</button>
<button form="cat-edit-form" type="submit" disabled={updateMutation.isPending} className="cp-btn-primary">
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<form
id="cat-edit-form"
onSubmit={handleSubmit((d) => editTarget && updateMutation.mutate({ id: editTarget.id, d }))}
>
<CategoryFormFields {...formSharedProps} />
</form>
</Modal>
<ConfirmDialog
open={!!deleteTarget}
title="حذف دسته‌بندی"
message={`آیا از حذف "${deleteTarget?.label}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="حذف"
danger
loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}
// ── Build API payload from form data ───────────────────────────────────────
function buildPayload(d: FormData, bundle: CategoryBundle, logoUrl: string | null): Record<string, unknown> {
const base: Record<string, unknown> = {
label: d.label.trim(),
bundle,
status: d.status !== undefined ? parseInt(d.status) : 1,
};
if (d.weight !== undefined && d.weight !== '') base.weight = parseInt(d.weight);
if (d.parent_id != null) base.parent_id = d.parent_id;
if (d.title?.trim()) base.title = d.title.trim();
if (bundle === 'insurance_type' || bundle === 'supplementary_insurance') {
base.logo_url = logoUrl;
}
if (bundle === 'city') {
base.representation_id = d.representation_id ?? null;
if (d.contact_phone?.trim()) base.contact_phone = d.contact_phone.trim();
if (d.email?.trim()) base.email = d.email.trim();
if (d.description?.trim()) base.description = d.description.trim();
if (d.slogan?.trim()) base.slogan = d.slogan.trim();
if (d.domain?.trim()) base.domain = d.domain.trim();
if (d.keywords?.trim()) base.keywords = d.keywords.trim();
if (d.footer_description?.trim()) base.footer_description = d.footer_description.trim();
if (d.footer_disclaimer?.trim()) base.footer_disclaimer = d.footer_disclaimer.trim();
}
return base;
}