- MySecretariesPage: doctor/clinic can manage their own secretaries
with add/permissions-matrix/deactivate; uses GET /api/v1/secretaries/{doctorUuid}
- AdminSubscriptionPage: admin can create/edit plans and periods,
view subscription sales report; tabs: پنلها / گزارش فروش
- Sidebar: add منشیان link for doctor+clinic roles, add اشتراکها link for admin
- App.tsx: add /my-secretaries and /admin-subscription routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { PlusIcon, PencilIcon, TrashIcon } 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 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 className="t">
|
|
<thead>
|
|
<tr>
|
|
<th>بخش</th>
|
|
{ACTION_HEADERS.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 sectionPrm = permissions[section] as Record<string, boolean>;
|
|
return (
|
|
<tr key={section}>
|
|
<td><b>{config.label}</b></td>
|
|
{ALL_ACTIONS.map((action) => {
|
|
const ac = config.actions.find((a) => a.key === action);
|
|
if (!ac) return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}>—</td>;
|
|
return (
|
|
<td key={action} style={{ textAlign: 'center' }}>
|
|
<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);
|
|
};
|
|
|
|
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>
|
|
) : isLoading ? (
|
|
<div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
|
|
) : secretaries.length === 0 ? (
|
|
<div className="card card-pad" style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-3)' }}>
|
|
هنوز منشیای ثبت نشده است
|
|
</div>
|
|
) : (
|
|
<div className="card">
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نام</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>موبایل</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>وضعیت</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
|
|
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{secretaries.map((s, i) => (
|
|
<tr key={s.uuid} style={{ borderBottom: i < secretaries.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
<td style={{ padding: '10px 16px' }}><b>{s.user_name}</b></td>
|
|
<td style={{ padding: '10px 16px', direction: 'ltr', textAlign: 'left' }}>{maskMobile(s.mobile_number)}</td>
|
|
<td style={{ padding: '10px 16px' }}><ActiveBadge active={s.is_active} /></td>
|
|
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</td>
|
|
<td style={{ padding: '10px 16px' }}>
|
|
<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>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|