1128 lines
63 KiB
TypeScript
1128 lines
63 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,
|
|
} 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';
|
|
|
|
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" 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" 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 {...register('weight')} type="number" dir="ltr" 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(),
|
|
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.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 ?? '',
|
|
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 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 {...register('weight')} type="number" dir="ltr" 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 {...register('weight')} type="number" dir="ltr" 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 {...register('weight')} type="number" dir="ltr" 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 [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 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: '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={() => 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>
|
|
</>
|
|
)}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|