diff --git a/assets/admin/hooks/usePwaInstall.ts b/assets/admin/hooks/usePwaInstall.ts index a6349d76..b01d5b44 100644 --- a/assets/admin/hooks/usePwaInstall.ts +++ b/assets/admin/hooks/usePwaInstall.ts @@ -1,47 +1,67 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; export interface BeforeInstallPromptEvent extends Event { - prompt(): Promise; - userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; + prompt(): Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } -const DISMISSED_KEY = 'pwa-dismissed'; +const DISMISSED_KEY = "pwa-dismissed"; export function usePwaInstall() { - const [promptEvent, setPromptEvent] = useState(null); - const [isInstalled, setIsInstalled] = useState(false); - const [isDismissed, setIsDismissed] = useState(() => !!localStorage.getItem(DISMISSED_KEY)); + const [promptEvent, setPromptEvent] = + useState(null); + const [isInstalled, setIsInstalled] = useState(false); + const [isDismissed, setIsDismissed] = useState( + () => !!localStorage.getItem(DISMISSED_KEY), + ); - useEffect(() => { - if (window.matchMedia('(display-mode: standalone)').matches) { - setIsInstalled(true); - return; - } + useEffect(() => { + if (window.matchMedia("(display-mode: standalone)").matches) { + setIsInstalled(true); + return; + } - const handler = (e: Event) => { - e.preventDefault(); - setPromptEvent(e as BeforeInstallPromptEvent); + const handleBeforeInstallPrompt = (e: Event) => { + e.preventDefault(); + setPromptEvent(e as BeforeInstallPromptEvent); + }; + + const handleAppInstalled = () => { + setIsInstalled(true); + setPromptEvent(null); + }; + + window.addEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt, + ); + window.addEventListener("appinstalled", handleAppInstalled); + + return () => { + window.removeEventListener( + "beforeinstallprompt", + handleBeforeInstallPrompt, + ); + window.removeEventListener("appinstalled", handleAppInstalled); + }; + }, []); + + const install = async (): Promise => { + if (!promptEvent) return false; + + try { + await promptEvent.prompt(); + const { outcome } = await promptEvent.userChoice; + return outcome === "accepted"; + } finally { + setPromptEvent(null); + } }; - window.addEventListener('beforeinstallprompt', handler); - return () => window.removeEventListener('beforeinstallprompt', handler); - }, []); + const dismiss = () => { + localStorage.setItem(DISMISSED_KEY, "1"); + setIsDismissed(true); + }; - const install = async (): Promise => { - if (!promptEvent) return false; - await promptEvent.prompt(); - const { outcome } = await promptEvent.userChoice; - if (outcome === 'accepted') { - setPromptEvent(null); - return true; - } - return false; - }; - - const dismiss = () => { - localStorage.setItem(DISMISSED_KEY, '1'); - setIsDismissed(true); - }; - - return { promptEvent, isInstalled, isDismissed, install, dismiss }; + return { promptEvent, isInstalled, isDismissed, install, dismiss }; } diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index adb93532..5a9a0ef2 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -1,676 +1,1346 @@ -import React, { useState, useCallback, useRef } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { - MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon, - FolderOpenIcon, UsersIcon, UserPlusIcon, CheckCircleIcon, -} 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, PaginatedResponse } from '../lib/api'; -import type { PatientRecord, PatientSession, ServiceSection, ServiceItem } from '../types'; -import { formatDate, formatDateTime, formatRial, formatNumber } from '../lib/utils'; -import DataTable, { type Column } from '../components/ui/DataTable'; -import Pagination from '../components/ui/Pagination'; -import Modal from '../components/ui/Modal'; -import PageHeader from '../components/ui/PageHeader'; -import SearchableSelect from '../components/ui/SearchableSelect'; -import FeatureGate from '../components/ui/FeatureGate'; + CheckCircleIcon, + ChevronRightIcon, + FolderOpenIcon, + MagnifyingGlassIcon, + PencilIcon, + PhoneIcon, + PlusIcon, + UserPlusIcon, + UsersIcon, +} from "@heroicons/react/24/outline"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import React, { useCallback, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import DataTable, { type Column } from "../components/ui/DataTable"; +import FeatureGate from "../components/ui/FeatureGate"; +import Modal from "../components/ui/Modal"; +import PageHeader from "../components/ui/PageHeader"; +import Pagination from "../components/ui/Pagination"; +import SearchableSelect from "../components/ui/SearchableSelect"; +import type { ApiResponse, PaginatedResponse } from "../lib/api"; +import { api } from "../lib/api"; +import { + formatDate, + formatDateTime, + formatNumber, + formatRial, +} from "../lib/utils"; +import type { + PatientRecord, + PatientSession, + ServiceItem, + ServiceSection, +} from "../types"; const sessionSchema = z.object({ - visit_price_rials: z.coerce.number().min(0), - base_insurance_discount_percent: z.coerce.number().min(0).max(100), - supplementary_discount_percent: z.coerce.number().min(0).max(100), - payment_method: z.enum(['cash', 'card', 'insurance', 'online', 'pending']), - notes: z.string().optional(), + visit_price_rials: z.coerce.number().min(0), + base_insurance_discount_percent: z.coerce.number().min(0).max(100), + supplementary_discount_percent: z.coerce.number().min(0).max(100), + payment_method: z.enum(["cash", "card", "insurance", "online", "pending"]), + notes: z.string().optional(), }); type SessionFormData = z.infer; const PAYMENT_LABELS: Record = { - cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار', + cash: "نقدی", + card: "کارت", + insurance: "بیمه", + online: "آنلاین", + pending: "در انتظار", }; const PAYMENT_BADGE: Record = { - cash: 'green', card: 'blue', insurance: 'purple', online: 'blue', pending: 'amber', + cash: "green", + card: "blue", + insurance: "purple", + online: "blue", + pending: "amber", }; const EMPTY_RECORDS: PatientRecord[] = []; const EMPTY_SESSIONS: PatientSession[] = []; -function calcFinalPrice(visitPrice: number, baseDiscount: number, suppDiscount: number, servicesTotal: number) { - const afterBase = visitPrice * (1 - baseDiscount / 100); - const afterSupp = afterBase * (1 - suppDiscount / 100); - return Math.round(afterSupp) + servicesTotal; +function getPatientName(record?: PatientRecord | null) { + const user = record?.user; + return user?.fullName || user?.name || record?.user_name || "—"; +} + +function getPatientPhone(record?: PatientRecord | null) { + const user = record?.user; + return user?.phone || record?.user_mobile || "—"; +} + +function calcFinalPrice( + visitPrice: number, + baseDiscount: number, + suppDiscount: number, + servicesTotal: number, +) { + const afterBase = visitPrice * (1 - baseDiscount / 100); + const afterSupp = afterBase * (1 - suppDiscount / 100); + return Math.round(afterSupp) + servicesTotal; } function MyPatientsPageInner() { - const qc = useQueryClient(); - const [selectedRecord, setSelectedRecord] = useState(null); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [sessionPage, setSessionPage] = useState(1); - const [sessionModal, setSessionModal] = useState(false); - const [editSession, setEditSession] = useState(null); - - const form = useForm({ - resolver: zodResolver(sessionSchema), - defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' }, - }); - const watchVisit = form.watch('visit_price_rials') ?? 0; - const watchBase = form.watch('base_insurance_discount_percent') ?? 0; - const watchSupp = form.watch('supplementary_discount_percent') ?? 0; - - const [selectedServices, setSelectedServices] = useState<{ service_item_uuid: string; name: string; price_rials: number }[]>([]); - const [sectionUuid, setSectionUuid] = useState(''); - const [itemUuid, setItemUuid] = useState(''); - - const [createRecordOpen, setCreateRecordOpen] = useState(false); - const [searchMobile, setSearchMobile] = useState(''); - const [foundUser, setFoundUser] = useState<{ uuid: string; name: string | null; mobile: string } | null>(null); - const [searchError, setSearchError] = useState(''); - const mobileInputRef = useRef(null); - - const servicesTotal = selectedServices.reduce((sum, s) => sum + s.price_rials, 0); - const finalPrice = calcFinalPrice(Number(watchVisit), Number(watchBase), Number(watchSupp), servicesTotal); - - const limit = 20; - - const { data: recordsData, isLoading } = useQuery>({ - queryKey: ['patients', page, search], - queryFn: () => api.get(`/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`), - }); - - const { data: sessionsData, isLoading: sessionsLoading } = useQuery>({ - queryKey: ['patient-sessions', selectedRecord?.uuid, sessionPage], - queryFn: () => api.get(`/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`), - enabled: !!selectedRecord, - }); - - const { data: sectionsData } = useQuery>({ - queryKey: ['service-sections'], - queryFn: () => api.get('/api/v1/service-sections'), - }); - - const { data: itemsData } = useQuery>({ - queryKey: ['service-items-for-session', sectionUuid], - queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), - enabled: !!sectionUuid, - }); - - const records = recordsData?.data ?? EMPTY_RECORDS; - const sessions = sessionsData?.data ?? EMPTY_SESSIONS; - const totalRec = recordsData?.meta?.totalRecords ?? 0; - const totalSes = sessionsData?.meta?.totalRecords ?? 0; - - const sectionOptions = (sectionsData?.data ?? []).map((s) => ({ value: s.uuid, label: s.name })); - const itemOptions = (itemsData?.data ?? []).filter((i) => i.active).map((i) => ({ - value: i.uuid, - label: `${i.name} — ${formatRial(i.price_rials)}`, - })); - - const createSessionMut = useMutation({ - mutationFn: (body: object) => api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['patient-sessions', selectedRecord?.uuid] }); - setSessionModal(false); - form.reset(); - setSelectedServices([]); - toast.success('مراجعه ثبت شد'); - }, - onError: (e: any) => toast.error(e.message), - }); - - const updateSessionMut = useMutation({ - mutationFn: ({ uuid, body }: { uuid: string; body: object }) => api.patch(`/api/v1/session/${uuid}`, body), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['patient-sessions', selectedRecord?.uuid] }); - setEditSession(null); - toast.success('مراجعه ویرایش شد'); - }, - onError: (e: any) => toast.error(e.message), - }); - - const searchUserMut = useMutation({ - mutationFn: (mobile: string) => api.get(`/api/v1/patient/search-user?mobile=${encodeURIComponent(mobile)}`), - onSuccess: (res: any) => { setFoundUser(res?.data); setSearchError(''); }, - onError: () => { setFoundUser(null); setSearchError('کاربری با این شماره در سیستم یافت نشد'); }, - }); - - const createRecordMut = useMutation({ - mutationFn: (userUuid: string) => api.post('/api/v1/patient', { user_uuid: userUuid }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['patients'] }); - setCreateRecordOpen(false); - setSearchMobile(''); - setFoundUser(null); - setSearchError(''); - toast.success('پرونده بیمار ایجاد شد'); - }, - onError: (e: any) => toast.error(e.message), - }); - - const handleSearchMobile = () => { - const digits = searchMobile.replace(/\D/g, ''); - if (!/^09\d{9}$/.test(digits)) { setSearchError('شماره موبایل معتبر نیست'); return; } - setSearchError(''); - setFoundUser(null); - searchUserMut.mutate(digits); - }; - - const handleCreateRecordClose = () => { - setCreateRecordOpen(false); - setSearchMobile(''); - setFoundUser(null); - setSearchError(''); - }; - - const handleAddService = () => { - if (!itemUuid) return; - const found = itemsData?.data?.find((i) => i.uuid === itemUuid); - if (!found || selectedServices.some((s) => s.service_item_uuid === found.uuid)) return; - setSelectedServices((p) => [...p, { service_item_uuid: found.uuid, name: found.name, price_rials: found.price_rials }]); - setItemUuid(''); - }; - - const handleSubmitSession = form.handleSubmit((d) => { - createSessionMut.mutate({ ...d, services: selectedServices.map((s) => ({ service_item_uuid: s.service_item_uuid })) }); - }); - - const handleSearch = useCallback((v: string) => { setSearch(v); setPage(1); }, []); - - const recordColumns: Column[] = [ - { - key: 'user', - header: 'بیمار', - render: (r) => ( -
-
- {(r.user.fullName ?? '?').charAt(0)} -
-
-
{r.user.fullName ?? '—'}
-
- - {r.user.phone ?? '—'} -
-
-
- ), - }, - { - key: 'created_at', - header: 'تاریخ ثبت', - render: (r) => ( - {formatDate(r.created_at)} - ), - }, - { - key: 'uuid', - header: '', - render: (r) => ( - - ), - }, - ]; - - if (!selectedRecord) { - return ( - <> - setCreateRecordOpen(true)}> - - پرونده جدید - - } - /> - -
-
- - handleSearch(e.target.value)} - placeholder="جستجو بر اساس نام یا تلفن..." - /> -
-
- -
- {records.length === 0 && !isLoading ? ( -
- -
- {search ? 'بیماری یافت نشد' : 'هنوز بیماری ثبت نشده است'} -
-
- {search ? 'عبارت جستجو را تغییر دهید' : 'پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود'} -
-
- ) : ( - <> - -
- -
- - )} -
- - {/* Modal ایجاد پرونده دستی */} - - - - - } - > -
- -
- { - const digits = e.target.value.replace(/\D/g, ''); - setSearchMobile(digits); - setFoundUser(null); - setSearchError(''); - }} - onKeyDown={(e) => { if (e.key === 'Enter') handleSearchMobile(); }} - /> - -
- {searchError && {searchError}} -
- - {foundUser && ( -
- -
-
{foundUser.name ?? 'بدون نام'}
-
{foundUser.mobile}
-
-
- )} - -

