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>
This commit is contained in:
hamed
2026-06-14 22:57:30 +03:30
co-authored by Claude Sonnet 4.6
parent 5a4116fee0
commit 94580894c8
4 changed files with 751 additions and 0 deletions
+4
View File
@@ -37,6 +37,8 @@ import StaffPage from './pages/StaffPage';
import SubscriptionPage from './pages/SubscriptionPage';
import ClinicServicesPage from './pages/ClinicServicesPage';
import SmsWalletPage from './pages/SmsWalletPage';
import MySecretariesPage from './pages/MySecretariesPage';
import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
import PwaInstallBanner from './components/ui/PwaInstallBanner';
// ── Guards ──────────────────────────────────────────────────────────────────
@@ -154,6 +156,8 @@ export default function App() {
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']}><SubscriptionPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']}><MySecretariesPage /></RoleRoute>} />
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
{/* فقط ادمین */}
<Route path="clinics/new" element={<RoleRoute roles={['admin']}><ClinicFormPage /></RoleRoute>} />
@@ -21,6 +21,7 @@ import {
UserPlusIcon,
WrenchScrewdriverIcon,
FolderOpenIcon,
IdentificationIcon,
} from "@heroicons/react/24/outline";
import { NavLink, useNavigate } from "react-router-dom";
import { useAuthStore } from "../../stores/authStore";
@@ -65,6 +66,7 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
{ to: '/admin/categories', icon: TagIcon, label: 'دسته‌بندی‌ها' },
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' },
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشی‌ها' },
{ to: '/admin/admin-subscription', icon: CreditCardIcon, label: 'اشتراک‌ها' },
{ to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' },
],
},
@@ -87,6 +89,7 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' },
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' },
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' },
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' },
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' },
],
@@ -120,6 +123,7 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' },
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' },
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' },
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' },
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' },
],
@@ -0,0 +1,432 @@
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 { PaginatedResponse } from '../lib/api';
import type { SubscriptionPlan, SubscriptionPeriod } from '../types';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import Pagination from '../components/ui/Pagination';
// ── Types ─────────────────────────────────────────────────────────────────
interface ReportRow {
uuid: string;
entityType: string;
entityId: number;
isTrial: boolean;
startsAt: number;
expiresAt: number | null;
createdAt: number;
plan_name: string;
plan_level: number;
}
// ── Schemas ───────────────────────────────────────────────────────────────
const planSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
level: z.coerce.number().min(0),
max_secretaries: z.coerce.number().min(1),
features: z.object({
patient_records: z.boolean(),
services: z.boolean(),
sms_panel: z.boolean(),
}),
active: z.boolean(),
});
type PlanForm = z.infer<typeof planSchema>;
const periodSchema = z.object({
plan_uuid: z.string().min(1, 'پنل الزامی است'),
label: z.string().min(1, 'عنوان دوره الزامی است'),
duration_months: z.coerce.number().min(1),
price_rials: z.coerce.number().min(0),
is_trial: z.boolean(),
sort_order: z.coerce.number().min(0),
active: z.boolean(),
});
type PeriodForm = z.infer<typeof periodSchema>;
// ── Feature labels ────────────────────────────────────────────────────────
const FEATURE_LABELS: Record<string, string> = {
patient_records: 'پرونده بیمار',
services: 'سرویس‌ها',
sms_panel: 'پنل پیامک',
};
const PLAN_DISPLAY: Record<string, string> = {
free: 'رایگان',
basic: 'پایه',
professional: 'حرفه‌ای',
};
// ── Plans tab ─────────────────────────────────────────────────────────────
function PlansTab() {
const qc = useQueryClient();
const [planModal, setPlanModal] = useState<'create' | SubscriptionPlan | null>(null);
const [periodModal, setPeriodModal] = useState<'create' | SubscriptionPeriod | null>(null);
const [activePlan, setActivePlan] = useState<SubscriptionPlan | null>(null);
const [deletePeriod, setDeletePeriod] = useState<SubscriptionPeriod | null>(null);
const { data: plansData, isLoading } = useQuery({
queryKey: ['admin-subscription-plans'],
queryFn: () => api.get<PaginatedResponse<SubscriptionPlan>>('/api/v1/admin/subscription/plans'),
});
const plans: SubscriptionPlan[] = (plansData as any)?.data ?? [];
const planForm = useForm<PlanForm>({
resolver: zodResolver(planSchema),
defaultValues: { features: { patient_records: false, services: false, sms_panel: false }, active: true },
});
const periodForm = useForm<PeriodForm>({
resolver: zodResolver(periodSchema),
defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 },
});
const createPlanMut = useMutation({
mutationFn: (body: PlanForm) => api.post('/api/v1/admin/subscription/plan', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پنل ایجاد شد'); },
onError: (e: any) => toast.error(e.message),
});
const updatePlanMut = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: PlanForm }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پنل بروزرسانی شد'); },
onError: (e: any) => toast.error(e.message),
});
const createPeriodMut = useMutation({
mutationFn: (body: PeriodForm) => api.post('/api/v1/admin/subscription/period', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPeriodModal(null); periodForm.reset(); toast.success('دوره ایجاد شد'); },
onError: (e: any) => toast.error(e.message),
});
const updatePeriodMut = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: PeriodForm }) => api.patch(`/api/v1/admin/subscription/period/${uuid}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPeriodModal(null); toast.success('دوره بروزرسانی شد'); },
onError: (e: any) => toast.error(e.message),
});
const deletePeriodMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/period/${uuid}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setDeletePeriod(null); toast.success('دوره حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
const openEditPlan = (plan: SubscriptionPlan) => {
planForm.reset({
name: plan.name,
level: plan.level,
max_secretaries: plan.max_secretaries,
features: { patient_records: plan.features.patient_records ?? false, services: plan.features.services ?? false, sms_panel: plan.features.sms_panel ?? false },
active: (plan as any).active ?? true,
});
setPlanModal(plan);
};
const openEditPeriod = (period: SubscriptionPeriod) => {
periodForm.reset({
plan_uuid: (period as any).plan_uuid ?? '',
label: period.label,
duration_months: period.duration_months,
price_rials: period.price_rials,
is_trial: period.is_trial,
sort_order: (period as any).sort_order ?? 0,
active: (period as any).active ?? true,
});
setPeriodModal(period);
};
const openCreatePeriod = (plan: SubscriptionPlan) => {
setActivePlan(plan);
periodForm.reset({ plan_uuid: plan.uuid, is_trial: false, sort_order: 0, active: true, price_rials: 0, label: '', duration_months: 1 });
setPeriodModal('create');
};
return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn primary sm" onClick={() => { planForm.reset({ features: { patient_records: false, services: false, sms_panel: false }, active: true, level: 0, max_secretaries: 1 }); setPlanModal('create'); }}>
<PlusIcon style={{ width: 16 }} /> پنل جدید
</button>
</div>
{isLoading ? (
<div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{plans.map((plan) => (
<div key={plan.uuid} className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<b style={{ fontSize: 15 }}>{PLAN_DISPLAY[plan.name] ?? plan.name}</b>
<span className="badge blue" style={{ fontSize: 11 }}>سطح {plan.level}</span>
<span className="muted" style={{ fontSize: 12 }}>حداکثر {plan.max_secretaries} منشی</span>
<span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
{Object.entries(plan.features).map(([k, v]) => (
<span key={k} className={`badge ${v ? 'green' : 'gray'}`} style={{ fontSize: 10 }}>{FEATURE_LABELS[k] ?? k}</span>
))}
</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm" onClick={() => openCreatePeriod(plan)}>
<PlusIcon style={{ width: 13 }} /> دوره جدید
</button>
<button className="btn sm" onClick={() => openEditPlan(plan)}>
<PencilIcon style={{ width: 13 }} />
</button>
</div>
</div>
{plan.periods.length === 0 ? (
<div style={{ padding: '20px 16px', color: 'var(--text-3)', fontSize: 13 }}>هنوز دورهای تعریف نشده</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>دوره</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>مدت</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>قیمت</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نوع</th>
<th style={{ textAlign: 'right', padding: '8px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
</tr>
</thead>
<tbody>
{plan.periods.map((period, i) => (
<tr key={period.uuid} style={{ borderBottom: i < plan.periods.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '8px 16px' }}><b>{period.label}</b></td>
<td style={{ padding: '8px 16px' }}>{period.duration_months} ماه</td>
<td style={{ padding: '8px 16px' }}>{period.is_trial ? 'رایگان' : formatRial(period.price_rials)}</td>
<td style={{ padding: '8px 16px' }}>
{period.is_trial ? <span className="badge amber">تریال</span> : <span className="badge blue">پولی</span>}
</td>
<td style={{ padding: '8px 16px' }}>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn sm" onClick={() => openEditPeriod(period)}>
<PencilIcon style={{ width: 13 }} />
</button>
<button className="btn sm" onClick={() => setDeletePeriod(period)}>
<TrashIcon style={{ width: 13 }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
</div>
)}
{/* Modal پنل */}
<Modal
open={planModal !== null}
onClose={() => setPlanModal(null)}
title={planModal === 'create' ? 'پنل جدید' : 'ویرایش پنل'}
>
<form onSubmit={planForm.handleSubmit((d) => {
if (planModal === 'create') createPlanMut.mutate(d);
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d });
})}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
<label>نام (slug) *</label>
<input {...planForm.register('name')} placeholder="basic" dir="ltr" />
{planForm.formState.errors.name && <span className="field-error">{planForm.formState.errors.name.message}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>سطح *</label>
<input {...planForm.register('level')} type="number" min={0} dir="ltr" />
</div>
<div className="field">
<label>حداکثر منشی *</label>
<input {...planForm.register('max_secretaries')} type="number" min={1} dir="ltr" />
</div>
</div>
<div className="field">
<label>قابلیتها</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 }}>
{(Object.keys(FEATURE_LABELS) as (keyof typeof FEATURE_LABELS)[]).map((k) => (
<label key={k} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...planForm.register(`features.${k}` as any)} style={{ accentColor: 'var(--primary)' }} />
{FEATURE_LABELS[k]}
</label>
))}
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...planForm.register('active')} style={{ accentColor: 'var(--primary)' }} />
فعال
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createPlanMut.isPending || updatePlanMut.isPending}>ذخیره</button>
<button type="button" className="btn" onClick={() => setPlanModal(null)}>انصراف</button>
</div>
</form>
</Modal>
{/* Modal دوره */}
<Modal
open={periodModal !== null}
onClose={() => setPeriodModal(null)}
title={periodModal === 'create' ? `دوره جدید — ${PLAN_DISPLAY[activePlan?.name ?? ''] ?? activePlan?.name ?? ''}` : 'ویرایش دوره'}
>
<form onSubmit={periodForm.handleSubmit((d) => {
if (periodModal === 'create') createPeriodMut.mutate(d);
else if (periodModal !== null && typeof periodModal === 'object') updatePeriodMut.mutate({ uuid: periodModal.uuid, body: d });
})}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
<label>عنوان دوره *</label>
<input {...periodForm.register('label')} placeholder="مثلاً: یک ماهه" />
{periodForm.formState.errors.label && <span className="field-error">{periodForm.formState.errors.label.message}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>مدت (ماه) *</label>
<input {...periodForm.register('duration_months')} type="number" min={1} dir="ltr" />
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...periodForm.register('price_rials')} type="number" min={0} dir="ltr" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field">
<label>ترتیب نمایش</label>
<input {...periodForm.register('sort_order')} type="number" min={0} dir="ltr" />
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...periodForm.register('is_trial')} style={{ accentColor: 'var(--primary)' }} />
تریال (رایگان)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, cursor: 'pointer' }}>
<input type="checkbox" {...periodForm.register('active')} style={{ accentColor: 'var(--primary)' }} />
فعال
</label>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button type="submit" className="btn primary" disabled={createPeriodMut.isPending || updatePeriodMut.isPending}>ذخیره</button>
<button type="button" className="btn" onClick={() => setPeriodModal(null)}>انصراف</button>
</div>
</form>
</Modal>
{/* Confirm حذف دوره */}
<ConfirmDialog
open={!!deletePeriod}
title="حذف دوره"
message={`آیا مطمئن هستید که می‌خواهید دوره «${deletePeriod?.label}» را حذف کنید؟`}
confirmLabel="حذف"
danger
loading={deletePeriodMut.isPending}
onConfirm={() => deletePeriod && deletePeriodMut.mutate(deletePeriod.uuid)}
onCancel={() => setDeletePeriod(null)}
/>
</>
);
}
// ── Report tab ────────────────────────────────────────────────────────────
function ReportTab() {
const [page, setPage] = useState(1);
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['admin-subscription-report', page],
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
});
const rows: ReportRow[] = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="card">
{isLoading ? (
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
<div className="card-pad" style={{ color: 'var(--text-3)', textAlign: 'center', padding: '40px 0' }}>هیچ اشتراکی ثبت نشده</div>
) : (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<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>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.uuid} style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px' }}>
<span className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
{row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId}
</span>
</td>
<td style={{ padding: '10px 16px' }}>
<b>{PLAN_DISPLAY[row.plan_name] ?? row.plan_name}</b>
<span className="muted" style={{ fontSize: 11, marginRight: 6 }}>سطح {row.plan_level}</span>
</td>
<td style={{ padding: '10px 16px' }}>
{row.isTrial ? <span className="badge amber">تریال</span> : <span className="badge blue">پولی</span>}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>{formatDate(row.startsAt)}</td>
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ padding: '12px 16px' }}>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</>
)}
</div>
);
}
// ── Main ──────────────────────────────────────────────────────────────────
export default function AdminSubscriptionPage() {
const [tab, setTab] = useState<'plans' | 'report'>('plans');
return (
<>
<PageHeader title="مدیریت اشتراک‌ها" description="تعریف پنل‌ها، دوره‌ها و گزارش فروش" />
<div className="seg" style={{ marginBottom: 20 }}>
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پنلها و دورهها</button>
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
</div>
{tab === 'plans' && <PlansTab />}
{tab === 'report' && <ReportTab />}
</>
);
}
+311
View File
@@ -0,0 +1,311 @@
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)}
/>
</>
);
}