import React, { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon, BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon, PhotoIcon, XMarkIcon, ArrowDownTrayIcon, ArrowUpTrayIcon, ExclamationTriangleIcon, } 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 { PaginatedResponse } from '../lib/api'; import type { Province, City, SpecialtyFull, DoctorService, Insurance, Tag, Representation } from '../types'; import { useAuthStore } from '../stores/authStore'; 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'; import { numericField } from '../lib/forms'; type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags'; interface ImportError { row?: number; field?: string; message: string } interface TabConfig { key: TabKey; label: string; icon: React.ElementType; hue: number; } const TABS: TabConfig[] = [ { key: 'provinces', label: 'استان‌ها', icon: MapPinIcon, hue: 256 }, { key: 'cities', label: 'شهرها', icon: BuildingOffice2Icon, hue: 205 }, { key: 'specialties', label: 'تخصص‌ها', icon: HeartIcon, hue: 295 }, { key: 'doctor_services', label: 'خدمات پزشک', icon: WrenchScrewdriverIcon, hue: 162 }, { key: 'insurances', label: 'بیمه‌ها', icon: ShieldCheckIcon, hue: 205 }, { key: 'tags', label: 'تگ‌ها', icon: TagIcon, hue: 272 }, ]; // ── 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); const handleFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploading(true); try { const res = await fetch(uploadUrl, { 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 (
{value ? (
logo
) : (
)} JPG، PNG یا WebP
); } // ── Status badge ─────────────────────────────────────────────────────────────── function SBadge({ status }: { status: number }) { return ( {status === 1 ? 'فعال' : 'غیرفعال'} ); } // ── Export helper ────────────────────────────────────────────────────────────── async function exportJson(url: string, filename: string, token: string | null) { const res = await fetch(url, token ? { headers: { Authorization: `Bearer ${token}` } } : {}); const json = await res.json(); const items = json?.data ?? json?.data?.data ?? []; const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } // ── Tab sub-component wrapper ────────────────────────────────────────────────── function TabActions({ label, onClick, exportUrl, exportFile, bundle, entityLabel, onImported }: { label: string; onClick: () => void; exportUrl: string; exportFile: string; bundle: TabKey; entityLabel: string; onImported: () => void; }) { const token = useAuthStore((s) => s.token); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); const [errors, setErrors] = useState(null); const [pendingItems, setPendingItems] = useState(null); const fileRef = useRef(null); const handleExport = async () => { setExporting(true); try { await exportJson(exportUrl, exportFile, token); } catch { // silent } finally { setExporting(false); } }; const handleFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; let parsed: unknown; try { parsed = JSON.parse(await file.text()); } catch { setErrors([{ message: 'فایل یک JSON معتبر نیست' }]); return; } let items: unknown[] | null = null; if (Array.isArray(parsed)) items = parsed; else if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { items?: unknown[] }).items)) { items = (parsed as { items: unknown[] }).items; } if (!items || items.length === 0) { setErrors([{ message: 'فایل باید یک آرایه‌ی غیرخالی از رکوردها باشد' }]); return; } setPendingItems(items); }; const doImport = async () => { if (!pendingItems) return; setImporting(true); try { const res = await fetch(`/api/v1/admin/categories/${bundle}/import`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(pendingItems), }); const json = await res.json().catch(() => ({})); if (res.ok && json.success) { toast.success(`${json.data?.imported ?? 0} رکورد با موفقیت وارد شد`); setPendingItems(null); onImported(); } else { setErrors(json.errors?.length ? json.errors : [{ message: json.errors?.[0]?.message ?? 'خطا در ورود اطلاعات' }]); setPendingItems(null); } } catch { setErrors([{ message: 'خطا در ارتباط با سرور' }]); setPendingItems(null); } finally { setImporting(false); } }; return (
setPendingItems(null)} /> setErrors(null)} footer={} >
به دلیل خطاهای زیر هیچ تغییری اعمال نشد. فایل را اصلاح و دوباره تلاش کنید.
{(errors ?? []).map((er, idx) => (
{er.field && {er.field}} {er.message}
))}
); } // ── 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 [idSort, setIdSort] = useState<'asc' | 'desc' | null>(null); const sortQs = idSort ? `&sort=id&order=${idSort}` : ''; const toggleSort = () => { setIdSort((p) => (p === 'asc' ? 'desc' : 'asc')); setPage(1); }; const { data, isLoading } = useQuery({ queryKey: ['admin-provinces', page, search, idSort], queryFn: () => api.get>(`/api/v1/admin/provinces?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`), }); const items = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; const { register, handleSubmit, reset, control, formState: { errors } } = useForm({ resolver: zodResolver(provinceSchema), defaultValues: { status: '1', weight: '0' }, }); 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', sortable: true, header: 'شناسه', render: (p) => {p.id} }, { key: 'name', header: 'نام', render: (p) => {p.name} }, { key: 'weight', header: 'ترتیب', render: (p) => {p.weight} }, { key: 'status', header: 'وضعیت', render: (p) => }, ]; return ( <> { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/provinces/export" exportFile="state.json" bundle="provinces" entityLabel="استان‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-provinces'] })} /> columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در استان‌ها..." emptyMessage="هیچ استانی یافت نشد" actions={(p) => ( <> )} /> {total > 20 &&
} } >
editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
{errors.name &&

{errors.name.message}

}
( field.onChange(v ?? '1')} isClearable={false} /> )} />
deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} /> ); } // ── Cities Tab ───────────────────────────────────────────────────────────────── const citySchema = z.object({ name: z.string().min(1, 'نام الزامی است'), site_name: z.string().optional(), title: z.string().optional(), province_id: z.number().nullable().optional(), weight: z.string().optional(), status: z.string().optional(), representation_id: z.number().nullable().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(), }); type CityForm = z.infer; function CitiesTab() { 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 [idSort, setIdSort] = useState<'asc' | 'desc' | null>(null); const sortQs = idSort ? `&sort=id&order=${idSort}` : ''; const toggleSort = () => { setIdSort((p) => (p === 'asc' ? 'desc' : 'asc')); setPage(1); }; const { data, isLoading } = useQuery({ queryKey: ['admin-cities', page, search, idSort], queryFn: () => api.get>(`/api/v1/admin/cities?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`), }); const provincesQuery = useQuery({ queryKey: ['admin-provinces-select'], queryFn: () => api.get>('/api/v1/admin/provinces?limit=100'), staleTime: 60_000, }); const provinceOptions = (provincesQuery.data?.data ?? []).map((p) => ({ value: p.id, label: p.name })); const provinceMap = Object.fromEntries((provincesQuery.data?.data ?? []).map((p) => [p.id, p.name])); const representationsQuery = useQuery({ queryKey: ['representations-select'], queryFn: () => api.get>('/api/v1/admin/representations?limit=200'), 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 = Object.fromEntries((representationsQuery.data?.data ?? []).map((r) => [r.id, r.full_name])); const items = data?.data ?? []; const total = data?.meta?.totalRecords ?? 0; const { register, handleSubmit, reset, control, formState: { errors } } = useForm({ resolver: zodResolver(citySchema), defaultValues: { status: '1', weight: '0' }, }); const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1', weight: '0' }); }; const buildPayload = (d: CityForm) => ({ name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1'), province_id: d.province_id ?? null, representation_id: d.representation_id ?? null, ...(d.site_name?.trim() && { site_name: d.site_name.trim() }), ...(d.title?.trim() && { title: d.title.trim() }), ...(d.contact_phone?.trim() && { contact_phone: d.contact_phone.trim() }), ...(d.email?.trim() && { email: d.email.trim() }), ...(d.description?.trim() && { description: d.description.trim() }), ...(d.slogan?.trim() && { slogan: d.slogan.trim() }), ...(d.domain?.trim() && { domain: d.domain.trim() }), ...(d.keywords?.trim() && { keywords: d.keywords.trim() }), ...(d.footer_description?.trim() && { footer_description: d.footer_description.trim() }), }); const createMutation = useMutation({ mutationFn: (d: CityForm) => api.post('/api/v1/admin/city', buildPayload(d)), onSuccess: () => { toast.success('شهر اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-cities'] }); }, onError: (err: Error) => toast.error(err.message), }); const updateMutation = useMutation({ mutationFn: ({ id, d }: { id: number; d: CityForm }) => api.patch(`/api/v1/admin/city/${id}`, buildPayload(d)), onSuccess: () => { toast.success('شهر بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-cities'] }); }, onError: (err: Error) => toast.error(err.message), }); const deleteMutation = useMutation({ mutationFn: (c: City) => api.delete(`/api/v1/admin/city/${c.id}`), onSuccess: () => { toast.success('شهر حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-cities'] }); }, onError: (err: Error) => toast.error(err.message), }); const openEdit = (c: City) => { setEditTarget(c); reset({ name: c.name, weight: String(c.weight), status: String(c.status), site_name: c.site_name ?? '', title: c.title ?? '', province_id: c.province_id ?? null, representation_id: c.representation_id ?? null, 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 ?? '', }); }; const columns: Column[] = [ { key: 'id', sortable: true, header: 'شناسه', render: (c) => {c.id} }, { key: 'name', header: 'نام', render: (c) => {c.name} }, { key: 'site_name', header: 'نام سایت', render: (c) => c.site_name ? {c.site_name} : }, { key: 'province_id', header: 'استان', render: (c) => c.province_id ? {provinceMap[c.province_id] ?? `#${c.province_id}`} : }, { key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? {representationMap[c.representation_id] ?? `#${c.representation_id}`} : }, { key: 'domain', header: 'دامنه', render: (c) => c.domain ? {c.domain} : }, { key: 'weight', header: 'ترتیب', render: (c) => {c.weight} }, { key: 'status', header: 'وضعیت', render: (c) => }, ]; return ( <> { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/cities/export" exportFile="city.json" bundle="cities" entityLabel="شهرها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-cities'] })} /> columns={columns} data={items} loading={isLoading} sortKey={idSort ? 'id' : null} sortDir={idSort ?? undefined} onSort={toggleSort} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد" actions={(c) => ( <> )} /> {total > 20 &&
} } >
editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
{errors.name &&

{errors.name.message}

}
( field.onChange(v as number | null)} placeholder="-- انتخاب استان --" isClearable isLoading={provincesQuery.isLoading} noOptionsMessage="استانی یافت نشد" /> )} />
( field.onChange(v as number | null)} placeholder="انتخاب نماینده..." isClearable isLoading={representationsQuery.isLoading} noOptionsMessage="هیچ نماینده‌ای یافت نشد" /> )} />