From ed18c260caa2ace1c83845aae0dff797f728b7fc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 14 Jun 2026 22:23:30 +0330 Subject: [PATCH] feat: add Staff, Subscription, ClinicServices, SmsWallet pages (TASK-10,11,13,14) Co-Authored-By: Claude Sonnet 4.6 --- assets/admin/App.tsx | 10 + assets/admin/components/layout/Sidebar.tsx | 23 ++ assets/admin/pages/ClinicServicesPage.tsx | 312 +++++++++++++++++++++ assets/admin/pages/SmsWalletPage.tsx | 231 +++++++++++++++ assets/admin/pages/StaffPage.tsx | 213 ++++++++++++++ assets/admin/pages/SubscriptionPage.tsx | 238 ++++++++++++++++ assets/admin/types/index.ts | 98 +++++++ 7 files changed, 1125 insertions(+) create mode 100644 assets/admin/pages/ClinicServicesPage.tsx create mode 100644 assets/admin/pages/SmsWalletPage.tsx create mode 100644 assets/admin/pages/StaffPage.tsx create mode 100644 assets/admin/pages/SubscriptionPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 05daa030..cbcbb02a 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -33,6 +33,10 @@ import MyPatientsPage from './pages/MyPatientsPage'; import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; +import StaffPage from './pages/StaffPage'; +import SubscriptionPage from './pages/SubscriptionPage'; +import ClinicServicesPage from './pages/ClinicServicesPage'; +import SmsWalletPage from './pages/SmsWalletPage'; import PwaInstallBanner from './components/ui/PwaInstallBanner'; // ── Guards ────────────────────────────────────────────────────────────────── @@ -145,6 +149,12 @@ export default function App() { } /> } /> + {/* فاز ۲ — دکتر / کلینیک */} + } /> + } /> + } /> + } /> + {/* فقط ادمین */} } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 9c5daa91..09aed2cd 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -18,6 +18,9 @@ import { UserCircleIcon, UserGroupIcon, UsersIcon, + UserPlusIcon, + WrenchScrewdriverIcon, + FolderOpenIcon, } from "@heroicons/react/24/outline"; import { NavLink, useNavigate } from "react-router-dom"; import { useAuthStore } from "../../stores/authStore"; @@ -82,6 +85,16 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti label: 'مدیریت', items: [ { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, + { to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, + { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, + { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, + ], + }, + { + label: 'اشتراک', + items: [ + { to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, ], }, ]; @@ -105,6 +118,16 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti label: 'مدیریت', items: [ { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, + { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, + { to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, + { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, + { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, + ], + }, + { + label: 'اشتراک', + items: [ + { to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, ], }, ]; diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx new file mode 100644 index 00000000..d768ee5b --- /dev/null +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -0,0 +1,312 @@ +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 { ServiceSection, ServiceItem, ClinicStaff } from '../types'; +import { formatRial } from '../lib/utils'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import PageHeader from '../components/ui/PageHeader'; +import SearchableSelect from '../components/ui/SearchableSelect'; + +const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') }); +const itemSchema = z.object({ + name: z.string().min(1, 'نام سرویس الزامی است'), + price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'), + staff_uuid: z.string().optional(), +}); +type SectionForm = z.infer; +type ItemForm = z.infer; + +const EMPTY_SECTIONS: ServiceSection[] = []; +const EMPTY_ITEMS: ServiceItem[] = []; + +export default function ClinicServicesPage() { + const qc = useQueryClient(); + + const [selectedSection, setSelectedSection] = useState(null); + const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null); + const [deleteSection, setDeleteSection] = useState(null); + const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null); + const [deleteItem, setDeleteItem] = useState(null); + + const { data: sectionsData, isLoading: sectionsLoading } = useQuery>({ + queryKey: ['service-sections'], + queryFn: () => api.get('/api/v1/service-sections'), + }); + + const { data: itemsData, isLoading: itemsLoading } = useQuery>({ + queryKey: ['service-items', selectedSection?.uuid], + queryFn: () => api.get(`/api/v1/service-items/${selectedSection!.uuid}`), + enabled: !!selectedSection, + }); + + const { data: staffData } = useQuery>({ + queryKey: ['staff'], + queryFn: () => api.get('/api/v1/staff'), + }); + + const sections = sectionsData?.data ?? EMPTY_SECTIONS; + const items = itemsData?.data ?? EMPTY_ITEMS; + const staffOptions = (staffData?.data ?? []).filter((s) => s.active).map((s) => ({ + value: s.uuid, + label: s.full_name, + })); + + const sectionForm = useForm({ resolver: zodResolver(sectionSchema) }); + const itemForm = useForm({ resolver: zodResolver(itemSchema) }); + + const createSection = useMutation({ + mutationFn: (body: SectionForm) => api.post('/api/v1/service-section', body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-sections'] }); setSectionModal(null); sectionForm.reset(); toast.success('بخش ایجاد شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const editSection = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: SectionForm }) => + api.patch(`/api/v1/service-section/${uuid}`, body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-sections'] }); setSectionModal(null); toast.success('بخش ویرایش شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const delSection = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/service-section/${uuid}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['service-sections'] }); + if (selectedSection?.uuid === deleteSection?.uuid) setSelectedSection(null); + setDeleteSection(null); + toast.success('بخش حذف شد'); + }, + onError: (e: any) => { toast.error(e.message); setDeleteSection(null); }, + }); + + const createItem = useMutation({ + mutationFn: (body: ItemForm & { section_uuid: string }) => api.post('/api/v1/service-item', body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-items', selectedSection?.uuid] }); setItemModal(null); itemForm.reset(); toast.success('سرویس ایجاد شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const editItem = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: ItemForm }) => + api.patch(`/api/v1/service-item/${uuid}`, body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-items', selectedSection?.uuid] }); setItemModal(null); toast.success('سرویس ویرایش شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const delItem = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/service-item/${uuid}`), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-items', selectedSection?.uuid] }); setDeleteItem(null); toast.success('سرویس حذف شد'); }, + onError: (e: any) => { + if (e.code === 'ERR_SERVICE_ITEM_IN_USE') { + toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست'); + } else { + toast.error(e.message); + } + setDeleteItem(null); + }, + }); + + const openEditSection = (s: ServiceSection) => { + sectionForm.reset({ name: s.name }); + setSectionModal(s); + }; + + const openEditItem = (item: ServiceItem) => { + itemForm.reset({ + name: item.name, + price_rials: item.price_rials, + staff_uuid: item.staff?.uuid ?? '', + }); + setItemModal(item); + }; + + return ( + <> + + +
+ {/* ستون بخش‌ها */} +
+
+ بخش‌ها + +
+ {sectionsLoading ? ( +
در حال بارگذاری...
+ ) : sections.length === 0 ? ( +
بخشی ثبت نشده است
+ ) : ( +
+ {sections.map((s) => ( +
setSelectedSection(s)} + > + {s.name} +
e.stopPropagation()}> + + +
+
+ ))} +
+ )} +
+ + {/* ستون آیتم‌ها */} +
+ {!selectedSection ? ( +
+ یک بخش را از سمت راست انتخاب کنید +
+ ) : ( + <> +
+ سرویس‌های «{selectedSection.name}» + +
+ {itemsLoading ? ( +
در حال بارگذاری...
+ ) : items.length === 0 ? ( +
سرویسی ثبت نشده است
+ ) : ( + + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
نام سرویسقیمتپرسنل مسئولعملیات
{item.name}{formatRial(item.price_rials)}{item.staff?.full_name ?? '—'} +
+ + +
+
+ )} + + )} +
+
+ + {/* Modal بخش */} + setSectionModal(null)} + title={sectionModal === 'create' ? 'بخش جدید' : 'ویرایش بخش'} + > +
{ + if (sectionModal === 'create') createSection.mutate(d); + else if (sectionModal !== null && typeof sectionModal === 'object') editSection.mutate({ uuid: sectionModal.uuid, body: d }); + })}> +
+ + + {sectionForm.formState.errors.name && ( + {sectionForm.formState.errors.name.message} + )} +
+
+ + +
+
+
+ + {/* Modal آیتم */} + setItemModal(null)} + title={itemModal === 'create' ? 'سرویس جدید' : 'ویرایش سرویس'} + > +
{ + if (itemModal === 'create' && selectedSection) { + createItem.mutate({ ...d, section_uuid: selectedSection.uuid }); + } else if (itemModal !== null && typeof itemModal === 'object') { + editItem.mutate({ uuid: itemModal.uuid, body: d }); + } + })}> +
+
+ + + {itemForm.formState.errors.name && ( + {itemForm.formState.errors.name.message} + )} +
+
+ + +
+
+ + itemForm.setValue('staff_uuid', v != null ? String(v) : undefined)} + placeholder="انتخاب پرسنل (اختیاری)" + isClearable + /> +
+
+
+ + +
+
+
+ + {/* Confirm حذف بخش */} + deleteSection && delSection.mutate(deleteSection.uuid)} + onCancel={() => setDeleteSection(null)} + loading={delSection.isPending} + /> + + {/* Confirm حذف آیتم */} + deleteItem && delItem.mutate(deleteItem.uuid)} + onCancel={() => setDeleteItem(null)} + loading={delItem.isPending} + /> + + ); +} diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx new file mode 100644 index 00000000..b2f37cfc --- /dev/null +++ b/assets/admin/pages/SmsWalletPage.tsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +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, PaginatedResponse } from '../lib/api'; +import type { SmsWalletBalance, SmsWalletLog, SmsSettings } from '../types'; +import { formatRial, formatNumber, formatDateTime } from '../lib/utils'; +import Modal from '../components/ui/Modal'; +import Pagination from '../components/ui/Pagination'; +import PageHeader from '../components/ui/PageHeader'; + +const chargeSchema = z.object({ + amount_rials: z.coerce.number().min(10000, 'حداقل مبلغ ۱۰,۰۰۰ ریال است'), +}); +type ChargeForm = z.infer; + +const EMPTY_LOGS: SmsWalletLog[] = []; + +export default function SmsWalletPage() { + const qc = useQueryClient(); + const [chargeOpen, setChargeOpen] = useState(false); + const [gateway, setGateway] = useState<'mellat' | 'sep'>('mellat'); + const [logPage, setLogPage] = useState(1); + + const { data: balanceData, isLoading: balanceLoading } = useQuery>({ + queryKey: ['sms-wallet-balance'], + queryFn: () => api.get('/api/v1/sms/wallet/balance'), + }); + + const { data: logsData, isLoading: logsLoading } = useQuery>({ + queryKey: ['sms-wallet-logs', logPage], + queryFn: () => api.get(`/api/v1/sms/wallet/logs?page=${logPage}&limit=20`), + }); + + const { data: settingsData } = useQuery>({ + queryKey: ['sms-settings'], + queryFn: () => api.get('/api/v1/sms/settings'), + }); + + const balance = balanceData?.data; + const logs = logsData?.data ?? EMPTY_LOGS; + const total = logsData?.meta?.totalRecords ?? 0; + const settings = settingsData?.data; + + const chargeForm = useForm({ resolver: zodResolver(chargeSchema) }); + + const chargeMutation = useMutation({ + mutationFn: ({ amount_rials }: ChargeForm) => + api.post<{ data: { payment_url: string } }>('/api/v1/sms/wallet/charge', { gateway, amount_rials }), + onSuccess: (res: any) => { + const url = res?.data?.payment_url; + if (url) window.location.href = url; + else toast.error('خطا در دریافت لینک پرداخت'); + }, + onError: (e: any) => toast.error(e.message), + }); + + const [localSettings, setLocalSettings] = useState(null); + const currentSettings = localSettings ?? settings; + + const saveMutation = useMutation({ + mutationFn: (body: SmsSettings) => api.patch('/api/v1/sms/settings', body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['sms-settings'] }); toast.success('تنظیمات ذخیره شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + return ( + <> + + +
+ {/* کارت موجودی */} +
+
موجودی کیف پول
+ {balanceLoading ? ( +
+ ) : ( + <> +
+ {formatRial(balance?.balance_rials ?? 0)} +
+
+ معادل {formatNumber(balance?.estimated_sms_count ?? 0)} پیامک + {balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''} +
+ + + )} +
+ + {/* تنظیمات پیامک */} +
+
تنظیمات ارسال
+ {currentSettings ? ( +
+ + {currentSettings.reminder_enabled && ( +
+ + setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })} + dir="ltr" + style={{ width: 80 }} + /> +
+ )} + + {currentSettings.post_visit_enabled && ( +
+ +