diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index d7b74e4b..5af887e8 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import AdminLayout from './components/layout/AdminLayout'; import LoginPage from './pages/LoginPage'; @@ -33,6 +33,7 @@ import SettingsPage from './pages/SettingsPage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe } = useAuthStore(); + const location = useLocation(); useEffect(() => { if (isAuthenticated && !primaryRole) { @@ -47,8 +48,8 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { return
در حال بارگذاری...
; } - // اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو - if (availableContexts.length > 1 && !dbUuid) { + // اگر چند context دارد و هنوز انتخاب نشده و روی صفحه انتخاب نیستیم + if (availableContexts.length > 1 && !dbUuid && location.pathname !== '/admin/select-context') { return ; } @@ -126,9 +127,11 @@ export default function App() { {/* ادمین + کلینیک */} } /> - } /> - } /> - } /> + + {/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */} + } /> + } /> + } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 3007d09b..60704f58 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -73,7 +73,6 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti items: [ { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, { to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' }, - { to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, ], }, { diff --git a/assets/admin/components/ui/InviteDoctorModal.tsx b/assets/admin/components/ui/InviteDoctorModal.tsx new file mode 100644 index 00000000..6b92aa13 --- /dev/null +++ b/assets/admin/components/ui/InviteDoctorModal.tsx @@ -0,0 +1,73 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation } from '@tanstack/react-query'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; + +const inviteSchema = z.object({ + mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'), + name: z.string().optional(), + specialty: z.string().optional(), +}); +type InviteForm = z.infer; + +interface Props { + clinicUuid: string; + onClose: () => void; + onInvited?: () => void; +} + +export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Props) { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(inviteSchema), + }); + + const inviteMut = useMutation({ + mutationFn: (d: InviteForm) => + api.post>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d), + onSuccess: () => { + toast.success('دعوتنامه ارسال شد'); + onInvited?.(); + onClose(); + }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( +
+
e.stopPropagation()}> +
+ دعوت پزشک به کلینیک + +
+
inviteMut.mutate(d))}> +
+
+ + + {errors.mobile &&
{errors.mobile.message}
} +
+
+ + +
+
+ + +
+

پیامک دعوتنامه با لینک ۷۲ ساعته ارسال می‌شود

