- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL. - Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages. - Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values. - Add SlotPicker component for selecting appointment slots based on availability. - Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL. - Update API documentation to reflect changes in appointment creation and slot selection processes.
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline';
|
|
import { Link } from 'react-router-dom';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import type { Secretary, SecretaryPermissions } from '../types';
|
|
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
|
|
import DataTable, { Column } from '../components/ui/DataTable';
|
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import Modal from '../components/ui/Modal';
|
|
|
|
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
|
appointments: { view: true, create: false, cancel: false, update_status: false },
|
|
patients: { view: true, create: false, update: false, delete: false },
|
|
payments: { view: true, create: false, update: false, delete: false },
|
|
insurances: { view: true, create: false, update: false, delete: false },
|
|
addresses: { view: true, create: false, update: false, delete: false },
|
|
clinic_info: { view: true, update: false },
|
|
inventory: { view: false, create: false, update: false, delete: false },
|
|
tags: { view: false, create: false, update: false, delete: false },
|
|
services: { view: false, create: false, update: false, delete: false },
|
|
staff: { view: false, create: false, update: false, delete: false },
|
|
discounts: { view: false, create: false, update: false, delete: false },
|
|
sms: { view: false, create: false, update: false, delete: false },
|
|
appointment_settings: { view: false, update: false },
|
|
clinic_doctors: { view: false, create: false, update: false, delete: false },
|
|
subscription: { view: false, create: false },
|
|
};
|
|
|
|
type PermSection = keyof SecretaryPermissions;
|
|
|
|
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
|
|
appointments: {
|
|
label: 'نوبتها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'cancel', label: 'لغو' },
|
|
{ key: 'update_status', label: 'تغییر وضعیت' },
|
|
],
|
|
},
|
|
patients: {
|
|
label: 'پرونده بیماران',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
payments: {
|
|
label: 'پرداختها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
addresses: {
|
|
label: 'آدرسها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
clinic_info: {
|
|
label: 'اطلاعات کلینیک',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
],
|
|
},
|
|
insurances: {
|
|
label: 'بیمهها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
inventory: {
|
|
label: 'انبار',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
tags: {
|
|
label: 'تگها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
services: {
|
|
label: 'خدمات و تعرفهها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
staff: {
|
|
label: 'پرسنل',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
discounts: {
|
|
label: 'تخفیفها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
sms: {
|
|
label: 'پیامکها',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'شارژ/ارسال' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
appointment_settings: {
|
|
label: 'تنظیمات نوبتدهی',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
],
|
|
},
|
|
clinic_doctors: {
|
|
label: 'مدیریت پزشکان کلینیک',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'ایجاد' },
|
|
{ key: 'update', label: 'ویرایش' },
|
|
{ key: 'delete', label: 'حذف' },
|
|
],
|
|
},
|
|
subscription: {
|
|
label: 'خرید اشتراک',
|
|
actions: [
|
|
{ key: 'view', label: 'مشاهده' },
|
|
{ key: 'create', label: 'خرید' },
|
|
],
|
|
},
|
|
};
|
|
|
|
function PermissionsMatrix({
|
|
permissions,
|
|
onChange,
|
|
}: {
|
|
permissions: SecretaryPermissions;
|
|
onChange: (p: SecretaryPermissions) => void;
|
|
}) {
|
|
const toggle = (section: PermSection, action: string) => {
|
|
const current = (permissions[section] as Record<string, boolean>)[action];
|
|
onChange({
|
|
...permissions,
|
|
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !current },
|
|
});
|
|
};
|
|
|
|
const allActions = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
|
const actionHeaders = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="t">
|
|
<thead>
|
|
<tr>
|
|
<th>بخش</th>
|
|
{actionHeaders.map((h) => (
|
|
<th key={h} style={{ textAlign: 'center', fontSize: 12 }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => {
|
|
const config = PERMISSION_LABELS[section];
|
|
const sectionPerms = permissions[section] as Record<string, boolean>;
|
|
return (
|
|
<tr key={section}>
|
|
<td><b>{config.label}</b></td>
|
|
{allActions.map((action) => {
|
|
const actionConfig = config.actions.find((a) => a.key === action);
|
|
if (!actionConfig) {
|
|
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
|
}
|
|
return (
|
|
<td key={action} style={{ textAlign: 'center' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={sectionPerms[action] ?? false}
|
|
onChange={() => toggle(section, action)}
|
|
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
|
|
/>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SecretariesPage() {
|
|
const qc = useQueryClient();
|
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین جستجو و صفحه را برگرداند.
|
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '' });
|
|
const page = pageOf(urlState.page);
|
|
const search = urlState.search;
|
|
const setPage = (p: number) => setUrlState({ page: String(p) });
|
|
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
|
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
|
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
|
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
|
const limit = 15;
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['secretaries', page, search],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (search) params.set('search', search);
|
|
return api.get<PaginatedResponse<Secretary>>(`/api/v1/admin/secretaries?${params}`);
|
|
},
|
|
});
|
|
|
|
const updatePermsMutation = useMutation({
|
|
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
|
api.patch<ApiResponse<null>>(`/api/v1/secretary/${uuid}`, { permissions }),
|
|
onSuccess: () => {
|
|
toast.success('دسترسیها بروزرسانی شد');
|
|
setEditTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['secretaries'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (s: Secretary) => api.delete<ApiResponse<null>>(`/api/v1/secretary/${s.uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('منشی حذف شد');
|
|
setDeleteTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['secretaries'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const openEdit = (s: Secretary) => {
|
|
setEditTarget(s);
|
|
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
|
};
|
|
|
|
const columns: Column<Secretary>[] = [
|
|
{ key: 'user_name', header: 'نام', render: (s) => <b>{s.user_name}</b> },
|
|
{ key: 'mobile_number', header: 'موبایل', render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span> },
|
|
{ key: 'doctor_name', header: 'پزشک', render: (s) => s.doctor_name },
|
|
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
|
|
{ key: 'online_share_percent', header: 'سهم آنلاین', render: (s) => (
|
|
s.online_share_enabled
|
|
? <span style={{ fontSize: 12.5, color: 'var(--primary)', fontWeight: 700 }}>{formatNumber(s.online_share_percent ?? 0)}٪</span>
|
|
: <span className="muted">—</span>
|
|
) },
|
|
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
|
|
];
|
|
|
|
const items = data?.data ?? [];
|
|
const total = data?.meta?.totalRecords ?? 0;
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">منشیها</h1>
|
|
<div className="muted">{total} منشی ثبتشده</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
|
<div className="toolbar">
|
|
<div className="field" style={{ minWidth: 240 }}>
|
|
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
placeholder="جستجو بر اساس نام یا پزشک..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DataTable<Secretary>
|
|
columns={columns}
|
|
data={items}
|
|
loading={isLoading}
|
|
emptyMessage="هیچ منشیای یافت نشد"
|
|
actions={(sec) => (
|
|
<>
|
|
<Link to={`/admin/secretaries/${sec.uuid}`} className="mini-btn" title="جزئیات و سهم درآمد">
|
|
<CurrencyDollarIcon style={{ width: 15, height: 15 }} />
|
|
</Link>
|
|
<button onClick={() => openEdit(sec)} className="mini-btn" title="ویرایش دسترسیها">
|
|
<PencilIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button onClick={() => setDeleteTarget(sec)} className="mini-btn danger" title="حذف">
|
|
<TrashIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
</>
|
|
)}
|
|
/>
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</div>
|
|
|
|
<Modal
|
|
open={!!editTarget}
|
|
title={`دسترسیهای ${editTarget?.user_name}`}
|
|
size="lg"
|
|
onClose={() => setEditTarget(null)}
|
|
footer={
|
|
<>
|
|
<button onClick={() => setEditTarget(null)} className="btn ghost sm">لغو</button>
|
|
<button
|
|
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
|
disabled={updatePermsMutation.isPending}
|
|
className="btn primary sm">
|
|
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
|
</Modal>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="حذف منشی"
|
|
message={`آیا از حذف منشی "${deleteTarget?.user_name}" اطمینان دارید؟`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={deleteMutation.isPending}
|
|
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|