From 94580894c8d5b2b276fe4599679079bb472ea525 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 14 Jun 2026 22:57:30 +0330 Subject: [PATCH] feat: add MySecretariesPage and AdminSubscriptionPage (PRD completion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- assets/admin/App.tsx | 4 + assets/admin/components/layout/Sidebar.tsx | 4 + assets/admin/pages/AdminSubscriptionPage.tsx | 432 +++++++++++++++++++ assets/admin/pages/MySecretariesPage.tsx | 311 +++++++++++++ 4 files changed, 751 insertions(+) create mode 100644 assets/admin/pages/AdminSubscriptionPage.tsx create mode 100644 assets/admin/pages/MySecretariesPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index cbcbb02a..27b0d597 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> {/* فقط ادمین */} } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 09aed2cd..3ba968ef 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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: 'کیف پول پیامک' }, ], diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx new file mode 100644 index 00000000..89d72415 --- /dev/null +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -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; + +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; + +// ── Feature labels ──────────────────────────────────────────────────────── + +const FEATURE_LABELS: Record = { + patient_records: 'پرونده بیمار', + services: 'سرویس‌ها', + sms_panel: 'پنل پیامک', +}; + +const PLAN_DISPLAY: Record = { + 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(null); + const [deletePeriod, setDeletePeriod] = useState(null); + + const { data: plansData, isLoading } = useQuery({ + queryKey: ['admin-subscription-plans'], + queryFn: () => api.get>('/api/v1/admin/subscription/plans'), + }); + + const plans: SubscriptionPlan[] = (plansData as any)?.data ?? []; + + const planForm = useForm({ + resolver: zodResolver(planSchema), + defaultValues: { features: { patient_records: false, services: false, sms_panel: false }, active: true }, + }); + + const periodForm = useForm({ + 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 ( + <> +
+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : ( +
+ {plans.map((plan) => ( +
+
+
+ {PLAN_DISPLAY[plan.name] ?? plan.name} + سطح {plan.level} + حداکثر {plan.max_secretaries} منشی + + {Object.entries(plan.features).map(([k, v]) => ( + {FEATURE_LABELS[k] ?? k} + ))} + +
+
+ + +
+
+ + {plan.periods.length === 0 ? ( +
هنوز دوره‌ای تعریف نشده
+ ) : ( + + + + + + + + + + + + {plan.periods.map((period, i) => ( + + + + + + + + ))} + +
دورهمدتقیمتنوععملیات
{period.label}{period.duration_months} ماه{period.is_trial ? 'رایگان' : formatRial(period.price_rials)} + {period.is_trial ? تریال : پولی} + +
+ + +
+
+ )} +
+ ))} +
+ )} + + {/* Modal پنل */} + setPlanModal(null)} + title={planModal === 'create' ? 'پنل جدید' : 'ویرایش پنل'} + > +
{ + if (planModal === 'create') createPlanMut.mutate(d); + else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d }); + })}> +
+
+ + + {planForm.formState.errors.name && {planForm.formState.errors.name.message}} +
+
+
+ + +
+
+ + +
+
+
+ +
+ {(Object.keys(FEATURE_LABELS) as (keyof typeof FEATURE_LABELS)[]).map((k) => ( + + ))} +
+
+ +
+
+ + +
+
+
+ + {/* Modal دوره */} + setPeriodModal(null)} + title={periodModal === 'create' ? `دوره جدید — ${PLAN_DISPLAY[activePlan?.name ?? ''] ?? activePlan?.name ?? ''}` : 'ویرایش دوره'} + > +
{ + if (periodModal === 'create') createPeriodMut.mutate(d); + else if (periodModal !== null && typeof periodModal === 'object') updatePeriodMut.mutate({ uuid: periodModal.uuid, body: d }); + })}> +
+
+ + + {periodForm.formState.errors.label && {periodForm.formState.errors.label.message}} +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + {/* Confirm حذف دوره */} + 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>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`), + }); + + const rows: ReportRow[] = data?.data ?? []; + const total = data?.meta?.totalRecords ?? 0; + + return ( +
+ {isLoading ? ( +
در حال بارگذاری...
+ ) : rows.length === 0 ? ( +
هیچ اشتراکی ثبت نشده
+ ) : ( + <> + + + + + + + + + + + + + {rows.map((row, i) => ( + + + + + + + + + ))} + +
نوعپنلنوع اشتراکشروعانقضاتاریخ ثبت
+ + {row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId} + + + {PLAN_DISPLAY[row.plan_name] ?? row.plan_name} + سطح {row.plan_level} + + {row.isTrial ? تریال : پولی} + {formatDate(row.startsAt)} + {row.expiresAt ? formatDate(row.expiresAt) : بی‌نهایت} + {formatDate(row.createdAt)}
+
+ +
+ + )} +
+ ); +} + +// ── Main ────────────────────────────────────────────────────────────────── + +export default function AdminSubscriptionPage() { + const [tab, setTab] = useState<'plans' | 'report'>('plans'); + + return ( + <> + + +
+ + +
+ + {tab === 'plans' && } + {tab === 'report' && } + + ); +} diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx new file mode 100644 index 00000000..bdcb8803 --- /dev/null +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -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 = { + 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)[action]; + onChange({ + ...permissions, + [section]: { ...(permissions[section] as Record), [action]: !cur }, + }); + }; + + return ( +
+ + + + + {ACTION_HEADERS.map((h) => ( + + ))} + + + + {(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section) => { + const config = PERMISSION_LABELS[section]; + const sectionPrm = permissions[section] as Record; + return ( + + + {ALL_ACTIONS.map((action) => { + const ac = config.actions.find((a) => a.key === action); + if (!ac) return ; + return ( + + ); + })} + + ); + })} + +
بخش{h}
{config.label} + toggle(section, action)} + style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }} + /> +
+
+ ); +} + +// ── Create form schema ───────────────────────────────────────────────────── + +const createSchema = z.object({ + mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'), +}); +type CreateForm = z.infer; + +// ── Main component ───────────────────────────────────────────────────────── + +export default function MySecretariesPage() { + const qc = useQueryClient(); + const { doctorUuid } = useAuthStore(); + + const [createOpen, setCreateOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [editPerms, setEditPerms] = useState(DEFAULT_PERMISSIONS); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data, isLoading } = useQuery>({ + queryKey: ['my-secretaries', doctorUuid], + queryFn: () => api.get(`/api/v1/secretaries/${doctorUuid}`), + enabled: !!doctorUuid, + }); + + const secretaries = data?.data ?? []; + + const createForm = useForm({ 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 ( + <> + setCreateOpen(true)}> + + افزودن منشی + + } + /> + + {!doctorUuid ? ( +
+ پروفایل پزشک یافت نشد +
+ ) : isLoading ? ( +
در حال بارگذاری...
+ ) : secretaries.length === 0 ? ( +
+ هنوز منشی‌ای ثبت نشده است +
+ ) : ( +
+ + + + + + + + + + + + {secretaries.map((s, i) => ( + + + + + + + + ))} + +
نامموبایلوضعیتتاریخ ثبتعملیات
{s.user_name}{maskMobile(s.mobile_number)}{formatDate(Number(s.created_at))} +
+ + +
+
+
+ )} + + {/* Modal افزودن منشی */} + setCreateOpen(false)} title="افزودن منشی جدید"> +
createMutation.mutate(d))}> +
+ + + {createForm.formState.errors.mobile_number && ( + {createForm.formState.errors.mobile_number.message} + )} +
+

+ کاربری با این شماره در سیستم جستجو شده و به عنوان منشی اضافه می‌شود. +

+
+ + +
+
+
+ + {/* Modal ویرایش دسترسی‌ها */} + setEditTarget(null)} + title={`دسترسی‌های ${editTarget?.user_name ?? ''}`} + size="lg" + footer={ + <> + + + + } + > + + + + {/* Confirm غیرفعال‌سازی */} + deleteTarget && deleteMutation.mutate(deleteTarget.uuid)} + onCancel={() => setDeleteTarget(null)} + /> + + ); +}