+
+
+ + +
+
+
+
+ ); +} diff --git a/assets/admin/components/ui/PersianDateInput.tsx b/assets/admin/components/ui/PersianDateInput.tsx new file mode 100644 index 00000000..10360233 --- /dev/null +++ b/assets/admin/components/ui/PersianDateInput.tsx @@ -0,0 +1,69 @@ +import React, { useRef } from 'react'; +import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { formatDate } from '../../lib/utils'; + +interface Props { + value: string; + onChange: (v: string) => void; + placeholder?: string; + min?: string; + max?: string; + style?: React.CSSProperties; + className?: string; +} + +export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', min, max, style, className }: Props) { + const hiddenRef = useRef(null); + + const open = () => { + const el = hiddenRef.current; + if (!el) return; + if (typeof el.showPicker === 'function') { + try { el.showPicker(); } catch { el.focus(); } + } else { + el.focus(); + } + }; + + return ( +
+ {/* visible text layer */} +
+ + {value ? formatDate(value) : placeholder} + {value && ( + { e.stopPropagation(); onChange(''); }} + style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }} + > + + + )} +
+ + {/* hidden native input — opens picker on click */} + onChange(e.target.value)} + style={{ + position: 'absolute', opacity: 0, pointerEvents: 'none', + width: 1, height: 1, top: 0, left: 0, + }} + tabIndex={-1} + /> +
+ ); +} diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 83792a6e..e683b11a 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -6,32 +6,36 @@ export function formatNumber(n: number): string { return new Intl.NumberFormat('fa-IR').format(n); } -export function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return '—'; - try { - return new Intl.DateTimeFormat('fa-IR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).format(new Date(dateStr)); - } catch { - return dateStr; - } +export function toDate(val: string | number | null | undefined): Date | null { + if (val == null || val === '') return null; + if (typeof val === 'number') return new Date(val * 1000); + // Y-m-d → treat as local noon to avoid UTC-off-by-one + if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return new Date(`${val}T12:00:00`); + return new Date(val); } -export function formatDateTime(dateStr: string | null | undefined): string { - if (!dateStr) return '—'; - try { - return new Intl.DateTimeFormat('fa-IR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(dateStr)); - } catch { - return dateStr; - } +export function formatDate(val: string | number | null | undefined): string { + const d = toDate(val); + if (!d || isNaN(d.getTime())) return '—'; + return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + year: 'numeric', month: '2-digit', day: '2-digit', + }).format(d); +} + +export function formatDateTime(val: string | number | null | undefined): string { + const d = toDate(val); + if (!d || isNaN(d.getTime())) return '—'; + return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }).format(d); +} + +export function toGregorianDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; } export function maskMobile(mobile: string): string { diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 1a78d1bf..5aa8f0ae 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -1,46 +1,61 @@ -import React, { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import React, { useState, useMemo } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon, - CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, FunnelIcon, + CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, + PlusIcon, XMarkIcon, ChevronRightIcon, ChevronLeftIcon, UserCircleIcon, } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; import { api } from '../lib/api'; -import type { PaginatedResponse } from '../lib/api'; +import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, formatRial, maskMobile } from '../lib/utils'; +import { formatDate, maskMobile, toGregorianDate } from '../lib/utils'; import DataTable, { Column } from '../components/ui/DataTable'; -import StatusBadge from '../components/ui/StatusBadge'; import Pagination from '../components/ui/Pagination'; +import PersianDateInput from '../components/ui/PersianDateInput'; import { useAuthStore } from '../stores/authStore'; -// ── Status helpers ──────────────────────────────────────────────────────── +// ── Status config ────────────────────────────────────────────────────────── + +const STATUS_META: Record = { + waiting_for_payment: { label: 'انتظار پرداخت', cls: 'status-amber' }, + reserved: { label: 'رزرو شده', cls: 'status-blue' }, + checked_in: { label: 'ورود به مطب', cls: 'status-violet' }, + waiting: { label: 'صف انتظار', cls: 'status-amber' }, + in_progress: { label: 'در حال ویزیت', cls: 'status-violet' }, + visited: { label: 'ویزیت شده', cls: 'status-green' }, + completed: { label: 'تکمیل شده', cls: 'status-green' }, + cancelled_by_user: { label: 'لغو توسط بیمار', cls: 'status-red' }, + cancelled_by_doctor: { label: 'لغو توسط پزشک', cls: 'status-red' }, + cancelled_by_admin: { label: 'لغو توسط ادمین', cls: 'status-red' }, + auto_cancel_unpaid: { label: 'لغو خودکار', cls: 'status-gray' }, + no_show: { label: 'غیبت', cls: 'status-gray' }, +}; const STATUS_FILTERS = [ - { value: '', label: 'همه' }, + { value: '', label: 'همه وضعیت‌ها' }, + { value: 'reserved', label: 'رزرو شده' }, { value: 'waiting_for_payment', label: 'در انتظار پرداخت' }, - { value: 'reserved', label: 'رزرو شده' }, - { value: 'checked_in', label: 'ورود به مطب' }, - { value: 'waiting', label: 'صف انتظار' }, - { value: 'in_progress', label: 'در حال ویزیت' }, - { value: 'visited', label: 'ویزیت شده' }, - { value: 'completed', label: 'تکمیل شده' }, + { value: 'checked_in', label: 'ورود به مطب' }, + { value: 'waiting', label: 'صف انتظار' }, + { value: 'in_progress', label: 'در حال ویزیت' }, + { value: 'visited', label: 'ویزیت شده' }, + { value: 'completed', label: 'تکمیل شده' }, { value: 'cancelled_by_doctor', label: 'لغو پزشک' }, - { value: 'cancelled_by_user', label: 'لغو بیمار' }, - { value: 'no_show', label: 'غیبت' }, + { value: 'cancelled_by_user', label: 'لغو بیمار' }, + { value: 'no_show', label: 'غیبت' }, ]; -const APPT_CLS: Record = { - waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet', - waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green', - cancelled_by_doctor: 'red', cancelled_by_user: 'red', auto_cancel_unpaid: 'gray', no_show: 'gray', -}; -const APPT_LABEL: Record = { - waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب', - waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده', - completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار', - auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت', -}; +function ApptStatus({ status }: { status: string }) { + const m = STATUS_META[status] ?? { label: status, cls: 'status-gray' }; + return ( + + + {m.label} + + ); +} // ── Timeline View ───────────────────────────────────────────────────────── @@ -48,18 +63,68 @@ interface TimelineProps { items: Appointment[]; loading: boolean; onView: (uuid: string) => void; + groupByDoctor?: boolean; } -function TimelineView({ items, loading, onView }: TimelineProps) { +function DayGroup({ date, appts, onView }: { date: string; appts: Appointment[]; onView: (u: string) => void }) { + return ( +
+
+ + {formatDate(date)} + +
+
+ {appts.map((a) => ( +
onView(a.uuid)} + onMouseEnter={e => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,.07)')} + onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')} + > +
+ {a.appointment_time} +
+
+
+ {a.patient_name || maskMobile(a.patient_mobile)} +
+
+ دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''} +
+
+ + +
+ ))} +
+
+ ); +} + +function TimelineView({ items, loading, onView, groupByDoctor }: TimelineProps) { if (loading) { return (
- {Array.from({ length: 6 }).map((_, i) => ( -
-
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
-
-
+
+
))} @@ -71,97 +136,339 @@ function TimelineView({ items, loading, onView }: TimelineProps) { return

هیچ نوبتی یافت نشد

; } - // گروه‌بندی بر اساس تاریخ - const grouped = items.reduce>((acc, a) => { - const key = a.appointment_date; - if (!acc[key]) acc[key] = []; - acc[key].push(a); + if (groupByDoctor) { + // group by doctor → date + const byDoctor = items.reduce>((acc, a) => { + (acc[a.doctor_name] ??= []).push(a); + return acc; + }, {}); + + return ( +
+ {Object.entries(byDoctor).map(([docName, docAppts]) => { + const byDate = docAppts.reduce>((acc, a) => { + (acc[a.appointment_date] ??= []).push(a); + return acc; + }, {}); + return ( +
+
+ + دکتر {docName} + {docAppts.length} نوبت +
+ {Object.entries(byDate).map(([date, appts]) => ( + + ))} +
+ ); + })} +
+ ); + } + + // default: group by date only + const byDate = items.reduce>((acc, a) => { + (acc[a.appointment_date] ??= []).push(a); return acc; }, {}); return (
- {Object.entries(grouped).map(([date, appts]) => ( -
-
- - {new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} - + {Object.entries(byDate).map(([date, appts]) => ( + + ))} +
+ ); +} + +// ── Doctor-grouped table ────────────────────────────────────────────────── + +interface DoctorGroupedTableProps { + items: Appointment[]; + loading: boolean; + onView: (uuid: string) => void; + columns: Column[]; +} + +function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) { + if (loading) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ); + } + if (!items.length) { + return

هیچ نوبتی یافت نشد

; + } + + const byDoctor = items.reduce>((acc, a) => { + (acc[a.doctor_name] ??= []).push(a); + return acc; + }, {}); + + return ( +
+ {Object.entries(byDoctor).map(([docName, docAppts]) => ( +
+ {/* Doctor header row */} +
+ + دکتر {docName} + {docAppts.length} نوبت
-
- {appts.map((a) => ( -
onView(a.uuid)} - onMouseEnter={e => (e.currentTarget.style.boxShadow = 'var(--shadow-sm, 0 2px 8px rgba(0,0,0,.08))')} - onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')} - > - {/* ساعت */} -
- {a.appointment_time} -
- {/* اطلاعات */} -
-
- {a.patient_name || maskMobile(a.patient_mobile)} -
-
- دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''} -
-
- - {/* وضعیت */} - - {APPT_LABEL[a.status] ?? a.status} - - - -
- ))} -
+ {/* Appointments table for this doctor */} + + columns={columns} + data={docAppts} + loading={false} + emptyMessage="" + actions={(appt) => ( + + )} + />
))}
); } +// ── New Appointment Modal ───────────────────────────────────────────────── + +interface NewApptModalProps { + onClose: () => void; + onCreated: () => void; + defaultDoctorUuid?: string; +} + +function NewAppointmentModal({ onClose, onCreated, defaultDoctorUuid }: NewApptModalProps) { + const { primaryRole } = useAuthStore(); + const isAdmin = primaryRole === 'admin'; + + const [step, setStep] = useState<1 | 2 | 3>(1); + const [doctorUuid, setDoctorUuid] = useState(defaultDoctorUuid ?? ''); + const [patientMobile, setPatientMobile] = useState(''); + const [dateStr, setDateStr] = useState(toGregorianDate(new Date())); + const [slots, setSlots] = useState>([]); + const [selectedSlot, setSelectedSlot] = useState<{ start: number; end: number; label: string } | null>(null); + const [note, setNote] = useState(''); + const [loadingSlots, setLoadingSlots] = useState(false); + + const fetchSlots = async () => { + if (!doctorUuid.trim() || !dateStr) { toast.error('UUID پزشک و تاریخ را وارد کنید'); return; } + setLoadingSlots(true); + try { + const res = await api.get }>>( + `/api/v1/appointment-slots?doctor_uuid=${doctorUuid.trim()}&date=${dateStr}` + ); + const raw = (res as any)?.data?.slots ?? []; + setSlots(raw); + setSelectedSlot(null); + setStep(2); + if (!raw.length) toast.info('هیچ نوبت خالی در این تاریخ وجود ندارد'); + } catch (e: any) { + toast.error(e.message ?? 'خطا در دریافت نوبت‌ها'); + } finally { + setLoadingSlots(false); + } + }; + + const createMut = useMutation({ + mutationFn: () => { + if (!selectedSlot) throw new Error('نوبت را انتخاب کنید'); + const body: Record = { + doctor_uuid: doctorUuid.trim(), + slot_start: selectedSlot.start, + slot_end: selectedSlot.end, + note: note || undefined, + }; + if (isAdmin) { + body.patient_mobile = patientMobile.trim(); + return api.post('/api/v1/admin/appointment', body); + } + return api.post('/api/v1/appointment', body); + }, + onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onCreated(); onClose(); }, + onError: (e: Error) => toast.error(e.message), + }); + + const handleDateChange = (v: string) => { + setDateStr(v); setSlots([]); setSelectedSlot(null); setStep(1); + }; + + const changeDate = (delta: number) => { + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() + delta); + handleDateChange(toGregorianDate(d)); + }; + + return ( +
+
e.stopPropagation()}> +
+ ثبت نوبت جدید + +
+ +
+
+
+ + { setDoctorUuid(e.target.value); setStep(1); setSlots([]); setSelectedSlot(null); }} + /> +
+
+ +
+ + + +
+
+
+ + {isAdmin && ( +
+ + setPatientMobile(e.target.value)} /> +
+ )} + + + + {step >= 2 && slots.length > 0 && ( +
+ +
+ {slots.map((s) => ( + + ))} +
+
+ )} + + {step >= 2 && slots.length === 0 && ( +

نوبت خالی در این تاریخ وجود ندارد

+ )} + + {step >= 3 && ( +
+ +