Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
TrashIcon, PlusIcon, PencilIcon, TagIcon, MapPinIcon,
|
||||
BuildingOffice2Icon, HeartIcon, ShieldCheckIcon, WrenchScrewdriverIcon,
|
||||
CheckCircleIcon, XCircleIcon, PhotoIcon, XMarkIcon,
|
||||
PhotoIcon, XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -13,77 +13,33 @@ 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 PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
// ── Tab types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabKey = 'provinces' | 'cities' | 'specialties' | 'doctor_services' | 'insurances' | 'tags';
|
||||
|
||||
interface TabConfig {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
description: string;
|
||||
hue: number;
|
||||
}
|
||||
|
||||
const TABS: TabConfig[] = [
|
||||
{
|
||||
key: 'provinces',
|
||||
label: 'استانها',
|
||||
icon: MapPinIcon,
|
||||
color: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10',
|
||||
description: 'مدیریت استانها',
|
||||
},
|
||||
{
|
||||
key: 'cities',
|
||||
label: 'شهرها',
|
||||
icon: BuildingOffice2Icon,
|
||||
color: 'text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-400/10',
|
||||
description: 'مدیریت شهرها و اطلاعات تفصیلی هر شهر',
|
||||
},
|
||||
{
|
||||
key: 'specialties',
|
||||
label: 'تخصصها',
|
||||
icon: HeartIcon,
|
||||
color: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-400/10',
|
||||
description: 'تخصصهای پزشکی',
|
||||
},
|
||||
{
|
||||
key: 'doctor_services',
|
||||
label: 'خدمات پزشک',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
color: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10',
|
||||
description: 'خدمات قابل ارائه توسط پزشکان',
|
||||
},
|
||||
{
|
||||
key: 'insurances',
|
||||
label: 'بیمهها',
|
||||
icon: ShieldCheckIcon,
|
||||
color: 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-400/10',
|
||||
description: 'بیمههای پایه و تکمیلی',
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'تگها',
|
||||
icon: TagIcon,
|
||||
color: 'text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-400/10',
|
||||
description: 'تگهای مورد استفاده در مطالب بلاگ',
|
||||
},
|
||||
{ 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 ─────────────────────────────────────────────────────────────
|
||||
// ── Logo uploader ──────────────────────────────────────────────────────────────
|
||||
|
||||
function LogoUploadField({
|
||||
value,
|
||||
onChange,
|
||||
uploadUrl,
|
||||
}: {
|
||||
function LogoUploadField({ value, onChange, uploadUrl }: {
|
||||
value: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
uploadUrl: string;
|
||||
@@ -121,60 +77,57 @@ function LogoUploadField({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{value ? (
|
||||
<div className="relative group">
|
||||
<img src={value} alt="logo" className="w-16 h-16 object-contain rounded-xl border border-slate-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 hover:bg-red-600 rounded-full text-white flex items-center justify-center shadow transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-3 h-3" />
|
||||
<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 className="w-16 h-16 rounded-xl border-2 border-dashed border-slate-300 dark:border-gray-600 flex items-center justify-center bg-slate-50 dark:bg-gray-800">
|
||||
<PhotoIcon className="w-6 h-6 text-slate-300 dark:text-gray-600" />
|
||||
<div 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={`cursor-pointer cp-btn-secondary text-sm ${uploading ? 'opacity-60 pointer-events-none' : ''}`}>
|
||||
{uploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||
</svg>
|
||||
در حال آپلود...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<PhotoIcon className="w-4 h-4" />
|
||||
{value ? 'تغییر لگو' : 'انتخاب لگو'}
|
||||
</span>
|
||||
)}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={handleFile} disabled={uploading} />
|
||||
<label 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>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">JPG، PNG یا WebP</p>
|
||||
<span className="muted" style={{ fontSize: 11 }}>JPG، PNG یا WebP</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── StatusBadge helper ────────────────────────────────────────────────────────
|
||||
// ── Status badge ───────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: number }) {
|
||||
return status === 1 ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-400/10 px-2 py-0.5 rounded-full">
|
||||
<CheckCircleIcon className="w-3 h-3" /> فعال
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-400/10 px-2 py-0.5 rounded-full">
|
||||
<XCircleIcon className="w-3 h-3" /> غیرفعال
|
||||
function SBadge({ status }: { status: number }) {
|
||||
return (
|
||||
<span className={`badge ${status === 1 ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />
|
||||
{status === 1 ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provinces Tab ─────────────────────────────────────────────────────────────
|
||||
// ── Tab sub-component wrapper ──────────────────────────────────────────────────
|
||||
|
||||
function TabActions({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 'var(--card-pad)' }}>
|
||||
<button onClick={onClick} className="btn primary sm">
|
||||
<PlusIcon style={{ width: 15, height: 15 }} /> {label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provinces Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
const provinceSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -230,56 +183,44 @@ function ProvincesTab() {
|
||||
};
|
||||
|
||||
const columns: Column<Province>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (p) => <span className="text-xs text-slate-400 font-mono">{p.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (p) => <span className="font-medium text-slate-800 dark:text-slate-100">{p.name}</span> },
|
||||
{ key: 'weight', header: 'ترتیب', className: 'w-20', render: (p) => <span className="text-xs text-slate-400">{p.weight}</span> },
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (p) => <StatusBadge status={p.status} /> },
|
||||
{ key: 'id', 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} /> },
|
||||
];
|
||||
|
||||
const formBody = (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام استان" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">ترتیب نمایش</label>
|
||||
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<Controller name="status" control={control} render={({ field }) => (
|
||||
<SearchableSelect options={[{ value: '1', label: 'فعال' }, { value: '0', label: 'غیرفعال' }]} value={field.value ?? '1'} onChange={(v) => field.onChange(v ?? '1')} isClearable={false} />
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن استان
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<Province> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در استانها..." emptyMessage="هیچ استانی یافت نشد"
|
||||
actions={(p) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(p)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(p)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن استان" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
|
||||
<DataTable<Province> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="province-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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))}>
|
||||
{formBody}
|
||||
<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>
|
||||
|
||||
@@ -289,7 +230,7 @@ function ProvincesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cities Tab ────────────────────────────────────────────────────────────────
|
||||
// ── Cities Tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const citySchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -399,92 +340,86 @@ function CitiesTab() {
|
||||
};
|
||||
|
||||
const columns: Column<City>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (c) => <span className="text-xs text-slate-400 font-mono">{c.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (c) => <span className="font-medium text-slate-800 dark:text-slate-100">{c.name}</span> },
|
||||
{ key: 'province_id', header: 'استان', render: (c) => c.province_id ? <span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-2 py-0.5 rounded-full">{provinceMap[c.province_id] ?? `#${c.province_id}`}</span> : <span className="text-slate-300">—</span> },
|
||||
{ key: 'representation_id', header: 'نماینده', render: (c) => c.representation_id ? <span className="text-xs text-slate-500 dark:text-slate-400">{representationMap[c.representation_id] ?? `#${c.representation_id}`}</span> : <span className="text-slate-300">—</span> },
|
||||
{ key: 'domain', header: 'دامنه', render: (c) => c.domain ? <span className="text-xs font-mono text-slate-500">{c.domain}</span> : <span className="text-slate-300">—</span> },
|
||||
{ key: 'weight', header: 'ترتیب', className: 'w-20', render: (c) => <span className="text-xs text-slate-400">{c.weight}</span> },
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (c) => <StatusBadge status={c.status} /> },
|
||||
{ key: 'id', header: 'شناسه', render: (c) => <span className="muted" style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (c) => <b>{c.name}</b> },
|
||||
{ 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 (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن شهر
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<City> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در شهرها..." emptyMessage="هیچ شهری یافت نشد"
|
||||
actions={(c) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(c)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(c)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن شهر" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
|
||||
<DataTable<City> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="city-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام شهر" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">استان</label>
|
||||
<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 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>
|
||||
<label className="cp-label">نماینده</label>
|
||||
<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="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="cp-label">تلفن تماس</label>
|
||||
<input {...register('contact_phone')} className="cp-input h-10" dir="ltr" placeholder="021xxxxxxxx" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">ایمیل</label>
|
||||
<input {...register('email')} type="email" className="cp-input h-10" dir="ltr" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>تلفن تماس</label>
|
||||
<input {...register('contact_phone')} className="input" dir="ltr" placeholder="021xxxxxxxx" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">دامنه</label>
|
||||
<input {...register('domain')} className="cp-input h-10" dir="ltr" />
|
||||
<div className="form-row">
|
||||
<label>ایمیل</label>
|
||||
<input {...register('email')} type="email" className="input" dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">شعار</label>
|
||||
<input {...register('slogan')} className="cp-input h-10" />
|
||||
</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>
|
||||
<label className="cp-label">کلیدواژهها</label>
|
||||
<input {...register('keywords')} className="cp-input h-10" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">توضیحات</label>
|
||||
<textarea {...register('description')} rows={3} className="cp-textarea" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">توضیحات فوتر</label>
|
||||
<textarea {...register('footer_description')} rows={2} className="cp-textarea" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">ترتیب نمایش</label>
|
||||
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<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} />
|
||||
)} />
|
||||
@@ -499,7 +434,7 @@ function CitiesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Specialties Tab ───────────────────────────────────────────────────────────
|
||||
// ── Specialties Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
const specialtySchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -559,55 +494,49 @@ function SpecialtiesTab() {
|
||||
const parentOptions = items.filter((s) => s.parent_id === null).map((s) => ({ value: s.id, label: s.name }));
|
||||
|
||||
const columns: Column<SpecialtyFull>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (s) => <span className="text-xs text-slate-400 font-mono">{s.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (s) => <span className="font-medium text-slate-800 dark:text-slate-100">{s.name}</span> },
|
||||
{ key: 'slug', header: 'slug', render: (s) => <span className="text-xs font-mono text-slate-400">{s.slug}</span> },
|
||||
{ key: 'parent_id', header: 'والد', render: (s) => s.parent_id ? <span className="text-xs bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded-full">{parentMap[s.parent_id] ?? `#${s.parent_id}`}</span> : <span className="text-slate-300">—</span> },
|
||||
{ key: 'weight', header: 'ترتیب', className: 'w-20', render: (s) => <span className="text-xs text-slate-400">{s.weight}</span> },
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (s) => <StatusBadge status={s.status} /> },
|
||||
{ key: 'id', 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 (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن تخصص
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<SpecialtyFull> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تخصصها..." emptyMessage="هیچ تخصصی یافت نشد"
|
||||
actions={(s) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(s)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(s)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن تخصص" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
|
||||
<DataTable<SpecialtyFull> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="specialty-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام تخصص" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
<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>
|
||||
<label className="cp-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>
|
||||
<label className="cp-label">ترتیب نمایش</label>
|
||||
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<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} />
|
||||
)} />
|
||||
@@ -622,7 +551,7 @@ function SpecialtiesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── DoctorServices Tab ────────────────────────────────────────────────────────
|
||||
// ── DoctorServices Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -687,54 +616,48 @@ function DoctorServicesTab() {
|
||||
};
|
||||
|
||||
const columns: Column<DoctorService>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (s) => <span className="text-xs text-slate-400 font-mono">{s.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (s) => <span className="font-medium text-slate-800 dark:text-slate-100">{s.name}</span> },
|
||||
{ key: 'specialty_id', header: 'تخصص', render: (s) => s.specialty_id ? <span className="text-xs bg-rose-50 dark:bg-rose-400/10 text-rose-600 dark:text-rose-400 px-2 py-0.5 rounded-full">{specialtyMap[s.specialty_id] ?? `#${s.specialty_id}`}</span> : <span className="text-slate-300">—</span> },
|
||||
{ key: 'weight', header: 'ترتیب', className: 'w-20', render: (s) => <span className="text-xs text-slate-400">{s.weight}</span> },
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (s) => <StatusBadge status={s.status} /> },
|
||||
{ key: 'id', 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 (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن خدمت
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<DoctorService> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در خدمات..." emptyMessage="هیچ خدمتی یافت نشد"
|
||||
actions={(s) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(s)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(s)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن خدمت" onClick={() => { reset({ status: '1', weight: '0' }); setAddOpen(true); }} />
|
||||
<DataTable<DoctorService> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="service-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام خدمت" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
<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>
|
||||
<label className="cp-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>
|
||||
<label className="cp-label">ترتیب نمایش</label>
|
||||
<input {...register('weight')} type="number" dir="ltr" className="cp-input h-11 w-32" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<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} />
|
||||
)} />
|
||||
@@ -749,7 +672,7 @@ function DoctorServicesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Insurances Tab ────────────────────────────────────────────────────────────
|
||||
// ── Insurances Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const insuranceSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -809,72 +732,62 @@ function InsurancesTab() {
|
||||
};
|
||||
|
||||
const columns: Column<Insurance>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (i) => <span className="text-xs text-slate-400 font-mono">{i.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (i) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{ key: 'id', 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} className="w-8 h-8 object-contain rounded-lg border border-slate-200 dark:border-gray-600 bg-white p-0.5 shrink-0" />
|
||||
<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 className="w-8 h-8 rounded-lg border border-dashed border-slate-200 dark:border-gray-600 flex items-center justify-center shrink-0">
|
||||
<PhotoIcon className="w-4 h-4 text-slate-300" />
|
||||
<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>
|
||||
)}
|
||||
<span className="font-medium text-slate-800 dark:text-slate-100">{i.name}</span>
|
||||
<b>{i.name}</b>
|
||||
</div>
|
||||
)},
|
||||
{ key: 'type', header: 'نوع', render: (i) => (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${i.type === 'basic' ? 'bg-teal-50 dark:bg-teal-400/10 text-teal-700 dark:text-teal-400' : 'bg-cyan-50 dark:bg-cyan-400/10 text-cyan-700 dark:text-cyan-400'}`}>
|
||||
{i.type === 'basic' ? 'پایه' : 'تکمیلی'}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (i) => <StatusBadge status={i.status} /> },
|
||||
{ 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 (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن بیمه
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در بیمهها..." emptyMessage="هیچ بیمهای یافت نشد"
|
||||
actions={(i) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(i)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(i)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن بیمه" onClick={() => { reset({ status: '1', type: 'basic' }); setLogoUrl(null); setUploadTarget(null); setAddOpen(true); }} />
|
||||
<DataTable<Insurance> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="insurance-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<label className="cp-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>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام بیمه" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">نوع <span className="text-red-500">*</span></label>
|
||||
<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>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<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} />
|
||||
)} />
|
||||
@@ -889,7 +802,7 @@ function InsurancesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tags Tab ──────────────────────────────────────────────────────────────────
|
||||
// ── Tags Tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const tagSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
@@ -944,45 +857,33 @@ function TagsTab() {
|
||||
};
|
||||
|
||||
const columns: Column<Tag>[] = [
|
||||
{ key: 'id', header: 'شناسه', className: 'w-16', render: (t) => <span className="text-xs text-slate-400 font-mono">{t.id}</span> },
|
||||
{ key: 'name', header: 'نام', render: (t) => <span className="font-medium text-slate-800 dark:text-slate-100">{t.name}</span> },
|
||||
{ key: 'slug', header: 'slug', render: (t) => <span className="text-xs font-mono text-slate-400">{t.slug}</span> },
|
||||
{ key: 'status', header: 'وضعیت', className: 'w-24', render: (t) => <StatusBadge status={t.status} /> },
|
||||
{ key: 'id', 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 (
|
||||
<>
|
||||
<div className="flex justify-end px-5 pt-4">
|
||||
<button onClick={() => { reset({ status: '1' }); setAddOpen(true); }} className="cp-btn-primary">
|
||||
<PlusIcon className="w-4 h-4" /> افزودن تگ
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<DataTable<Tag> columns={columns} data={items} loading={isLoading} searchValue={search} onSearchChange={(v) => { setSearch(v); setPage(1); }} searchPlaceholder="جستجو در تگها..." emptyMessage="هیچ تگی یافت نشد"
|
||||
actions={(t) => (
|
||||
<>
|
||||
<button onClick={() => openEdit(t)} className="cp-action-edit" title="ویرایش"><PencilIcon className="w-4 h-4" /></button>
|
||||
<button onClick={() => setDeleteTarget(t)} className="cp-action-delete" title="حذف"><TrashIcon className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{total > 20 && <div className="mt-4"><Pagination page={page} total={total} limit={20} onPageChange={setPage} /></div>}
|
||||
</div>
|
||||
<TabActions label="افزودن تگ" onClick={() => { reset({ status: '1' }); setAddOpen(true); }} />
|
||||
<DataTable<Tag> columns={columns} data={items} loading={isLoading} 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="cp-btn-secondary">لغو</button><button form="tag-form" type="submit" disabled={createMutation.isPending || updateMutation.isPending} className="cp-btn-primary">{(createMutation.isPending || updateMutation.isPending) ? 'در حال ذخیره...' : (editTarget ? 'ذخیره' : 'افزودن')}</button></>}
|
||||
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="space-y-4">
|
||||
<div>
|
||||
<label className="cp-label">نام <span className="text-red-500">*</span></label>
|
||||
<input {...register('name')} className="cp-input h-11" placeholder="نام تگ" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت</label>
|
||||
<input {...register('status')} className="hidden" />
|
||||
</div>
|
||||
<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>
|
||||
@@ -993,24 +894,24 @@ function TagsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('provinces');
|
||||
|
||||
const tab = TABS.find((t) => t.key === activeTab)!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="دستهبندیها"
|
||||
description={tab.description}
|
||||
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'دستهبندیها' }]}
|
||||
/>
|
||||
<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="cp-card overflow-hidden">
|
||||
{/* ── Tab bar ── */}
|
||||
<div className="flex overflow-x-auto border-b border-slate-200 dark:border-gray-700 px-2 pt-2 gap-1 scrollbar-none">
|
||||
<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;
|
||||
@@ -1018,37 +919,37 @@ export default function CategoriesPage() {
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
className={`flex items-center gap-2 px-3.5 py-2.5 text-sm font-medium rounded-t-xl whitespace-nowrap border-b-2 -mb-px transition-all ${
|
||||
isActive
|
||||
? 'border-primary-600 text-primary-600 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-400/5'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<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>
|
||||
|
||||
{/* ── Bundle info bar ── */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 border-b border-slate-100 dark:border-gray-700/50 bg-slate-50/50 dark:bg-gray-800/30">
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${tab.color}`}>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">{tab.label}</p>
|
||||
<p className="text-[11px] text-slate-400 dark:text-slate-500">{tab.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ── */}
|
||||
{activeTab === 'provinces' && <ProvincesTab />}
|
||||
{activeTab === 'cities' && <CitiesTab />}
|
||||
{activeTab === 'specialties' && <SpecialtiesTab />}
|
||||
{/* Content */}
|
||||
{activeTab === 'provinces' && <ProvincesTab />}
|
||||
{activeTab === 'cities' && <CitiesTab />}
|
||||
{activeTab === 'specialties' && <SpecialtiesTab />}
|
||||
{activeTab === 'doctor_services' && <DoctorServicesTab />}
|
||||
{activeTab === 'insurances' && <InsurancesTab />}
|
||||
{activeTab === 'tags' && <TagsTab />}
|
||||
{activeTab === 'insurances' && <InsurancesTab />}
|
||||
{activeTab === 'tags' && <TagsTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user