feat(api): enhance appointment retrieval and add today stats endpoint
This commit is contained in:
@@ -13,6 +13,8 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||
|
||||
const EMPTY_ARR: Appointment[] = [];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status config
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -36,12 +38,12 @@ function statusMeta(s: string) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function IconTotal() {
|
||||
const dots: [number, number][] = [];
|
||||
for (const x of [10, 16, 22, 28, 34]) for (const y of [10, 16, 22, 28, 34]) dots.push([x, y]);
|
||||
return (
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
||||
<circle cx="22" cy="22" r="22" fill="#ede9fe" />
|
||||
{[8,14,20,26].map(x => [8,14,20,26].map(y => (
|
||||
<circle key={`${x}${y}`} cx={x} cy={y} r="2" fill="#7c3aed" opacity="0.7" />
|
||||
)))}
|
||||
{dots.map(([x, y]) => <circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" fill="#7c3aed" opacity="0.6" />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -82,10 +84,14 @@ function IconCancelled() {
|
||||
|
||||
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
||||
|
||||
function StatsBar({ date }: { date: string }) {
|
||||
function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) {
|
||||
const { data } = useQuery<ApiResponse<TodayStats>>({
|
||||
queryKey: ['appt-today-stats', date],
|
||||
queryFn: () => api.get(`/api/v1/admin/appointments/today-stats?date=${date}`),
|
||||
queryKey: ['appt-today-stats', date, isAdmin],
|
||||
queryFn: () => api.get(
|
||||
isAdmin
|
||||
? `/api/v1/admin/appointments/today-stats?date=${date}`
|
||||
: `/api/v1/my/appointments/today-stats?date=${date}`
|
||||
),
|
||||
});
|
||||
const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
||||
const stats = [
|
||||
@@ -337,8 +343,11 @@ function NewAppointmentModal({
|
||||
const [mobile, setMobile] = useState('');
|
||||
const qc = useQueryClient();
|
||||
|
||||
const role = useAuthStore(s => s.primaryRole);
|
||||
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.post('/api/v1/admin/appointment', {
|
||||
mutationFn: () => api.post(createEndpoint, {
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: slot.start,
|
||||
slot_end: slot.end,
|
||||
@@ -405,7 +414,8 @@ function NewAppointmentModal({
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { primaryRole, dbUuid } = useAuthStore(s => ({ primaryRole: s.primaryRole, dbUuid: s.dbUuid }));
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const isClinic = primaryRole === 'clinic';
|
||||
const isDoctor = primaryRole === 'doctor';
|
||||
@@ -418,15 +428,16 @@ export default function AppointmentsPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Appointments query
|
||||
const apptQueryKey = ['admin-appointments', selectedDate, selectedDoctorUuid];
|
||||
const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
|
||||
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
||||
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
||||
|
||||
const apptQuery = useQuery<PaginatedResponse<Appointment>>({
|
||||
queryKey: apptQueryKey,
|
||||
queryFn: () => api.get(`/api/v1/admin/appointments?${apptParams}`),
|
||||
queryFn: () => api.get(`${apptEndpoint}?${apptParams}`),
|
||||
});
|
||||
const appointments: Appointment[] = apptQuery.data?.data ?? [];
|
||||
const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR;
|
||||
|
||||
// ── Unique doctors from results (for clinic tabs)
|
||||
const doctors = React.useMemo(() => {
|
||||
@@ -451,7 +462,7 @@ export default function AppointmentsPage() {
|
||||
// ── Merge slots + appointments for schedule view
|
||||
const mergedSlots: SlotItem[] = React.useMemo(() => {
|
||||
if (viewMode !== 'schedule') return [];
|
||||
const rawSlots: any[] = slotsQuery.data?.data ?? [];
|
||||
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
||||
const apptByStart = new Map<number, Appointment>();
|
||||
appointments.forEach(a => apptByStart.set(a.slot_start, a));
|
||||
|
||||
@@ -484,7 +495,7 @@ export default function AppointmentsPage() {
|
||||
return (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
{/* Stats Bar */}
|
||||
{isAdmin && <StatsBar date={selectedDate} />}
|
||||
<StatsBar date={selectedDate} isAdmin={isAdmin} />
|
||||
|
||||
{/* Doctor tabs (clinic with ≥2 doctors) */}
|
||||
{showDoctorTabs && (
|
||||
|
||||
@@ -28,35 +28,35 @@ class MyAppointmentsController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$status = trim((string) $request->query->get('status', ''));
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(500, max(1, (int) $request->query->get('limit', 15)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$status = trim((string) $request->query->get('status', ''));
|
||||
$date = trim((string) $request->query->get('date', ''));
|
||||
$doctorUuid = trim((string) $request->query->get('doctor_uuid', ''));
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version',
|
||||
'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'
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name'
|
||||
)
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->join('a.user', 'u')
|
||||
->leftJoin('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->orderBy('a.slotStart', 'DESC');
|
||||
->orderBy('a.slotStart', 'ASC');
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) {
|
||||
// Admin voit tout
|
||||
// Admin sees all
|
||||
} elseif (in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('c = :clinic')
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $clinic);
|
||||
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
@@ -87,14 +87,15 @@ class MyAppointmentsController extends BaseController
|
||||
if ($status !== '') {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
if ($date !== '') {
|
||||
$dayStart = strtotime($date . ' 00:00:00');
|
||||
$dayEnd = strtotime($date . ' 23:59:59');
|
||||
if ($dayStart && $dayEnd) {
|
||||
$qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
|
||||
->setParameter('dayStart', $dayStart)
|
||||
->setParameter('dayEnd', $dayEnd);
|
||||
}
|
||||
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = (int) strtotime($date . ' 23:59:59');
|
||||
$qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
|
||||
->setParameter('dayStart', $dayStart)
|
||||
->setParameter('dayEnd', $dayEnd);
|
||||
}
|
||||
if ($doctorUuid !== '') {
|
||||
$qb->andWhere('d.uuid = :doctorUuid')->setParameter('doctorUuid', $doctorUuid);
|
||||
}
|
||||
|
||||
$total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
|
||||
@@ -106,16 +107,80 @@ class MyAppointmentsController extends BaseController
|
||||
'uuid' => $a['uuid'],
|
||||
'patient_name' => $a['patient_name'] ?? '',
|
||||
'patient_mobile' => $a['patient_mobile'],
|
||||
'doctor_uuid' => $a['doctor_uuid'],
|
||||
'doctor_name' => $a['doctor_name'],
|
||||
'clinic_name' => $a['clinic_name'] ?? null,
|
||||
'slot_start' => (int) $a['slotStart'],
|
||||
'slot_end' => (int) $a['slotEnd'],
|
||||
'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
|
||||
'appointment_time' => date('H:i', (int) $a['slotStart']),
|
||||
'slot_start' => (int) $a['slotStart'],
|
||||
'end_time' => date('H:i', (int) $a['slotEnd']),
|
||||
'status' => $a['status'],
|
||||
'amount' => 0,
|
||||
'version' => (int) $a['version'],
|
||||
'created_at' => date('c', (int) $a['createdAt']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/my/appointments/today-stats', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function todayStats(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$date = trim((string) $request->query->get('date', date('Y-m-d')));
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = (int) strtotime($date . ' 23:59:59');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('a.status, COUNT(a.id) AS cnt')
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->where('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
|
||||
->setParameter('dayStart', $dayStart)
|
||||
->setParameter('dayEnd', $dayEnd)
|
||||
->groupBy('a.status');
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic) {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $clinic);
|
||||
}
|
||||
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor) {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
|
||||
}
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel) {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor());
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
$byStatus = [];
|
||||
foreach ($rows as $row) {
|
||||
$byStatus[$row['status']] = (int) $row['cnt'];
|
||||
}
|
||||
|
||||
$total = array_sum($byStatus);
|
||||
$completed = ($byStatus['completed'] ?? 0);
|
||||
$cancelled = ($byStatus['cancelled_by_doctor'] ?? 0)
|
||||
+ ($byStatus['cancelled_by_user'] ?? 0)
|
||||
+ ($byStatus['no_show'] ?? 0)
|
||||
+ ($byStatus['expired'] ?? 0);
|
||||
$waiting = $total - $completed - $cancelled;
|
||||
|
||||
return $this->success([
|
||||
'total' => $total,
|
||||
'completed' => $completed,
|
||||
'waiting' => max(0, $waiting),
|
||||
'cancelled' => $cancelled,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user