351 lines
14 KiB
TypeScript
351 lines
14 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';
|
|
|
|
// ── 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>;
|
|
|
|
// ── Main component ─────────────────────────────────────────────────────────
|
|
|
|
export default function MySecretariesPage() {
|
|
const qc = useQueryClient();
|
|
const { doctorUuid } = useAuthStore();
|
|
|
|
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);
|
|
|
|
const { data, isLoading } = useQuery<ApiResponse<Secretary[]>>({
|
|
queryKey: ['my-secretaries', doctorUuid],
|
|
queryFn: () => api.get(`/api/v1/secretaries/${doctorUuid}`),
|
|
enabled: !!doctorUuid,
|
|
});
|
|
|
|
const secretaries = data?.data ?? [];
|
|
|
|
const createForm = useForm<CreateForm>({ resolver: zodResolver(createSchema) });
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: CreateForm) =>
|
|
api.post('/api/v1/secretary', { ...body, doctor_uuid: doctorUuid }),
|
|
onSuccess: () => {
|
|
toast.success('منشی اضافه شد');
|
|
setCreateOpen(false);
|
|
createForm.reset();
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const updatePermsMutation = useMutation({
|
|
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
|
api.patch(`/api/v1/secretary/${uuid}`, { permissions }),
|
|
onSuccess: () => {
|
|
toast.success('دسترسیها بروزرسانی شد');
|
|
setEditTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
|
},
|
|
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'] });
|
|
},
|
|
onError: (e: any) => toast.error(e.message),
|
|
});
|
|
|
|
const openEdit = (s: Secretary) => {
|
|
setEditTarget(s);
|
|
setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS);
|
|
};
|
|
|
|
const columns: 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>
|
|
),
|
|
},
|
|
{
|
|
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={
|
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon style={{ width: 16 }} />
|
|
افزودن منشی
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{!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 }}>
|
|
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
|
</div>
|
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<DataTable columns={columns} data={secretaries} loading={isLoading} emptyMessage="منشیای ثبت نشده است" />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal افزودن منشی */}
|
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="افزودن منشی جدید">
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|