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:
co-authored by
Claude Sonnet 4.6
parent
5a4116fee0
commit
94580894c8
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user