Files
clinicpro/assets/admin/pages/MySecretariesPage.tsx
T

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)}
/>
</>
);
}