Files
clinicpro/assets/admin/pages/SecretariesPage.tsx
T
hamedandClaude Opus 4.8 89e23a2c0d feat: port secretaries tab from tauri to admin my-secretaries page
- Redesign MySecretariesPage pixel-perfect to clinic-pro-tauri (active/previous
  tabs, desktop table, mobile cards, add/edit/view modal with permission
  accordions, deactivate confirm)
- Permission sections based on existing admin pages (appointments, patients,
  payments, insurances, addresses, clinic_info)
- Extend DoctorSecretary with national_code + address columns (+migration);
  wire create/update in SecretaryController; add patients/payments to
  DEFAULT_PERMISSIONS
- Extend Secretary/SecretaryPermissions types; update admin SecretariesPage
- Backend + frontend tests; update docs/api/secretary.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:23:17 +03:30

271 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Secretary, SecretaryPermissions } from '../types';
import { formatDate, 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 },
};
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: 'حذف' },
],
},
};
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();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
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: '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) => (
<>
<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>
);
}