- 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.
791 lines
31 KiB
TypeScript
791 lines
31 KiB
TypeScript
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;
|
||
}
|