- بیمار باید قبلاً در سیستم ثبت‌نام کرده باشد. با شماره موبایل جستجو کنید، سپس پرونده ایجاد کنید. -

-
- + const qc = useQueryClient(); + const [selectedRecord, setSelectedRecord] = useState( + null, ); - } + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [sessionPage, setSessionPage] = useState(1); + const [sessionModal, setSessionModal] = useState(false); + const [editSession, setEditSession] = useState(null); - return ( - <> - - - - + const form = useForm({ + resolver: zodResolver(sessionSchema), + defaultValues: { + visit_price_rials: 0, + base_insurance_discount_percent: 0, + supplementary_discount_percent: 0, + payment_method: "cash", + }, + }); + const watchVisit = form.watch("visit_price_rials") ?? 0; + const watchBase = form.watch("base_insurance_discount_percent") ?? 0; + const watchSupp = form.watch("supplementary_discount_percent") ?? 0; + + const [selectedServices, setSelectedServices] = useState< + { service_item_uuid: string; name: string; price_rials: number }[] + >([]); + const [sectionUuid, setSectionUuid] = useState(""); + const [itemUuid, setItemUuid] = useState(""); + + const [createRecordOpen, setCreateRecordOpen] = useState(false); + const [searchMobile, setSearchMobile] = useState(""); + const [foundUser, setFoundUser] = useState<{ + uuid: string; + name: string | null; + mobile: string; + } | null>(null); + const [searchError, setSearchError] = useState(""); + const mobileInputRef = useRef(null); + + const servicesTotal = selectedServices.reduce( + (sum, s) => sum + s.price_rials, + 0, + ); + const finalPrice = calcFinalPrice( + Number(watchVisit), + Number(watchBase), + Number(watchSupp), + servicesTotal, + ); + + const limit = 20; + + const { data: recordsData, isLoading } = useQuery< + PaginatedResponse + >({ + queryKey: ["patients", page, search], + queryFn: () => + api.get( + `/api/v1/patients?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`, + ), + }); + + const { data: sessionsData, isLoading: sessionsLoading } = useQuery< + PaginatedResponse + >({ + queryKey: ["patient-sessions", selectedRecord?.uuid, sessionPage], + queryFn: () => + api.get( + `/api/v1/patient/${selectedRecord!.uuid}/sessions?page=${sessionPage}&limit=20`, + ), + enabled: !!selectedRecord, + }); + + const { data: sectionsData } = useQuery>({ + queryKey: ["service-sections"], + queryFn: () => api.get("/api/v1/service-sections"), + }); + + const { data: itemsData } = useQuery>({ + queryKey: ["service-items-for-session", sectionUuid], + queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), + enabled: !!sectionUuid, + }); + + const records = recordsData?.data ?? EMPTY_RECORDS; + const sessions = sessionsData?.data ?? EMPTY_SESSIONS; + const totalRec = recordsData?.meta?.totalRecords ?? 0; + const totalSes = sessionsData?.meta?.totalRecords ?? 0; + const selectedPatientName = getPatientName(selectedRecord); + const selectedPatientPhone = getPatientPhone(selectedRecord); + + const sectionOptions = (sectionsData?.data ?? []).map((s) => ({ + value: s.uuid, + label: s.name, + })); + const itemOptions = (itemsData?.data ?? []) + .filter((i) => i.active) + .map((i) => ({ + value: i.uuid, + label: `${i.name} — ${formatRial(i.price_rials)}`, + })); + + const createSessionMut = useMutation({ + mutationFn: (body: object) => + api.post(`/api/v1/patient/${selectedRecord!.uuid}/session`, body), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ["patient-sessions", selectedRecord?.uuid], + }); + setSessionModal(false); + form.reset(); + setSelectedServices([]); + toast.success("مراجعه ثبت شد"); + }, + onError: (e: any) => toast.error(e.message), + }); + + const updateSessionMut = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: object }) => + api.patch(`/api/v1/session/${uuid}`, body), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ["patient-sessions", selectedRecord?.uuid], + }); + setEditSession(null); + toast.success("مراجعه ویرایش شد"); + }, + onError: (e: any) => toast.error(e.message), + }); + + const searchUserMut = useMutation({ + mutationFn: (mobile: string) => + api.get( + `/api/v1/patient/search-user?mobile=${encodeURIComponent(mobile)}`, + ), + onSuccess: (res: any) => { + setFoundUser(res?.data); + setSearchError(""); + }, + onError: () => { + setFoundUser(null); + setSearchError("کاربری با این شماره در سیستم یافت نشد"); + }, + }); + + const createRecordMut = useMutation({ + mutationFn: (userUuid: string) => + api.post("/api/v1/patient", { user_uuid: userUuid }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["patients"] }); + setCreateRecordOpen(false); + setSearchMobile(""); + setFoundUser(null); + setSearchError(""); + toast.success("پرونده بیمار ایجاد شد"); + }, + onError: (e: any) => toast.error(e.message), + }); + + const handleSearchMobile = () => { + const digits = searchMobile.replace(/\D/g, ""); + if (!/^09\d{9}$/.test(digits)) { + setSearchError("شماره موبایل معتبر نیست"); + return; } - /> + setSearchError(""); + setFoundUser(null); + searchUserMut.mutate(digits); + }; - {/* بنر اطلاعات بیمار */} -
-
- {(selectedRecord.user.fullName ?? '?').charAt(0)} -
-
-
{selectedRecord.user.fullName ?? '—'}
-
- - {selectedRecord.user.phone ?? '—'} -
-
-
-
{totalSes}
-
مراجعه
-
-
+ const handleCreateRecordClose = () => { + setCreateRecordOpen(false); + setSearchMobile(""); + setFoundUser(null); + setSearchError(""); + }; -
-
- تاریخچه مراجعات -
- {sessionsLoading ? ( -
در حال بارگذاری...
- ) : sessions.length === 0 ? ( -
- -
مراجعه‌ای ثبت نشده است
- -
- ) : ( - sessions.map((s) => ) - )} - {totalSes > 20 && ( -
- -
- )} -
+ const handleAddService = () => { + if (!itemUuid) return; + const found = itemsData?.data?.find((i) => i.uuid === itemUuid); + if ( + !found || + selectedServices.some((s) => s.service_item_uuid === found.uuid) + ) + return; + setSelectedServices((p) => [ + ...p, + { + service_item_uuid: found.uuid, + name: found.name, + price_rials: found.price_rials, + }, + ]); + setItemUuid(""); + }; - {/* Modal مراجعه جدید */} - setSessionModal(false)} title="ثبت مراجعه جدید"> -
-
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- -