diff --git a/assets/admin/pages/CategoriesPage.tsx b/assets/admin/pages/CategoriesPage.tsx index 5cacdadd..0c2b3abb 100644 --- a/assets/admin/pages/CategoriesPage.tsx +++ b/assets/admin/pages/CategoriesPage.tsx @@ -10,57 +10,49 @@ 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 type { PaginatedResponse } from '../lib/api'; +import type { Province, City, SpecialtyFull, DoctorService, Insurance, Tag, 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 Pagination from '../components/ui/Pagination'; import SearchableSelect from '../components/ui/SearchableSelect'; -// ── Tab config ────────────────────────────────────────────────────────────── +// ── Tab types ──────────────────────────────────────────────────────────────── + +type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags'; interface TabConfig { - key: CategoryBundle; + key: TabKey; 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', + key: 'provinces', 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', + key: 'cities', 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: 'تخصص‌های پزشکی', + key: 'specialties', + 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, + description: 'تخصص‌های پزشکی', }, { key: 'doctor_services', @@ -68,62 +60,33 @@ const TABS: TabConfig[] = [ 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: 'بیمه پایه', + key: 'insurances', + 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, + description: 'بیمه‌های پایه و تکمیلی', }, { - 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: 'تگ‌های بلاگ', + key: 'tags', + 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; - -// ── Logo uploader ──────────────────────────────────────────────────────────── +// ── Logo uploader ───────────────────────────────────────────────────────────── function LogoUploadField({ value, onChange, + uploadUrl, }: { value: string | null; onChange: (url: string | null) => void; + uploadUrl: string; }) { const [uploading, setUploading] = useState(false); const token = useAuthStore((s) => s.token); @@ -133,7 +96,7 @@ function LogoUploadField({ if (!file) return; setUploading(true); try { - const res = await fetch('/api/v1/admin/category/upload-logo', { + const res = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Disposition': `attachment; filename="${file.name}"`, @@ -161,11 +124,7 @@ function LogoUploadField({
{value ? (
- logo + logo
)} - -

JPG، PNG یا WebP — حداکثر ۵ مگابایت

+

JPG، PNG یا WebP

); } -// ── Helper to load parent list ────────────────────────────────────────────── +// ── StatusBadge helper ──────────────────────────────────────────────────────── -function useBundleList(bundle: CategoryBundle | undefined) { - return useQuery({ - queryKey: ['categories', bundle], - queryFn: () => api.get>(`/api/v1/categorys/${bundle}`), - enabled: !!bundle, - staleTime: 60_000, +function StatusBadge({ status }: { status: number }) { + return status === 1 ? ( + + فعال + + ) : ( + + غیرفعال + + ); +} + +// ── Provinces Tab ───────────────────────────────────────────────────────────── + +const provinceSchema = z.object({ + name: z.string().min(1, 'نام الزامی است'), + weight: z.string().optional(), + status: z.string().optional(), +}); +type ProvinceForm = z.infer; + +function ProvincesTab() { + const qc = useQueryClient(); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [addOpen, setAddOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-provinces', page, search], + queryFn: () => api.get>(`/api/v1/admin/provinces?page=${page}&limit=20&search=${encodeURIComponent(search)}`), }); -} -// ── Dynamic form fields ───────────────────────────────────────────────────── + const items = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; -interface FormFieldsProps { - register: ReturnType>['register']; - control: ReturnType>['control']; - errors: ReturnType>['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; -} + const { register, handleSubmit, reset, control, formState: { errors } } = useForm({ + resolver: zodResolver(provinceSchema), + defaultValues: { status: '1', weight: '0' }, + }); -function CategoryFormFields({ - register, control, errors, tab, - parentOptions, representationOptions, - parentLoading, representationLoading, - logoUrl, onLogoChange, -}: FormFieldsProps) { - return ( + const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1', weight: '0' }); }; + + const createMutation = useMutation({ + mutationFn: (d: ProvinceForm) => api.post('/api/v1/admin/province', { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1') }), + onSuccess: () => { toast.success('استان اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, d }: { id: number; d: ProvinceForm }) => api.patch(`/api/v1/admin/province/${id}`, { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1') }), + onSuccess: () => { toast.success('استان بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: (p: Province) => api.delete(`/api/v1/admin/province/${p.id}`), + onSuccess: () => { toast.success('استان حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-provinces'] }); }, + onError: (err: Error) => toast.error(err.message), + }); + + const openEdit = (p: Province) => { + setEditTarget(p); + reset({ name: p.name, weight: String(p.weight), status: String(p.status) }); + }; + + const columns: Column[] = [ + { key: 'id', header: 'شناسه', className: 'w-16', render: (p) => {p.id} }, + { key: 'name', header: 'نام', render: (p) => {p.name} }, + { key: 'weight', header: 'ترتیب', className: 'w-20', render: (p) => {p.weight} }, + { key: 'status', header: 'وضعیت', className: 'w-24', render: (p) => }, + ]; + + const formBody = (
- {/* Logo upload (insurance bundles) */} - {tab.hasLogo && ( -
- - -
- )} - - {/* Label */}
- - {errors.label &&

{errors.label.message}

} + + {errors.name &&

{errors.name.message}

} +
+
+ +
- - {/* Parent selector */} - {tab.hasParent && ( -
- - ( - field.onChange(val as number | null)} - placeholder="-- بدون والد --" - isClearable - isLoading={parentLoading} - noOptionsMessage="موردی یافت نشد" - /> - )} - /> -
- )} - - {/* Representation selector (city only) */} - {tab.key === 'city' && ( -
- - ( - field.onChange(val as number | null)} - placeholder="انتخاب نماینده..." - isClearable - isLoading={representationLoading} - noOptionsMessage="هیچ نماینده‌ای یافت نشد" - /> - )} - /> -
- )} - - {/* Title (city) */} - {tab.key === 'city' && ( -
- - -
- )} - - {/* City-specific fields */} - {tab.hasCityFields && ( - <> -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -