feat: add doctor invitation modal and appointment creation API

- Implemented InviteDoctorModal component for inviting doctors to clinics.
- Updated ClinicDashboard to include a button for inviting doctors and handle modal state.
- Added createAppointment API endpoint in AdminApiController for scheduling appointments.
- Enhanced ClinicInvitationController to check user access when inviting doctors.
- Updated MyAppointmentsController to ensure unique appointment records.
- Added seed_test_data.php for populating test data including doctors, clinics, and appointments.
- Refactored styles to include new appointment status badges and updated font imports.
This commit is contained in:
hamed
2026-06-11 13:36:54 +03:30
parent 04b51273e3
commit 82e1c264a1
18 changed files with 1095 additions and 274 deletions
+9 -6
View File
@@ -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 <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
}
// اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو
if (availableContexts.length > 1 && !dbUuid) {
// اگر چند context دارد و هنوز انتخاب نشده و روی صفحه انتخاب نیستیم
if (availableContexts.length > 1 && !dbUuid && location.pathname !== '/admin/select-context') {
return <Navigate to="/admin/select-context" replace />;
}
@@ -126,9 +127,11 @@ export default function App() {
{/* ادمین + کلینیک */}
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
<Route path="doctors" element={<RoleRoute roles={['admin', 'clinic']}><DoctorsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'clinic']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */}
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
@@ -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: 'پزشکان' },
],
},
{
@@ -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<typeof inviteSchema>;
interface Props {
clinicUuid: string;
onClose: () => void;
onInvited?: () => void;
}
export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Props) {
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
resolver: zodResolver(inviteSchema),
});
const inviteMut = useMutation({
mutationFn: (d: InviteForm) =>
api.post<ApiResponse<unknown>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
onSuccess: () => {
toast.success('دعوتنامه ارسال شد');
onInvited?.();
onClose();
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
</div>
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
</div>
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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<HTMLInputElement>(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 (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', ...style }} className={className}>
{/* visible text layer */}
<div
onClick={open}
style={{
display: 'flex', alignItems: 'center', gap: 7,
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface)',
cursor: 'pointer', fontSize: 13, color: value ? 'var(--text)' : 'var(--text-3)',
userSelect: 'none', minWidth: 148, whiteSpace: 'nowrap',
}}
>
<CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
{value && (
<span
onClick={e => { e.stopPropagation(); onChange(''); }}
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }}
>
<XMarkIcon style={{ width: 13, height: 13 }} />
</span>
)}
</div>
{/* hidden native input — opens picker on click */}
<input
ref={hiddenRef}
type="date"
value={value}
min={min}
max={max}
onChange={e => onChange(e.target.value)}
style={{
position: 'absolute', opacity: 0, pointerEvents: 'none',
width: 1, height: 1, top: 0, left: 0,
}}
tabIndex={-1}
/>
</div>
);
}
+28 -24
View File
@@ -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 {
+484 -152
View File
@@ -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<string, { label: string; cls: string }> = {
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<string, string> = {
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<string, string> = {
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 (
<span className={`appt-status ${m.cls}`}>
<span className="appt-status-dot" />
{m.label}
</span>
);
}
// ── 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 (
<div style={{ marginBottom: '1.25rem' }}>
<div style={{
fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.6rem',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
{formatDate(date)}
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{appts.map((a) => (
<div
key={a.uuid}
style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '0.7rem 1rem',
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
}}
onClick={() => onView(a.uuid)}
onMouseEnter={e => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,.07)')}
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
>
<div style={{
width: 50, height: 50, borderRadius: 10, flexShrink: 0,
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: 14, lineHeight: 1.2,
}}>
{a.appointment_time}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{a.patient_name || maskMobile(a.patient_mobile)}
</div>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
</div>
</div>
<ApptStatus status={a.status} />
<EyeIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
</div>
))}
</div>
</div>
);
}
function TimelineView({ items, loading, onView, groupByDoctor }: TimelineProps) {
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '1rem' }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div className="skeleton" style={{ width: 48, height: 48, borderRadius: 10, flexShrink: 0 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<div className="skeleton" style={{ width: 50, height: 50, borderRadius: 10, flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<div className="skeleton" style={{ height: 14, borderRadius: 5, width: '55%', marginBottom: 6 }} />
<div className="skeleton" style={{ height: 12, borderRadius: 5, width: '35%' }} />
<div className="skeleton" style={{ height: 13, borderRadius: 4, width: '50%', marginBottom: 7 }} />
<div className="skeleton" style={{ height: 11, borderRadius: 4, width: '30%' }} />
</div>
</div>
))}
@@ -71,97 +136,339 @@ function TimelineView({ items, loading, onView }: TimelineProps) {
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
}
// گروه‌بندی بر اساس تاریخ
const grouped = items.reduce<Record<string, Appointment[]>>((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<Record<string, Appointment[]>>((acc, a) => {
(acc[a.doctor_name] ??= []).push(a);
return acc;
}, {});
return (
<div style={{ padding: '0 1rem 1rem' }}>
{Object.entries(byDoctor).map(([docName, docAppts]) => {
const byDate = docAppts.reduce<Record<string, Appointment[]>>((acc, a) => {
(acc[a.appointment_date] ??= []).push(a);
return acc;
}, {});
return (
<div key={docName} style={{ marginBottom: '1.5rem' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: '0.75rem',
padding: '6px 10px', borderRadius: 8,
background: 'var(--primary-soft, #eef2ff)', border: '1px solid color-mix(in srgb, var(--primary) 20%, transparent)',
}}>
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
<span className="muted" style={{ fontSize: 12, marginRight: 'auto' }}>{docAppts.length} نوبت</span>
</div>
{Object.entries(byDate).map(([date, appts]) => (
<DayGroup key={date} date={date} appts={appts} onView={onView} />
))}
</div>
);
})}
</div>
);
}
// default: group by date only
const byDate = items.reduce<Record<string, Appointment[]>>((acc, a) => {
(acc[a.appointment_date] ??= []).push(a);
return acc;
}, {});
return (
<div style={{ padding: '0 1rem 1rem' }}>
{Object.entries(grouped).map(([date, appts]) => (
<div key={date} style={{ marginBottom: '1.5rem' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
{new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
{Object.entries(byDate).map(([date, appts]) => (
<DayGroup key={date} date={date} appts={appts} onView={onView} />
))}
</div>
);
}
// ── Doctor-grouped table ──────────────────────────────────────────────────
interface DoctorGroupedTableProps {
items: Appointment[];
loading: boolean;
onView: (uuid: string) => void;
columns: Column<Appointment>[];
}
function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) {
if (loading) {
return (
<div style={{ padding: '1rem' }}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton" style={{ height: 44, borderRadius: 6, marginBottom: 8 }} />
))}
</div>
);
}
if (!items.length) {
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
}
const byDoctor = items.reduce<Record<string, Appointment[]>>((acc, a) => {
(acc[a.doctor_name] ??= []).push(a);
return acc;
}, {});
return (
<div>
{Object.entries(byDoctor).map(([docName, docAppts]) => (
<div key={docName}>
{/* Doctor header row */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', margin: '0',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderBottom: '1px solid var(--border)', borderTop: '1px solid var(--border)',
}}>
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
<span className="muted" style={{ fontSize: 12 }}>{docAppts.length} نوبت</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{appts.map((a) => (
<div
key={a.uuid}
style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '0.75rem 1rem',
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
}}
onClick={() => 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')}
>
{/* ساعت */}
<div style={{
width: 52, height: 52, borderRadius: 10, flexShrink: 0,
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: 15, lineHeight: 1.2,
}}>
{a.appointment_time}
</div>
{/* اطلاعات */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{a.patient_name || maskMobile(a.patient_mobile)}
</div>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
</div>
</div>
{/* وضعیت */}
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
</span>
<EyeIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
</div>
))}
</div>
{/* Appointments table for this doctor */}
<DataTable<Appointment>
columns={columns}
data={docAppts}
loading={false}
emptyMessage=""
actions={(appt) => (
<button className="mini-btn" onClick={() => onView(appt.uuid)} title="مشاهده">
<EyeIcon style={{ width: 16, height: 16 }} />
</button>
)}
/>
</div>
))}
</div>
);
}
// ── 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<Array<{ start: number; end: number; label: string }>>([]);
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<ApiResponse<{ slots: Array<{ start: number; end: number; label: string }> }>>(
`/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<string, unknown> = {
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 (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 500 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>ثبت نوبت جدید</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="field-label">UUID پزشک *</label>
<input
className="input" dir="ltr" placeholder="xxxxxxxx-xxxx-..."
value={doctorUuid}
readOnly={!!defaultDoctorUuid}
onChange={e => { setDoctorUuid(e.target.value); setStep(1); setSlots([]); setSelectedSlot(null); }}
/>
</div>
<div>
<label className="field-label">تاریخ *</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button className="mini-btn" onClick={() => changeDate(-1)} title="روز قبل">
<ChevronRightIcon style={{ width: 14, height: 14 }} />
</button>
<PersianDateInput
value={dateStr}
onChange={handleDateChange}
min={toGregorianDate(new Date())}
style={{ flex: 1 }}
/>
<button className="mini-btn" onClick={() => changeDate(1)} title="روز بعد">
<ChevronLeftIcon style={{ width: 14, height: 14 }} />
</button>
</div>
</div>
</div>
{isAdmin && (
<div>
<label className="field-label">موبایل بیمار *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" value={patientMobile} onChange={e => setPatientMobile(e.target.value)} />
</div>
)}
<button
className="btn outline sm"
onClick={fetchSlots}
disabled={loadingSlots || !doctorUuid.trim() || !dateStr}
style={{ alignSelf: 'flex-start' }}
>
{loadingSlots ? 'در حال دریافت...' : 'دریافت نوبت‌های خالی'}
</button>
{step >= 2 && slots.length > 0 && (
<div>
<label className="field-label">انتخاب نوبت {formatDate(dateStr)}</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginTop: 6 }}>
{slots.map((s) => (
<button
key={s.start}
onClick={() => { setSelectedSlot(s); setStep(3); }}
style={{
padding: '8px 4px', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer',
border: selectedSlot?.start === s.start ? '2px solid var(--primary)' : '1px solid var(--border)',
background: selectedSlot?.start === s.start ? 'var(--primary-soft, #eef2ff)' : 'var(--surface)',
color: selectedSlot?.start === s.start ? 'var(--primary)' : 'var(--text)',
transition: 'all .15s',
}}
>
{s.label}
</button>
))}
</div>
</div>
)}
{step >= 2 && slots.length === 0 && (
<p className="muted" style={{ textAlign: 'center', fontSize: 13 }}>نوبت خالی در این تاریخ وجود ندارد</p>
)}
{step >= 3 && (
<div>
<label className="field-label">یادداشت (اختیاری)</label>
<textarea className="input" rows={2} style={{ resize: 'vertical' }} value={note} onChange={e => setNote(e.target.value)} />
</div>
)}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
<button
className="btn primary sm"
disabled={!selectedSlot || createMut.isPending || (isAdmin && !patientMobile.trim())}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? 'در حال ثبت...' : 'ثبت نوبت'}
</button>
</div>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────
export default function AppointmentsPage() {
const navigate = useNavigate();
const primaryRole = useAuthStore(s => s.primaryRole);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const navigate = useNavigate();
const { primaryRole, dbUuid } = useAuthStore();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFilter, setDateFilter] = useState('');
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
const [dateFilter, setDateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState('');
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
const [newApptOpen, setNewApptOpen] = useState(false);
const limit = 15;
const isAdmin = primaryRole === 'admin';
const isAdmin = primaryRole === 'admin';
const isDoctor = primaryRole === 'doctor';
const isClinic = primaryRole === 'clinic';
const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
const { data, isLoading } = useQuery({
const { data, isLoading, refetch } = useQuery({
queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
if (search) params.set('search', search);
if (statusFilter) params.set('status', statusFilter);
if (dateFilter) params.set('date', dateFilter);
if (dateFilter) params.set('date', dateFilter);
return api.get<PaginatedResponse<Appointment>>(`${endpoint}?${params}`);
},
});
const allItems = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
// unique doctor names for clinic filter tab
const doctorNames = useMemo(() => {
const names = [...new Set(allItems.map(a => a.doctor_name))];
return names.sort();
}, [allItems]);
const items = useMemo(() => {
if (!doctorFilter) return allItems;
return allItems.filter(a => a.doctor_name === doctorFilter);
}, [allItems, doctorFilter]);
// Show doctor grouping in clinic panel when multiple doctors exist
const showDoctorGroup = isClinic && doctorNames.length > 1 && !doctorFilter;
const columns: Column<Appointment>[] = [
{
key: 'patient',
@@ -169,7 +476,7 @@ export default function AppointmentsPage() {
render: (a) => (
<div className="cell-user">
<div className="avatar sm" style={{
background: `linear-gradient(145deg, oklch(0.62 0.15 222), oklch(0.48 0.16 222))`,
background: 'linear-gradient(145deg, oklch(0.62 0.15 222), oklch(0.48 0.16 222))',
}}>
{(a.patient_name ?? '؟').slice(0, 2)}
</div>
@@ -180,41 +487,41 @@ export default function AppointmentsPage() {
</div>
),
},
{ key: 'doctor_name', header: 'پزشک', render: (a) => `دکتر ${a.doctor_name}` },
...(!showDoctorGroup ? [{ key: 'doctor_name' as keyof Appointment, header: 'پزشک', render: (a: Appointment) => `دکتر ${a.doctor_name}` }] : []),
{ key: 'clinic_name', header: 'کلینیک', render: (a) => <span className="muted">{a.clinic_name ?? '—'}</span> },
{
key: 'appointment_date',
header: 'تاریخ نوبت',
header: 'تاریخ و ساعت',
render: (a) => (
<div>
<span>{formatDate(a.appointment_date)}</span>
<br /><small className="muted">{a.appointment_time}</small>
<span style={{ fontWeight: 600 }}>{formatDate(a.appointment_date)}</span>
<br />
<small className="muted" style={{ direction: 'ltr', display: 'inline-block' }}>{a.appointment_time}</small>
</div>
),
},
{
key: 'status',
header: 'وضعیت',
render: (a) => <StatusBadge type="appointment" value={a.status} />,
render: (a) => <ApptStatus status={a.status} />,
},
{
key: 'amount',
header: 'مبلغ',
render: (a) => <><b>{formatRial(a.amount)}</b> <span className="muted" style={{ fontSize: 11 }}>تومان</span></>,
key: 'created_at',
header: 'ثبت در',
render: (a) => <span className="muted" style={{ fontSize: 12 }}>{formatDate(a.created_at)}</span>,
},
{ key: 'created_at', header: 'تاریخ ثبت', render: (a) => <span className="muted">{formatDate(a.created_at)}</span> },
];
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const pageTitle = isAdmin ? 'نوبت‌ها' : (primaryRole === 'doctor' ? 'نوبت‌های من' : primaryRole === 'secretary' ? 'نوبت‌های پزشک' : 'نوبت‌های کلینیک');
const pageTitle = isAdmin ? 'نوبت‌ها'
: isDoctor ? 'نوبت‌های من'
: primaryRole === 'secretary' ? 'نوبت‌های پزشک'
: 'نوبت‌های کلینیک';
const statCards = [
{ label: 'کل نوبت‌ها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
];
return (
@@ -225,8 +532,8 @@ export default function AppointmentsPage() {
<h1 className="section-title">{pageTitle}</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
</div>
<button className="btn primary sm">
<CalendarIcon style={{ width: 15, height: 15 }} />
<button className="btn primary sm" onClick={() => setNewApptOpen(true)}>
<PlusIcon style={{ width: 15, height: 15 }} />
ثبت نوبت جدید
</button>
</div>
@@ -251,8 +558,34 @@ export default function AppointmentsPage() {
{/* Main card */}
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
{/* Doctor filter tabs — only for clinic with multiple doctors */}
{isClinic && doctorNames.length > 1 && (
<div style={{
display: 'flex', gap: 6, flexWrap: 'wrap',
marginBottom: 14, paddingBottom: 14, borderBottom: '1px solid var(--border)',
}}>
<button
onClick={() => setDoctorFilter('')}
className={`btn sm ${!doctorFilter ? 'primary' : 'ghost'}`}
style={{ fontSize: 12 }}
>
همه پزشکان
</button>
{doctorNames.map(name => (
<button
key={name}
onClick={() => setDoctorFilter(name)}
className={`btn sm ${doctorFilter === name ? 'primary' : 'ghost'}`}
style={{ fontSize: 12 }}
>
دکتر {name}
</button>
))}
</div>
)}
<div className="toolbar" style={{ flexWrap: 'wrap', gap: 10 }}>
{/* جستجو */}
<div className="field" style={{ minWidth: 220 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
@@ -262,18 +595,12 @@ export default function AppointmentsPage() {
/>
</div>
{/* فیلتر تاریخ */}
<div className="field" style={{ minWidth: 160 }}>
<FunnelIcon style={{ width: 15, height: 15 }} />
<input
type="date"
value={dateFilter}
onChange={(e) => { setDateFilter(e.target.value); setPage(1); }}
style={{ direction: 'ltr' }}
/>
</div>
<PersianDateInput
value={dateFilter}
onChange={(v) => { setDateFilter(v); setPage(1); }}
placeholder="فیلتر تاریخ"
/>
{/* فیلتر وضعیت */}
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
@@ -289,20 +616,11 @@ export default function AppointmentsPage() {
<div style={{ marginRight: 'auto' }} />
{/* تغییر نما */}
<div className="seg">
<button
className={viewMode === 'table' ? 'on' : ''}
onClick={() => setViewMode('table')}
title="نمای جدول"
>
<button className={viewMode === 'table' ? 'on' : ''} onClick={() => setViewMode('table')} title="نمای جدول">
<TableCellsIcon style={{ width: 15, height: 15 }} />
</button>
<button
className={viewMode === 'timeline' ? 'on' : ''}
onClick={() => setViewMode('timeline')}
title="نمای زمانی"
>
<button className={viewMode === 'timeline' ? 'on' : ''} onClick={() => setViewMode('timeline')} title="نمای زمانی">
<CalendarViewIcon style={{ width: 15, height: 15 }} />
</button>
</div>
@@ -311,21 +629,26 @@ export default function AppointmentsPage() {
{viewMode === 'table' ? (
<>
<DataTable<Appointment>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ نوبتی یافت نشد"
actions={(appt) => (
<button
className="mini-btn"
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
title="مشاهده"
>
<EyeIcon style={{ width: 16, height: 16 }} />
</button>
)}
/>
{showDoctorGroup ? (
<DoctorGroupedTable
items={items}
loading={isLoading}
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
columns={columns}
/>
) : (
<DataTable<Appointment>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ نوبتی یافت نشد"
actions={(appt) => (
<button className="mini-btn" onClick={() => navigate(`/admin/appointments/${appt.uuid}`)} title="مشاهده">
<EyeIcon style={{ width: 16, height: 16 }} />
</button>
)}
/>
)}
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</>
) : (
@@ -334,11 +657,20 @@ export default function AppointmentsPage() {
items={items}
loading={isLoading}
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
groupByDoctor={showDoctorGroup}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</>
)}
</div>
{newApptOpen && (
<NewAppointmentModal
onClose={() => setNewApptOpen(false)}
onCreated={refetch}
defaultDoctorUuid={isDoctor ? (dbUuid ?? '') : ''}
/>
)}
</div>
);
}
+13 -66
View File
@@ -21,6 +21,7 @@ import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import { useAuthStore } from '../stores/authStore';
// Fix leaflet icons
@@ -505,64 +506,6 @@ const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
removed: { label: 'حذف‌شده', cls: 'gray' },
};
// ── Invite modal ───────────────────────────────────────────────────────────
const inviteSchema = z.object({
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'),
name: z.string().optional(),
specialty: z.string().optional(),
});
type InviteForm = z.infer<typeof inviteSchema>;
function InviteModal({ clinicUuid, onClose, onInvited }: {
clinicUuid: string; onClose: () => void; onInvited: () => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
resolver: zodResolver(inviteSchema),
});
const inviteMut = useMutation({
mutationFn: (d: InviteForm) =>
api.post<ApiResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>دعوت پزشک به کلینیک</b>
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
</div>
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
</div>
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
@@ -748,13 +691,17 @@ export default function ClinicDetailPage() {
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
{primaryRole === 'admin' && (
<>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
</div>
@@ -1054,7 +1001,7 @@ export default function ClinicDetailPage() {
{/* Invite doctor modal */}
{inviteOpen && uuid && createPortal(
<InviteModal
<InviteDoctorModal
clinicUuid={uuid}
onClose={() => setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
+39 -11
View File
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
// ── Shared Status Maps ────────────────────────────────────────────────────
@@ -514,7 +515,8 @@ interface ClinicDashboardData {
}
function ClinicDashboard() {
const { context } = useAuthStore();
const { context, dbUuid } = useAuthStore();
const [inviteOpen, setInviteOpen] = useState(false);
const q = useQuery({
queryKey: ['dashboard-clinic'],
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>('/api/v1/dashboard/clinic'),
@@ -524,6 +526,7 @@ function ClinicDashboard() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = useMemo<ClinicDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
const clinicUuid = d?.clinic.uuid ?? dbUuid ?? '';
if (q.isLoading) return <LoadingSkeleton />;
@@ -541,10 +544,16 @@ function ClinicDashboard() {
<h1 className="section-title">داشبورد کلینیک</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {d?.clinic.name ?? context?.name ?? ''}</div>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<UserIcon style={{ width: 14, height: 14 }} />
دعوت پزشک
</button>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
</div>
<div className="stat-grid">
@@ -571,29 +580,48 @@ function ClinicDashboard() {
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
<Link to="/admin/doctors" className="link">همه</Link>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn ghost sm" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => setInviteOpen(true)}>
+ دعوت جدید
</button>
{clinicUuid && (
<Link to={`/admin/clinics/${clinicUuid}`} className="link">مدیریت</Link>
)}
</div>
</div>
{!d?.doctors.length ? (
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>پزشکی ثبت نشده</p>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<p className="muted" style={{ fontSize: 13.5, marginBottom: 12 }}>هنوز پزشکی دعوت نشده</p>
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
دعوت اولین پزشک
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{d.doctors.map((doc, i) => (
<Link
<div
key={doc.uuid}
to={`/admin/doctors/${doc.uuid}`}
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none', textDecoration: 'none', color: 'inherit' }}
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none' }}
>
<AvatarEl initials={doc.name.slice(0, 1)} hue={162} size="sm" />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 13.5 }}>دکتر {doc.name}</b>
</div>
<span className="badge blue"><span className="bdot" />{formatNumber(doc.today_count)} امروز</span>
</Link>
</div>
))}
</div>
)}
</div>
</div>
{inviteOpen && clinicUuid && (
<InviteDoctorModal
clinicUuid={clinicUuid}
onClose={() => setInviteOpen(false)}
onInvited={() => q.refetch()}
/>
)}
</div>
);
}
+23 -1
View File
@@ -1,5 +1,10 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap');
@import "@fontsource/vazirmatn/300.css";
@import "@fontsource/vazirmatn/400.css";
@import "@fontsource/vazirmatn/500.css";
@import "@fontsource/vazirmatn/600.css";
@import "@fontsource/vazirmatn/700.css";
@import "@fontsource/vazirmatn/800.css";
/* Class-based dark mode for Tailwind v4 */
@custom-variant dark (&:where(.dark, .dark *));
@@ -429,6 +434,23 @@ body {
.badge.violet { color: var(--violet); background: var(--violet-bg); }
.badge.gray { color: var(--text-2); background: var(--surface-3); }
/* ── Appointment status badges ───────────────────────────────── */
.appt-status {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 99px;
white-space: nowrap; border: 1.5px solid transparent; letter-spacing: 0.01em;
}
.appt-status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.appt-status.status-green { color: var(--success); background: var(--success-bg); border-color: color-mix(in srgb, var(--success) 25%, transparent); }
.appt-status.status-amber { color: var(--warning); background: var(--warning-bg); border-color: color-mix(in srgb, var(--warning) 25%, transparent); }
.appt-status.status-red { color: var(--danger); background: var(--danger-bg); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
.appt-status.status-blue { color: var(--info); background: var(--info-bg); border-color: color-mix(in srgb, var(--info) 25%, transparent); }
.appt-status.status-violet { color: var(--violet); background: var(--violet-bg); border-color: color-mix(in srgb, var(--violet) 25%, transparent); }
.appt-status.status-gray { color: var(--text-2); background: var(--surface-3); border-color: var(--border); }
/* field-label utility */
.field-label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 5px; }
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
+10
View File
@@ -6,6 +6,7 @@
"": {
"license": "UNLICENSED",
"dependencies": {
"@fontsource/vazirmatn": "^5.2.8",
"@heroicons/react": "^2.0.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.0.0",
@@ -2131,6 +2132,15 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource/vazirmatn": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.8.tgz",
"integrity": "sha512-WoDgv8R/y1gwgTS8Q2uL8d2ayeSGNv2IrYQ4wHmJkVwYPb8KVODrqbbvEjBqLOo5WP5kLJIrO06FRDMnSFuCaA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
+1
View File
@@ -22,6 +22,7 @@
"webpack-cli": "^6.0.0"
},
"dependencies": {
"@fontsource/vazirmatn": "^5.2.8",
"@heroicons/react": "^2.0.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

+261
View File
@@ -0,0 +1,261 @@
<?php
require __DIR__ . '/vendor/autoload.php';
(new Symfony\Component\Dotenv\Dotenv())->bootEnv(__DIR__ . '/.env');
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool)$_SERVER['APP_DEBUG']);
$kernel->boot();
$em = $kernel->getContainer()->get('doctrine')->getManager();
$conn = $em->getConnection();
$now = time();
// ── Helpers ──────────────────────────────────────────────────────────────────
function slot(string $date, int $hour, int $minute = 0): int {
return mktime($hour, $minute, 0, (int)date('m', strtotime($date)), (int)date('d', strtotime($date)), (int)date('Y', strtotime($date)));
}
function today(int $daysOffset = 0): string {
return date('Y-m-d', strtotime(($daysOffset >= 0 ? "+$daysOffset" : "$daysOffset") . ' days'));
}
// ── Load entities ────────────────────────────────────────────────────────────
$doctor1 = $em->getRepository(\App\Doctor\Entity\Doctor::class)->find(1184); // آرمان رضایی
$doctor4 = $em->getRepository(\App\Doctor\Entity\Doctor::class)->find(1185); // سامان علوی
$clinic2 = $em->getRepository(\App\Clinic\Entity\Clinic::class)->find(203); // کلینیک تست پگاه
$clinic4 = $em->getRepository(\App\Clinic\Entity\Clinic::class)->find(204); // کلینیک تخصصی علوی
$spec3 = $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(3); // قلب و عروق
$spec10 = $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(10); // نورولوژی
$spec110= $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(110); // ارتوپدی
$spec111= $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(111); // ارتوپدی عمومی
// ── کاربر ۱: دکتر آرمان رضایی ───────────────────────────────────────────────
$doctor1->setGender('man')
->setDegree('specialist')
->setMedicalSystemCode('IR-12345')
->setMobileNumber('09100000011')
->setActivityTime(mktime(0,0,0,1,1,2010))
->setInfo('متخصص قلب و عروق با بیش از ۱۴ سال سابقه بالینی. فارغ‌التحصیل دانشگاه علوم پزشکی تهران. عضو انجمن قلب ایران.')
->setDoctorRate(4.5)
->setDoctorRatePercentage(90.0)
->setActiveDoctorAppointment(true);
// تخصص‌ها
$doctor1->getSpecialties()->clear();
if ($spec3) $doctor1->getSpecialties()->add($spec3);
if ($spec10) $doctor1->getSpecialties()->add($spec10);
$em->flush();
// خدمات
$conn->executeStatement('DELETE FROM doctor_expertise WHERE doctor_id = ?', [1184]);
foreach ([101, 102, 103, 104, 105] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?, ?)', [1184, $svcId]);
}
echo "✅ دکتر آرمان رضایی بروزرسانی شد\n";
// ── کاربر ۴: دکتر سامان علوی ────────────────────────────────────────────────
$doctor4->setGender('man')
->setDegree('specialist')
->setMedicalSystemCode('IR-67890')
->setMobileNumber('09100000044')
->setActivityTime(mktime(0,0,0,1,1,2012))
->setInfo('متخصص ارتوپدی و جراحی مفاصل. فارغ‌التحصیل دانشگاه شهید بهشتی. تخصص ویژه در جراحی آرتروسکوپی زانو و تعویض مفصل.')
->setDoctorRate(4.7)
->setDoctorRatePercentage(94.0)
->setActiveDoctorAppointment(true);
$doctor4->getSpecialties()->clear();
if ($spec110) $doctor4->getSpecialties()->add($spec110);
if ($spec111) $doctor4->getSpecialties()->add($spec111);
$em->flush();
$conn->executeStatement('DELETE FROM doctor_expertise WHERE doctor_id = ?', [1185]);
foreach ([11101, 11105, 11201, 11401] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?, ?)', [1185, $svcId]);
}
echo "✅ دکتر سامان علوی بروزرسانی شد\n";
// ── کلینیک تست پگاه (clinic_id=203) ────────────────────────────────────────
$clinic2->setName('کلینیک تخصصی قلب پگاه')
->setInfo('کلینیک تخصصی قلب و عروق با مجهزترین تجهیزات تشخیصی. ارائه خدمات نوار قلب، اکو، تست ورزش و مشاوره تخصصی.')
->setAddress('تهران، خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲۰')
->setTelephone('02188001234')
->setCityId(108)
->setProvinceId(8)
->setLatitude(35.7219)
->setLongitude(51.3347)
->setIsActive(true)
->setIs247(false)
->setWorkingDays('شنبه تا چهارشنبه ۸-۱۶، پنجشنبه ۸-۱۲');
$em->flush();
// تخصص کلینیک
$conn->executeStatement('DELETE FROM clinic_specialties WHERE clinic_id = ?', [203]);
foreach ([3, 10] as $sId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?, ?)', [203, $sId]);
}
// دکتر آرمان را به این کلینیک اضافه کن (علاوه بر clinic id=1,2 که از قبل هست)
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?, ?)', [203, 1184]);
echo "✅ کلینیک تخصصی قلب پگاه بروزرسانی شد\n";
// ── کلینیک تخصصی علوی (clinic_id=204) ─────────────────────────────────────
$clinic4->setName('کلینیک تخصصی ارتوپدی علوی')
->setInfo('کلینیک تخصصی ارتوپدی با امکانات جراحی آرتروسکوپی. ویزیت، رادیولوژی دیجیتال، MRI، فیزیوتراپی.')
->setAddress('تهران، سعادت‌آباد، میدان کاج، خیابان ۲۴ متری، پلاک ۸۵')
->setTelephone('02122345678')
->setCityId(108)
->setProvinceId(8)
->setLatitude(35.8065)
->setLongitude(51.3822)
->setIsActive(true)
->setIs247(false)
->setWorkingDays('شنبه تا پنجشنبه ۸-۱۸');
$em->flush();
$conn->executeStatement('DELETE FROM clinic_specialties WHERE clinic_id = ?', [204]);
foreach ([110, 111] as $sId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?, ?)', [204, $sId]);
}
// دکتر سامان را به کلینیک خودش اضافه کن
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?, ?)', [204, 1185]);
echo "✅ کلینیک تخصصی ارتوپدی علوی بروزرسانی شد\n";
// ── نوبت‌ها ─────────────────────────────────────────────────────────────────
// بیمارانی که از قبل وجود دارن
$patientIds = [789, 790, 791, 792, 793, 794, 795, 796, 797, 798];
$uuid = fn() => sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$insertAppt = function(int $doctorId, int $userId, int $slotStart, int $slotEnd, string $status) use ($conn, $uuid, $now): void {
$conn->executeStatement(
'INSERT INTO appointments (uuid, slot_start, slot_end, status, doctor_id, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[$uuid(), $slotStart, $slotEnd, $status, $doctorId, $userId, $now, $now]
);
};
// ── نوبت‌های گذشته — دکتر آرمان رضایی (doctor_id=1184) ──────────────────
$pastAppts = [
// [-7 تا -1 روز گذشته]
[today(-7), 9, 0, 'visited', 789],
[today(-7), 10, 0, 'visited', 790],
[today(-6), 9, 0, 'cancelled_by_user', 791],
[today(-6), 11, 0, 'visited', 792],
[today(-5), 9, 0, 'visited', 793],
[today(-5), 14, 0, 'no_show', 794],
[today(-4), 9, 0, 'visited', 795],
[today(-4), 10, 0, 'visited', 796],
[today(-3), 9, 0, 'cancelled_by_doctor', 797],
[today(-3), 11, 0, 'visited', 798],
[today(-2), 9, 0, 'visited', 789],
[today(-2), 10, 0, 'visited', 790],
[today(-1), 9, 0, 'visited', 791],
[today(-1), 11, 0, 'no_show', 792],
];
foreach ($pastAppts as [$date, $h, $m, $status, $patId]) {
$start = slot($date, $h, $m);
$insertAppt(1184, $patId, $start, $start + 1800, $status);
}
echo "✅ نوبت‌های گذشته دکتر آرمان ساخته شد (" . count($pastAppts) . " نوبت)\n";
// ── نوبت‌های آینده — دکتر آرمان رضایی (doctor_id=1184) ───────────────────
$futureAppts = [
[today(1), 9, 0, 'reserved', 793],
[today(1), 10, 0, 'reserved', 794],
[today(1), 11, 0, 'reserved', 795],
[today(2), 9, 0, 'reserved', 796],
[today(2), 10, 0, 'reserved', 797],
[today(3), 9, 0, 'reserved', 798],
[today(3), 14, 0, 'reserved', 789],
[today(5), 9, 0, 'reserved', 790],
[today(7), 10, 0, 'reserved', 791],
[today(10),9, 0, 'reserved', 792],
];
foreach ($futureAppts as [$date, $h, $m, $status, $patId]) {
$start = slot($date, $h, $m);
$insertAppt(1184, $patId, $start, $start + 1800, $status);
}
echo "✅ نوبت‌های آینده دکتر آرمان ساخته شد (" . count($futureAppts) . " نوبت)\n";
// ── نوبت‌های گذشته — دکتر سامان علوی (doctor_id=1185) ───────────────────
$pastAppts4 = [
[today(-10), 10, 0, 'visited', 793],
[today(-10), 11, 0, 'visited', 794],
[today(-8), 9, 0, 'visited', 795],
[today(-8), 14, 0, 'cancelled_by_user', 796],
[today(-6), 9, 0, 'visited', 797],
[today(-6), 10, 0, 'visited', 798],
[today(-4), 9, 0, 'no_show', 789],
[today(-4), 11, 0, 'visited', 790],
[today(-2), 10, 0, 'visited', 791],
[today(-1), 9, 0, 'visited', 792],
];
foreach ($pastAppts4 as [$date, $h, $m, $status, $patId]) {
$start = slot($date, $h, $m);
$insertAppt(1185, $patId, $start, $start + 2700, $status); // 45 دقیقه برای ارتوپد
}
echo "✅ نوبت‌های گذشته دکتر سامان ساخته شد (" . count($pastAppts4) . " نوبت)\n";
// ── نوبت‌های آینده — دکتر سامان علوی (doctor_id=1185) ────────────────────
$futureAppts4 = [
[today(1), 10, 0, 'reserved', 793],
[today(1), 11, 0, 'reserved', 794],
[today(2), 9, 0, 'reserved', 795],
[today(2), 14, 0, 'reserved', 796],
[today(3), 10, 0, 'reserved', 797],
[today(4), 9, 0, 'reserved', 798],
[today(6), 11, 0, 'reserved', 789],
[today(8), 9, 0, 'reserved', 790],
];
foreach ($futureAppts4 as [$date, $h, $m, $status, $patId]) {
$start = slot($date, $h, $m);
$insertAppt(1185, $patId, $start, $start + 2700, $status);
}
echo "✅ نوبت‌های آینده دکتر سامان ساخته شد (" . count($futureAppts4) . " نوبت)\n";
echo "\n=== همه داده‌ها با موفقیت ساخته شدند ===\n\n";
// ── خلاصه نهایی ──────────────────────────────────────────────────────────────
echo "┌─────────────────────────────────────────────────────────────────────┐\n";
echo "│ خلاصه کاربران تستی │\n";
echo "├─────────────────────────────────────────────────────────────────────┤\n";
echo "│ ادمین: 09120671713 / admin123 │\n";
echo "│ دکتر چند کلینیک: 09100000011 / Test1234 (دکتر آرمان رضایی) │\n";
echo "│ صاحب کلینیک: 09100000022 / Test1234 (کلینیک قلب پگاه) │\n";
echo "│ منشی: 09100000033 / Test1234 (منشی دکتر مهناز) │\n";
echo "│ دکتر+کلینیک: 09100000044 / Test1234 (دکتر سامان + کلینیک علوی)│\n";
echo "└─────────────────────────────────────────────────────────────────────┘\n";
@@ -641,6 +641,52 @@ class AdminApiController extends BaseController
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
public function createAppointment(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
$mobile = trim($data['patient_mobile'] ?? '');
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) {
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422);
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
$conflict = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
->setParameter('doctor', $doctor)
->setParameter('start', $slotStart)
->setParameter('end', $slotEnd)
->getQuery()->getSingleScalarResult();
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$this->em->persist($appointment);
$this->em->flush();
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $slotStart,
'slot_end' => $slotEnd,
'status' => $appointment->getStatus(),
], 201);
}
#[Route('/api/v1/admin/payments', methods: ['GET'])]
public function payments(Request $request): JsonResponse
{
@@ -36,7 +36,7 @@ class MyAppointmentsController extends BaseController
$qb = $this->em->createQueryBuilder()
->select(
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
'd.uuid as doctor_uuid, d.name as doctor_name',
'u.mobileNumber as patient_mobile, u.realName as patient_name',
'c.name as clinic_name'
@@ -56,7 +56,7 @@ class MyAppointmentsController extends BaseController
if ($clinic === null) {
return $this->paginated([], 0, $page, $limit);
}
$qb->andWhere(':clinic MEMBER OF d.clinics')
$qb->andWhere('c = :clinic')
->setParameter('clinic', $clinic);
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
$doctor = $this->doctorRepo->findByUser($user);
@@ -2,6 +2,7 @@
namespace App\ClinicInvitation\Controller;
use App\Auth\Entity\User;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\Clinic\Repository\ClinicRepository;
@@ -10,6 +11,7 @@ use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ClinicInvitationController extends BaseController
@@ -23,14 +25,16 @@ class ClinicInvitationController extends BaseController
// ── Admin endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinic/{uuid}/invite-doctor', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function inviteDoctor(string $uuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$this->assertClinicAccess($clinic, $user);
$body = json_decode($request->getContent(), true) ?? [];
$mobile = trim($body['mobile'] ?? '');
$name = !empty($body['name']) ? trim($body['name']) : null;
@@ -40,20 +44,22 @@ class ClinicInvitationController extends BaseController
throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty);
$inv = $this->invitationService->invite($clinic, $user, $mobile, $name, $specialty);
return $this->success($inv->toArray(), 201);
}
#[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function listInvitations(string $uuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listInvitations(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$this->assertClinicAccess($clinic, $user);
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
@@ -76,28 +82,31 @@ class ClinicInvitationController extends BaseController
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function resendInvitation(string $invUuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$this->invitationService->resend($inv);
return $this->success(['message' => 'پیامک مجدداً ارسال شد']);
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$body = json_decode($request->getContent(), true) ?? [];
$status = $body['status'] ?? '';
@@ -107,19 +116,31 @@ class ClinicInvitationController extends BaseController
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteInvitation(string $invUuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$this->invitationService->delete($inv);
return $this->success(null, 204);
}
private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void
{
if ($user->hasRole('ROLE_ADMIN')) {
return;
}
if ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId()) {
return;
}
throw new AppException('ERR_ACCESS_DENIED', 'دسترسی ندارید', 403);
}
// ── Public endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])]
+5
View File
@@ -1082,6 +1082,11 @@
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz"
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
"@fontsource/vazirmatn@^5.2.8":
version "5.2.8"
resolved "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.8.tgz"
integrity sha512-WoDgv8R/y1gwgTS8Q2uL8d2ayeSGNv2IrYQ4wHmJkVwYPb8KVODrqbbvEjBqLOo5WP5kLJIrO06FRDMnSFuCaA==
"@heroicons/react@^2.0.0":
version "2.2.0"
resolved "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz"