Files
clinicpro/assets/admin/pages/MySecretariesPage.tsx
T
hamedandClaude Sonnet 4.6 94580894c8 feat: add MySecretariesPage and AdminSubscriptionPage (PRD completion)
- 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>
2026-06-14 22:57:30 +03:30

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