Files
clinicpro/assets/admin/pages/CategoriesPage.tsx
T
hamedandClaude Opus 5 58c6d9ac18 feat(insurance): resolve coverage percent per service category
Base insurance is a percentage-only rule: patient share is now total minus the
base share, and the contract franchise no longer inflates it (franchise stays
meaningful for supplementary contracts only).

Coverage percentages are managed centrally by admin per service category
(outpatient/inpatient, extensible via the ServiceCategory enum). A tenant
contract may override a category, otherwise it follows the admin default live —
changing the central value immediately applies to every contract that did not
override it.

- add ServiceCategory enum + GET /api/v1/service-categories as the single source
  of the category list for every client
- add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints)
  and expose coverage_defaults on the insurance list and insurance-pricing
- add tenant_insurance_category_coverage; tenant-insurances accepts optional
  category_coverages (needs insurances.update) and returns the effective
  percentages with their source
- add service_items.service_category; visits always resolve as outpatient
- drop the reverse-engineered percent from patient_share_rials in MyPatientsPage
  and align the client-side BillingCalculator mirror in CreateStep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:21:19 +03:30

1157 lines
65 KiB
TypeScript

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,
AdjustmentsHorizontalIcon,
} 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 InsuranceCoverageDefaultsModal from '../components/InsuranceCoverageDefaultsModal';
import { useServiceCategories } from '../hooks/useServiceCategories';
import { numericField } from '../lib/forms';
import { formatNumber } from '../lib/utils';
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<HTMLInputElement>) => {
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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{value ? (
<div style={{ position: 'relative' }}>
<img src={value} alt="logo" style={{ width: 64, height: 64, objectFit: 'contain', borderRadius: 12, border: '1px solid var(--border)', background: 'var(--surface)', padding: 4 }} />
<button type="button" onClick={() => onChange(null)} style={{
position: 'absolute', top: -6, right: -6, width: 20, height: 20, borderRadius: 10,
background: 'var(--danger)', color: '#fff', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<XMarkIcon style={{ width: 12, height: 12 }} />
</button>
</div>
) : (
<div style={{ width: 64, height: 64, borderRadius: 12, border: '2px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--surface-2)' }}>
<PhotoIcon style={{ width: 24, height: 24, color: 'var(--text-3)' }} />
</div>
)}
<label className={`btn ghost sm ${uploading ? 'disabled' : ''}`} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
<PhotoIcon style={{ width: 15, height: 15 }} />
{uploading ? 'در حال آپلود...' : (value ? 'تغییر لگو' : 'انتخاب لگو')}
<input type="file" aria-label="انتخاب فایل لگو" accept="image/jpeg,image/png,image/webp" style={{ display: 'none' }} onChange={handleFile} disabled={uploading} />
</label>
<span className="muted" style={{ fontSize: 11 }}>JPG، PNG یا WebP</span>
</div>
);
}
// ── Status badge ───────────────────────────────────────────────────────────────
function SBadge({ status }: { status: number }) {
return (
<span className={`badge ${status === 1 ? 'green' : 'gray'}`}>
<span className="bdot" />
{status === 1 ? 'فعال' : 'غیرفعال'}
</span>
);
}
// ── 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<ImportError[] | null>(null);
const [pendingItems, setPendingItems] = useState<unknown[] | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
setExporting(true);
try {
await exportJson(exportUrl, exportFile, token);
} catch {
// silent
} finally {
setExporting(false);
}
};
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 'var(--card-pad)' }}>
<input ref={fileRef} type="file" aria-label="انتخاب فایل JSON برای درون‌ریزی" accept=".json,application/json" style={{ display: 'none' }} onChange={handleFile} />
<button onClick={() => fileRef.current?.click()} className="btn ghost sm">
<ArrowUpTrayIcon style={{ width: 15, height: 15 }} />
ورود JSON
</button>
<button onClick={handleExport} disabled={exporting} className="btn ghost sm">
<ArrowDownTrayIcon style={{ width: 15, height: 15 }} />
{exporting ? 'در حال دانلود...' : 'خروجی JSON'}
</button>
<button onClick={onClick} className="btn primary sm">
<PlusIcon style={{ width: 15, height: 15 }} /> {label}
</button>
<ConfirmDialog
open={!!pendingItems}
title={`ورود ${entityLabel} از فایل`}
message={`این عملیات همه‌ی رکوردهای فعلی این بخش را حذف و با ${pendingItems?.length ?? 0} رکورد فایل جایگزین می‌کند. این کار بازگشت‌ناپذیر است. ادامه می‌دهید؟`}
confirmLabel="حذف و جایگزینی"
danger
loading={importing}
onConfirm={doImport}
onCancel={() => setPendingItems(null)}
/>
<Modal
open={!!errors}
title="فایل وارد نشد — خطاهای اعتبارسنجی"
size="md"
onClose={() => setErrors(null)}
footer={<button onClick={() => setErrors(null)} className="btn ghost sm">بستن</button>}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--danger)' }}>
<ExclamationTriangleIcon style={{ width: 18, height: 18, flexShrink: 0 }} />
<span style={{ fontSize: 13 }}>به دلیل خطاهای زیر هیچ تغییری اعمال نشد. فایل را اصلاح و دوباره تلاش کنید.</span>
</div>
<div style={{ maxHeight: 360, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{(errors ?? []).map((er, idx) => (
<div key={idx} style={{ fontSize: 12, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2)', display: 'flex', gap: 8 }}>
{er.field && <span className="chip" style={{ flexShrink: 0 }}>{er.field}</span>}
<span style={{ color: 'var(--text-2)' }}>{er.message}</span>
</div>
))}
</div>
</Modal>
</div>
);
}
// ── Provinces Tab ──────────────────────────────────────────────────────────────
const provinceSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
weight: z.string().optional(),
status: z.string().optional(),
});
type ProvinceForm = z.infer<typeof provinceSchema>;
function ProvincesTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Province | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Province | null>(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<PaginatedResponse<Province>>(`/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<ProvinceForm>({
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<Province>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (p) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.id}</span> },
{ key: 'name', header: 'نام', render: (p) => <b>{p.name}</b> },
{ key: 'weight', header: 'ترتیب', render: (p) => <span className="muted">{p.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (p) => <SBadge status={p.status} /> },
];
return (
<>
<TabActions label="افزودن استان" onClick={() => { 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'] })} />
<DataTable<Province> 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) => (
<>
<button onClick={() => openEdit(p)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(p)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن استان'} size="sm" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="province-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="province-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام استان" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>ترتیب نمایش</label>
<input {...numericField(register('weight'))} className="input" placeholder="0" style={{ maxWidth: 120 }} />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>وضعیت</label>
<Controller name="status" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
)} />
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف استان" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => 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<typeof citySchema>;
function CitiesTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<City | null>(null);
const [deleteTarget, setDeleteTarget] = useState<City | null>(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<PaginatedResponse<City>>(`/api/v1/admin/cities?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`),
});
const provincesQuery = useQuery({
queryKey: ['admin-provinces-select'],
queryFn: () => api.get<PaginatedResponse<Province>>('/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<PaginatedResponse<Representation>>('/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<CityForm>({
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<City>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (c) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.id}</span> },
{ key: 'name', header: 'نام', render: (c) => <b>{c.name}</b> },
{ key: 'site_name', header: 'نام سایت', render: (c) => c.site_name ? <span>{c.site_name}</span> : <span className="muted"></span> },
{ key: 'province_id', header: 'استان', render: (c) => c.province_id ? <span className="chip">{provinceMap[c.province_id] ?? `#${c.province_id}`}</span> : <span className="muted"></span> },
{ key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? <span className="muted" style={{ fontSize: 12 }}>{representationMap[c.representation_id] ?? `#${c.representation_id}`}</span> : <span className="muted"></span> },
{ key: 'domain', header: 'دامنه', render: (c) => c.domain ? <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.domain}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (c) => <span className="muted">{c.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (c) => <SBadge status={c.status} /> },
];
return (
<>
<TabActions label="افزودن شهر" onClick={() => { 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'] })} />
<DataTable<City> 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) => (
<>
<button onClick={() => openEdit(c)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(c)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن شهر'} size="lg" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="city-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="city-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام شهر" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>نام سایت</label>
<input {...register('site_name')} className="input" placeholder="مثال: یزد نوبت" />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>عنوان سئو (title)</label>
<input {...register('title')} className="input" placeholder="مثال: نوبت‌دهی آنلاین پزشکان یزد | یزد نوبت" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>استان</label>
<Controller name="province_id" control={control} render={({ field }) => (
<SearchableSelect options={provinceOptions} value={field.value ?? null} onChange={(v) => field.onChange(v as number | null)} placeholder="-- انتخاب استان --" isClearable isLoading={provincesQuery.isLoading} noOptionsMessage="استانی یافت نشد" />
)} />
</div>
<div className="form-row">
<label>نماینده</label>
<Controller name="representation_id" control={control} render={({ field }) => (
<SearchableSelect options={representationOptions} value={field.value ?? null} onChange={(v) => field.onChange(v as number | null)} placeholder="انتخاب نماینده..." isClearable isLoading={representationsQuery.isLoading} noOptionsMessage="هیچ نماینده‌ای یافت نشد" />
)} />
</div>
<div className="form-row">
<label>تلفن تماس</label>
<input {...register('contact_phone')} className="input" dir="ltr" placeholder="021xxxxxxxx" />
</div>
<div className="form-row">
<label>ایمیل</label>
<input {...register('email')} type="email" className="input" dir="ltr" />
</div>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
<input {...register('domain')} className="input" dir="ltr" />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شعار</label>
<input {...register('slogan')} className="input" />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>کلیدواژه‌ها</label>
<input {...register('keywords')} className="input" />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>توضیحات</label>
<textarea {...register('description')} rows={3} className="input" style={{ height: 'auto', resize: 'none' }} />
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>توضیحات فوتر</label>
<textarea {...register('footer_description')} rows={2} className="input" style={{ height: 'auto', resize: 'none' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
<Controller name="status" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
)} />
</div>
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف شهر" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} />
</>
);
}
// ── Specialties Tab ────────────────────────────────────────────────────────────
const specialtySchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
parent_id: z.number().nullable().optional(),
weight: z.string().optional(),
status: z.string().optional(),
});
type SpecialtyForm = z.infer<typeof specialtySchema>;
function SpecialtiesTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SpecialtyFull | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SpecialtyFull | null>(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-specialties', page, search, idSort],
queryFn: () => api.get<PaginatedResponse<SpecialtyFull>>(`/api/v1/admin/specialties?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`),
});
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 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),
defaultValues: { status: '1', weight: '0' },
});
const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1', weight: '0' }); };
const createMutation = useMutation({
mutationFn: (d: SpecialtyForm) => api.post('/api/v1/admin/specialty', { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1'), parent_id: d.parent_id ?? null }),
onSuccess: () => { toast.success('تخصص اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-specialties'] }); },
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: ({ id, d }: { id: number; d: SpecialtyForm }) => api.patch(`/api/v1/admin/specialty/${id}`, { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1'), parent_id: d.parent_id ?? null }),
onSuccess: () => { toast.success('تخصص بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-specialties'] }); },
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (s: SpecialtyFull) => api.delete(`/api/v1/admin/specialty/${s.id}`),
onSuccess: () => { toast.success('تخصص حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-specialties'] }); },
onError: (err: Error) => toast.error(err.message),
});
const openEdit = (s: SpecialtyFull) => {
setEditTarget(s);
reset({ name: s.name, weight: String(s.weight), status: String(s.status), parent_id: s.parent_id ?? null });
};
const parentOptions = rootItems.map((s) => ({ value: s.id, label: s.name }));
const columns: Column<SpecialtyFull>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.id}</span> },
{ key: 'name', header: 'نام', render: (s) => <b>{s.name}</b> },
{ key: 'slug', header: 'slug', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.slug}</span> },
{ key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? <span className="chip">{parentMap[s.parent_id] ?? `#${s.parent_id}`}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (s) => <span className="muted">{s.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (s) => <SBadge status={s.status} /> },
];
return (
<>
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/specialties/export" exportFile="specialties.json" bundle="specialties" entityLabel="تخصص‌ها" onImported={() => { qc.invalidateQueries({ queryKey: ['admin-specialties'] }); qc.invalidateQueries({ queryKey: ['admin-specialties-roots'] }); }} />
<DataTable<SpecialtyFull> 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={(s) => (
<>
<button onClick={() => openEdit(s)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(s)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن تخصص'} size="sm" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="specialty-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="specialty-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام تخصص" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>تخصص والد</label>
<Controller name="parent_id" control={control} render={({ field }) => (
<SearchableSelect options={parentOptions} value={field.value ?? null} onChange={(v) => field.onChange(v as number | null)} placeholder="-- بدون والد --" isClearable noOptionsMessage="موردی یافت نشد" />
)} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
<Controller name="status" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
)} />
</div>
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف تخصص" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} />
</>
);
}
// ── DoctorServices Tab ─────────────────────────────────────────────────────────
const serviceSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
specialty_id: z.number().nullable().optional(),
weight: z.string().optional(),
status: z.string().optional(),
});
type ServiceForm = z.infer<typeof serviceSchema>;
function DoctorServicesTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<DoctorService | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DoctorService | null>(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-doctor-services', page, search, idSort],
queryFn: () => api.get<PaginatedResponse<DoctorService>>(`/api/v1/admin/doctor-services?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`),
});
const specialtiesQuery = useQuery({
queryKey: ['admin-specialties-select'],
queryFn: () => api.get<PaginatedResponse<SpecialtyFull>>('/api/v1/admin/specialties?limit=100'),
staleTime: 60_000,
});
const specialtyOptions = (specialtiesQuery.data?.data ?? []).map((s) => ({ value: s.id, label: s.name }));
const specialtyMap = Object.fromEntries((specialtiesQuery.data?.data ?? []).map((s) => [s.id, s.name]));
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const { register, handleSubmit, reset, control, formState: { errors } } = useForm<ServiceForm>({
resolver: zodResolver(serviceSchema),
defaultValues: { status: '1', weight: '0' },
});
const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1', weight: '0' }); };
const createMutation = useMutation({
mutationFn: (d: ServiceForm) => api.post('/api/v1/admin/doctor-service', { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1'), specialty_id: d.specialty_id ?? null }),
onSuccess: () => { toast.success('خدمت اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-doctor-services'] }); },
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: ({ id, d }: { id: number; d: ServiceForm }) => api.patch(`/api/v1/admin/doctor-service/${id}`, { name: d.name.trim(), weight: parseInt(d.weight ?? '0'), status: parseInt(d.status ?? '1'), specialty_id: d.specialty_id ?? null }),
onSuccess: () => { toast.success('خدمت بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-doctor-services'] }); },
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (s: DoctorService) => api.delete(`/api/v1/admin/doctor-service/${s.id}`),
onSuccess: () => { toast.success('خدمت حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-doctor-services'] }); },
onError: (err: Error) => toast.error(err.message),
});
const openEdit = (s: DoctorService) => {
setEditTarget(s);
reset({ name: s.name, weight: String(s.weight), status: String(s.status), specialty_id: s.specialty_id ?? null });
};
const columns: Column<DoctorService>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (s) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.id}</span> },
{ key: 'name', header: 'نام', render: (s) => <b>{s.name}</b> },
{ key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? <span className="badge violet"><span className="bdot" />{specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`}</span> : <span className="muted"></span> },
{ key: 'weight', header: 'ترتیب', render: (s) => <span className="muted">{s.weight}</span> },
{ key: 'status', header: 'وضعیت', render: (s) => <SBadge status={s.status} /> },
];
return (
<>
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/doctor_services/export" exportFile="doctor-services.json" bundle="doctor_services" entityLabel="خدمات پزشک" onImported={() => qc.invalidateQueries({ queryKey: ['admin-doctor-services'] })} />
<DataTable<DoctorService> 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={(s) => (
<>
<button onClick={() => openEdit(s)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(s)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن خدمت'} size="sm" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="service-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="service-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام خدمت" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>تخصص مرتبط</label>
<Controller name="specialty_id" control={control} render={({ field }) => (
<SearchableSelect options={specialtyOptions} value={field.value ?? null} onChange={(v) => field.onChange(v as number | null)} placeholder="-- انتخاب تخصص --" isClearable isLoading={specialtiesQuery.isLoading} noOptionsMessage="تخصصی یافت نشد" />
)} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>ترتیب نمایش</label>
<input {...numericField(register('weight'))} className="input" placeholder="0" />
</div>
<div className="form-row">
<label>وضعیت</label>
<Controller name="status" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
)} />
</div>
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف خدمت" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} />
</>
);
}
// ── Insurances Tab ─────────────────────────────────────────────────────────────
const insuranceSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
type: z.enum(['basic', 'supplementary']),
status: z.string().optional(),
});
type InsuranceForm = z.infer<typeof insuranceSchema>;
function InsurancesTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Insurance | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Insurance | null>(null);
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [uploadTarget, setUploadTarget] = useState<number | null>(null);
const [coverageTarget, setCoverageTarget] = useState<Insurance | null>(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-insurances', page, search, idSort],
queryFn: () => api.get<PaginatedResponse<Insurance>>(`/api/v1/admin/insurances?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<InsuranceForm>({
resolver: zodResolver(insuranceSchema),
defaultValues: { status: '1', type: 'basic' },
});
const closeModal = () => { setAddOpen(false); setEditTarget(null); setLogoUrl(null); reset({ status: '1', type: 'basic' }); };
const createMutation = useMutation({
mutationFn: (d: InsuranceForm) => api.post('/api/v1/admin/insurance', { name: d.name.trim(), type: d.type, status: parseInt(d.status ?? '1'), logo_url: logoUrl }),
onSuccess: () => { toast.success('بیمه اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-insurances'] }); },
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: ({ id, d }: { id: number; d: InsuranceForm }) => api.patch(`/api/v1/admin/insurance/${id}`, { name: d.name.trim(), type: d.type, status: parseInt(d.status ?? '1'), logo_url: logoUrl }),
onSuccess: () => { toast.success('بیمه بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-insurances'] }); },
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (i: Insurance) => api.delete(`/api/v1/admin/insurance/${i.id}`),
onSuccess: () => { toast.success('بیمه حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-insurances'] }); },
onError: (err: Error) => toast.error(err.message),
});
const openEdit = (i: Insurance) => {
setEditTarget(i);
setLogoUrl(i.logo_url ?? null);
setUploadTarget(i.id);
reset({ name: i.name, type: i.type, status: String(i.status) });
};
// برچسب نوع خدمت از سرور می‌آید؛ افزودن نوع تازه نیازی به تغییر این صفحه ندارد.
const { categories } = useServiceCategories();
const categoryLabel = (key: string) => categories.find((c) => c.key === key)?.label ?? key;
const columns: Column<Insurance>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (i) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{i.id}</span> },
{ key: 'name', header: 'نام', render: (i) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{i.logo_url ? (
<img src={i.logo_url} alt={i.name} style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface)', padding: 2, flexShrink: 0 }} />
) : (
<div style={{ width: 32, height: 32, borderRadius: 8, border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<PhotoIcon style={{ width: 14, height: 14, color: 'var(--text-3)' }} />
</div>
)}
<b>{i.name}</b>
</div>
)},
{ key: 'type', header: 'نوع', render: (i) => <span className={`badge ${i.type === 'basic' ? 'green' : 'blue'}`}><span className="bdot" />{i.type === 'basic' ? 'پایه' : 'تکمیلی'}</span> },
{ key: 'coverage_defaults', header: 'درصد پوشش', render: (i) => {
const entries = Object.entries(i.coverage_defaults ?? {});
if (entries.length === 0) return <span className="muted"></span>;
return (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{entries.map(([key, percent]) => `${categoryLabel(key)} ${formatNumber(percent)}٪`).join(' · ')}
</span>
);
}},
{ key: 'status', header: 'وضعیت', render: (i) => <SBadge status={i.status} /> },
];
return (
<>
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/insurances/export" exportFile="insurances.json" bundle="insurances" entityLabel="بیمه‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-insurances'] })} />
<DataTable<Insurance> 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={(i) => (
<>
<button onClick={() => setCoverageTarget(i)} className="mini-btn" title="تنظیمات پوشش"><AdjustmentsHorizontalIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => openEdit(i)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(i)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
<InsuranceCoverageDefaultsModal insurance={coverageTarget} onClose={() => setCoverageTarget(null)} />
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن بیمه'} size="sm" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="insurance-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="insurance-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>لگو</label>
<LogoUploadField
value={logoUrl}
onChange={setLogoUrl}
uploadUrl={uploadTarget ? `/api/v1/admin/insurance/${uploadTarget}/upload-logo` : '/api/v1/admin/insurance/0/upload-logo'}
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام بیمه" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 12 }}>
<div className="form-row">
<label>نوع <span style={{ color: 'var(--danger)' }}>*</span></label>
<Controller name="type" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: 'basic', label: 'بیمه پایه' }, { value: 'supplementary', label: 'بیمه تکمیلی' }]} value={field.value} onChange={(v) => field.onChange(v ?? 'basic')} isClearable={false} />
)} />
</div>
<div className="form-row">
<label>وضعیت</label>
<Controller name="status" control={control} render={({ field }) => (
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
)} />
</div>
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف بیمه" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} />
</>
);
}
// ── Tags Tab ───────────────────────────────────────────────────────────────────
const tagSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
status: z.string().optional(),
});
type TagForm = z.infer<typeof tagSchema>;
function TagsTab() {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Tag | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Tag | null>(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-tags', page, search, idSort],
queryFn: () => api.get<PaginatedResponse<Tag>>(`/api/v1/admin/tags?page=${page}&limit=20&search=${encodeURIComponent(search)}${sortQs}`),
});
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const { register, handleSubmit, reset, formState: { errors } } = useForm<TagForm>({
resolver: zodResolver(tagSchema),
defaultValues: { status: '1' },
});
const closeModal = () => { setAddOpen(false); setEditTarget(null); reset({ status: '1' }); };
const createMutation = useMutation({
mutationFn: (d: TagForm) => api.post('/api/v1/admin/tag', { name: d.name.trim(), status: parseInt(d.status ?? '1') }),
onSuccess: () => { toast.success('تگ اضافه شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-tags'] }); },
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: ({ id, d }: { id: number; d: TagForm }) => api.patch(`/api/v1/admin/tag/${id}`, { name: d.name.trim(), status: parseInt(d.status ?? '1') }),
onSuccess: () => { toast.success('تگ بروزرسانی شد'); closeModal(); qc.invalidateQueries({ queryKey: ['admin-tags'] }); },
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: (t: Tag) => api.delete(`/api/v1/admin/tag/${t.id}`),
onSuccess: () => { toast.success('تگ حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-tags'] }); },
onError: (err: Error) => toast.error(err.message),
});
const openEdit = (t: Tag) => {
setEditTarget(t);
reset({ name: t.name, status: String(t.status) });
};
const columns: Column<Tag>[] = [
{ key: 'id', sortable: true, header: 'شناسه', render: (t) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{t.id}</span> },
{ key: 'name', header: 'نام', render: (t) => <span className="chip">{t.name}</span> },
{ key: 'slug', header: 'slug', render: (t) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{t.slug}</span> },
{ key: 'status', header: 'وضعیت', render: (t) => <SBadge status={t.status} /> },
];
return (
<>
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} exportUrl="/api/v1/admin/categories/tags/export" exportFile="tags.json" bundle="tags" entityLabel="تگ‌ها" onImported={() => qc.invalidateQueries({ queryKey: ['admin-tags'] })} />
<DataTable<Tag> 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={(t) => (
<>
<button onClick={() => openEdit(t)} className="mini-btn" title="ویرایش"><PencilIcon style={{ width: 14, height: 14 }} /></button>
<button onClick={() => setDeleteTarget(t)} className="mini-btn danger" title="حذف"><TrashIcon style={{ width: 14, height: 14 }} /></button>
</>
)}
/>
{total > 20 && <div style={{ padding: 'var(--card-pad)' }}><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
<Modal open={addOpen || !!editTarget} title={editTarget ? `ویرایش — ${editTarget.name}` : 'افزودن تگ'} size="sm" onClose={closeModal}
footer={<><button onClick={closeModal} className="btn ghost sm">لغو</button><button form="tag-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="btn primary sm">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
>
<form id="tag-form" onSubmit={handleSubmit((d) => editTarget ? updateMutation.mutate({ id: editTarget.id, d }) : createMutation.mutate(d))}>
<div className="form-row">
<label>نام <span style={{ color: 'var(--danger)' }}>*</span></label>
<input {...register('name')} className="input" placeholder="نام تگ" />
{errors.name && <p className="err-text">{errors.name.message}</p>}
</div>
</form>
</Modal>
<ConfirmDialog open={!!deleteTarget} title="حذف تگ" message={`آیا از حذف "${deleteTarget?.name}" اطمینان دارید؟`} confirmLabel="حذف" danger loading={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} />
</>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────────
export default function CategoriesPage() {
const [activeTab, setActiveTab] = useState<TabKey>('provinces');
const tab = TABS.find((t) => t.key === activeTab)!;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">دسته‌بندی‌ها</h1>
<div className="muted">{tab.label}</div>
</div>
</div>
<div className="card">
{/* Tab bar */}
<div style={{ display: 'flex', overflowX: 'auto', borderBottom: '1px solid var(--border)', padding: '0 var(--card-pad)' }}>
{TABS.map((t) => {
const Icon = t.icon;
const isActive = t.key === activeTab;
return (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '14px 14px', whiteSpace: 'nowrap',
fontSize: 13, fontWeight: 500,
border: 'none', background: 'none', cursor: 'pointer',
borderBottom: `2px solid ${isActive ? 'var(--primary)' : 'transparent'}`,
color: isActive ? 'var(--primary)' : 'var(--text-2)',
marginBottom: -1, transition: 'color .15s',
}}
>
<span style={{
width: 26, height: 26, borderRadius: 8, display: 'grid', placeItems: 'center',
background: isActive ? `oklch(0.62 0.15 ${t.hue} / 0.12)` : 'var(--surface-2)',
color: isActive ? `oklch(0.55 0.15 ${t.hue})` : 'var(--text-3)',
flexShrink: 0, transition: 'all .15s',
}}>
<Icon style={{ width: 14, height: 14 }} />
</span>
{t.label}
</button>
);
})}
</div>
{/* Content */}
{activeTab === 'provinces' && <ProvincesTab />}
{activeTab === 'cities' && <CitiesTab />}
{activeTab === 'specialties' && <SpecialtiesTab />}
{activeTab === 'doctor_services' && <DoctorServicesTab />}
{activeTab === 'insurances' && <InsurancesTab />}
{activeTab === 'tags' && <TagsTab />}
</div>
</div>
);
}