447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { PlusIcon, PencilIcon, TrashIcon, IdentificationIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
|
import { useForm } 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 { ApiResponse } from '../lib/api';
|
|
import type { Secretary, SecretaryPermissions } from '../types';
|
|
import { formatDate, maskMobile } from '../lib/utils';
|
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import Modal from '../components/ui/Modal';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { useSubscription } from '../hooks/useSubscription';
|
|
import { Link } from 'react-router-dom';
|
|
|
|
// ── Default permissions & labels ──────────────────────────────────────────
|
|
|
|
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
|
appointments: { view: true, create: false, cancel: false, update_status: false },
|
|
addresses: { view: true, create: false, update: false, delete: false },
|
|
clinic_info: { view: true, update: false },
|
|
insurances: { view: true, create: false, update: false, delete: 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: 'تغییر وضعیت' },
|
|
],
|
|
},
|
|
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: 'حذف' },
|
|
],
|
|
},
|
|
};
|
|
|
|
const ALL_ACTIONS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
|
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
|
|
|
function PermissionsMatrix({
|
|
permissions,
|
|
onChange,
|
|
}: {
|
|
permissions: SecretaryPermissions;
|
|
onChange: (p: SecretaryPermissions) => void;
|
|
}) {
|
|
const toggle = (section: PermSection, action: string) => {
|
|
const cur = (permissions[section] as Record<string, boolean>)[action];
|
|
onChange({
|
|
...permissions,
|
|
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !cur },
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ background: 'oklch(0.97 0.01 256)' }}>
|
|
<th style={{ textAlign: 'right', padding: '10px 12px', fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>بخش</th>
|
|
{ACTION_HEADERS.map((h) => (
|
|
<th key={h} style={{ textAlign: 'center', padding: '10px 8px', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section, idx) => {
|
|
const config = PERMISSION_LABELS[section];
|
|
const sectionPrm = permissions[section] as Record<string, boolean>;
|
|
return (
|
|
<tr key={section} style={{ background: idx % 2 === 0 ? 'transparent' : 'oklch(0.985 0.005 256)' }}>
|
|
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', fontWeight: 600 }}>
|
|
{config.label}
|
|
</td>
|
|
{ALL_ACTIONS.map((action) => {
|
|
const ac = config.actions.find((a) => a.key === action);
|
|
if (!ac) return (
|
|
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)', color: 'var(--border)' }}>—</td>
|
|
);
|
|
return (
|
|
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={sectionPrm[action] ?? false}
|
|
onChange={() => toggle(section, action)}
|
|
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
|
|
/>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Create form schema ─────────────────────────────────────────────────────
|
|
|
|
const createSchema = z.object({
|
|
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
|
});
|
|
type CreateForm = z.infer<typeof createSchema>;
|
|
|
|
interface ClinicDoctor { uuid: string; name: string; }
|
|
|
|
// ── Main component ─────────────────────────────────────────────────────────
|
|
|
|
export default function MySecretariesPage() {
|
|
const qc = useQueryClient();
|
|
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
|
|
|
|
const isClinic = primaryRole === 'clinic';
|
|
const { maxSecretaries } = useSubscription();
|
|
|
|
// for clinic: selected doctor to add secretary for
|
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>('');
|
|
|
|
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? '');
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
|
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
|
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
|
|
|
// clinic: load clinic's doctors
|
|
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
|
|
queryKey: ['clinic-doctors', dbUuid],
|
|
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
|
enabled: isClinic && !!dbUuid,
|
|
});
|
|
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
|
|
|
|
// clinic: load ALL secretaries across all its doctors
|
|
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
|
queryKey: ['my-secretaries-clinic', dbUuid],
|
|
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
|
|
enabled: isClinic && !!dbUuid,
|
|
});
|
|
|
|
// doctor: load secretaries for the doctor
|
|
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
|
queryKey: ['my-secretaries', activeDoctorUuid],
|
|
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
|
|
enabled: !isClinic && !!activeDoctorUuid,
|
|
});
|
|
|
|
const secretaries = isClinic
|
|
? (clinicSecrData?.data ?? [])
|
|
: (doctorSecrData?.data ?? []);
|
|
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
|
|
|
const createForm = useForm<CreateForm>({ resolver: zodResolver(createSchema) });
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: CreateForm) =>
|
|
api.post('/api/v1/secretary', { ...body, doctor_uuid: activeDoctorUuid }),
|
|
onSuccess: () => {
|
|
toast.success('منشی اضافه شد');
|
|
setCreateOpen(false);
|
|
createForm.reset();
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const updatePermsMutation = useMutation({
|
|
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
|
api.patch(`/api/v1/secretary/${uuid}`, { permissions: { version: 1, resources: permissions } }),
|
|
onSuccess: () => {
|
|
toast.success('دسترسیها بروزرسانی شد');
|
|
setEditTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (uuid: string) => api.delete(`/api/v1/secretary/${uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('منشی غیرفعال شد');
|
|
setDeleteTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const openEdit = (s: Secretary) => {
|
|
setEditTarget(s);
|
|
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
|
};
|
|
|
|
const handleCreateOpen = () => {
|
|
if (isClinic && !selectedDoctorUuid) {
|
|
toast.error('ابتدا یک پزشک را انتخاب کنید');
|
|
return;
|
|
}
|
|
setCreateOpen(true);
|
|
};
|
|
|
|
const selectedDoctorName = clinicDoctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
|
|
|
// for clinic: show doctor column in the table
|
|
const clinicColumns: Column<Secretary>[] = isClinic ? [
|
|
{
|
|
key: 'doctor_name',
|
|
header: 'پزشک',
|
|
render: (s) => (
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)', fontWeight: 500 }}>{s.doctor_name}</span>
|
|
),
|
|
},
|
|
] : [];
|
|
|
|
const allColumns: Column<Secretary>[] = [
|
|
{
|
|
key: 'user_name',
|
|
header: 'منشی',
|
|
render: (s) => (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div
|
|
className="avatar sm"
|
|
style={{
|
|
background: 'linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))',
|
|
flexShrink: 0, fontWeight: 700,
|
|
}}
|
|
>
|
|
{(s.user_name ?? '?').charAt(0)}
|
|
</div>
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.user_name}</div>
|
|
</div>
|
|
),
|
|
},
|
|
...clinicColumns,
|
|
{
|
|
key: 'mobile_number',
|
|
header: 'موبایل',
|
|
render: (s) => (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 13, color: 'var(--text-2)' }}>
|
|
<PhoneIcon style={{ width: 13 }} />
|
|
<span dir="ltr">{maskMobile(s.mobile_number)}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'is_active',
|
|
header: 'وضعیت',
|
|
render: (s) => <ActiveBadge active={s.is_active} />,
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'تاریخ ثبت',
|
|
render: (s) => (
|
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'uuid',
|
|
header: 'عملیات',
|
|
render: (s) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش دسترسیها">
|
|
<PencilIcon style={{ width: 14 }} />
|
|
</button>
|
|
<button className="btn sm" onClick={() => setDeleteTarget(s)} title="غیرفعالسازی">
|
|
<TrashIcon style={{ width: 14 }} />
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="منشیان من"
|
|
description="مدیریت منشیان و دسترسیهای آنها"
|
|
action={
|
|
secretaries.length >= maxSecretaries ? (
|
|
<Link to="/admin/subscription" className="btn sm" style={{ textDecoration: 'none', opacity: 0.8 }} title={`حداکثر ${maxSecretaries} منشی مجاز است`}>
|
|
ارتقاء پنل برای افزودن منشی
|
|
</Link>
|
|
) : (
|
|
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}>
|
|
<PlusIcon style={{ width: 16 }} />
|
|
افزودن منشی
|
|
</button>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* کلینیک: انتخاب پزشک برای افزودن منشی */}
|
|
{isClinic && (
|
|
<div className="card card-pad" style={{ marginBottom: 16 }}>
|
|
<div className="field" style={{ marginBottom: 0 }}>
|
|
<label>پزشک مورد نظر برای افزودن منشی جدید</label>
|
|
{clinicDoctorsLoading ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</p>
|
|
) : clinicDoctors.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا پزشک اضافه کنید.</p>
|
|
) : (
|
|
<select
|
|
value={selectedDoctorUuid}
|
|
onChange={(e) => setSelectedDoctorUuid(e.target.value)}
|
|
style={{ width: '100%', maxWidth: 360 }}
|
|
>
|
|
<option value="">— یک پزشک را انتخاب کنید —</option>
|
|
{clinicDoctors.map((d) => (
|
|
<option key={d.uuid} value={d.uuid}>{d.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isClinic && !doctorUuid ? (
|
|
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}>
|
|
پروفایل پزشک یافت نشد
|
|
</div>
|
|
) : (
|
|
<div className="card">
|
|
{secretaries.length === 0 && !isLoading ? (
|
|
<div style={{ textAlign: 'center', padding: '60px 24px' }}>
|
|
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز منشیای اضافه نشده</div>
|
|
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
|
{isClinic
|
|
? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید'
|
|
: 'منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند'}
|
|
</div>
|
|
{!isClinic && (
|
|
<button className="btn primary sm" onClick={handleCreateOpen}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<DataTable columns={allColumns} data={secretaries} loading={isLoading} emptyMessage="منشیای ثبت نشده است" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal افزودن منشی */}
|
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="افزودن منشی جدید">
|
|
{isClinic && selectedDoctorName && (
|
|
<div style={{ marginBottom: 12, padding: '8px 12px', background: 'var(--primary-subtle)', borderRadius: 8, fontSize: 13, color: 'var(--primary)' }}>
|
|
منشی برای دکتر {selectedDoctorName} اضافه میشود
|
|
</div>
|
|
)}
|
|
<form onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}>
|
|
<div className="field">
|
|
<label>شماره موبایل *</label>
|
|
<input
|
|
{...createForm.register('mobile_number')}
|
|
placeholder="09123456789"
|
|
dir="ltr"
|
|
/>
|
|
{createForm.formState.errors.mobile_number && (
|
|
<span className="field-error">{createForm.formState.errors.mobile_number.message}</span>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '8px 0 16px' }}>
|
|
کاربری با این شماره در سیستم جستجو شده و به عنوان منشی اضافه میشود.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button type="submit" className="btn primary" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'در حال افزودن...' : 'افزودن'}
|
|
</button>
|
|
<button type="button" className="btn" onClick={() => setCreateOpen(false)}>انصراف</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal ویرایش دسترسیها */}
|
|
<Modal
|
|
open={!!editTarget}
|
|
onClose={() => setEditTarget(null)}
|
|
title={`دسترسیهای ${editTarget?.user_name ?? ''}`}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<button className="btn ghost sm" onClick={() => setEditTarget(null)}>لغو</button>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={updatePermsMutation.isPending}
|
|
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
|
>
|
|
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
|
</Modal>
|
|
|
|
{/* Confirm غیرفعالسازی */}
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="غیرفعالسازی منشی"
|
|
message={`آیا مطمئن هستید که میخواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟`}
|
|
confirmLabel="غیرفعالسازی"
|
|
danger
|
|
loading={deleteMutation.isPending}
|
|
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.uuid)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|