feat: update appointment management API and frontend components
- Added new endpoint to get today's appointment statistics with optional date filter. - Enhanced appointment listing API to support filtering by date and doctor UUID. - Updated Appointment model to include new fields and modified status values. - Implemented AppointmentStatusDropdown component for status management with visual feedback. - Created PersianCalendar component for date selection in Jalali format. - Updated API documentation to reflect changes in appointment management.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: run-prompt
|
||||
description: اجرای یک فایل پرامپت .md به صورت گامبهگام و ایمن. هر قابلیت را جداگانه پیادهسازی، تست و مستند میکند. استفاده کن وقتی کاربر میگوید "اجرای پرامپت"، "پرامپت را اجرا کن"، "run prompt"، یا مسیر یک فایل .md میدهد.
|
||||
---
|
||||
|
||||
## نحوه دریافت ورودی
|
||||
|
||||
اگر کاربر مسیر فایل داد → آن را بخوان.
|
||||
اگر فایل مشخص نشد → بپرس: «مسیر فایل پرامپت .md را وارد کنید»
|
||||
|
||||
---
|
||||
|
||||
## قبل از شروع — تحلیل پرامپت
|
||||
|
||||
۱. فایل پرامپت را کامل بخوان
|
||||
۲. مستندات موجود را بررسی کن: `docs/api/`
|
||||
۳. کد مرتبط در `src/` و `assets/admin/` را مرور کن
|
||||
۴. لیست قابلیتها را از پرامپت استخراج کن
|
||||
۵. به کاربر نمایش بده:
|
||||
|
||||
```
|
||||
📋 قابلیتهای شناساییشده:
|
||||
۱. [نام قابلیت اول]
|
||||
۲. [نام قابلیت دوم]
|
||||
...
|
||||
|
||||
🚀 شروع با قابلیت ۱ — آیا ادامه دهم؟
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قوانین اجرا (اجباری — هیچ استثنایی ندارد)
|
||||
|
||||
### ۱. یک قابلیت در هر مرحله
|
||||
- هرگز دو قابلیت را همزمان پیادهسازی نکن
|
||||
- هرگز بدون تأیید موفقیت مرحله قبل به مرحله بعد نرو
|
||||
|
||||
### ۲. ترتیب اجرای هر قابلیت
|
||||
|
||||
```
|
||||
① تحلیل ② طراحی ③ پیادهسازی ④ تست ⑤ رفع خطا ⑥ مستندسازی ⑦ گزارش
|
||||
```
|
||||
|
||||
### ۳. سازگاری با پروژه
|
||||
- همه تغییرات باید با Symfony 7 + React 19 سازگار باشند
|
||||
- از الگوهای موجود پروژه پیروی کن (BaseController، TanStack Query، Zod، ...)
|
||||
- اگر Entity تغییر کرد: `doctrine:migrations:diff` و `doctrine:migrations:migrate` اجرا کن
|
||||
- اگر API تغییر کرد: همه بخشهای وابسته (frontend types، api calls، ...) را اصلاح کن
|
||||
|
||||
### ۴. اصول کدنویسی
|
||||
- SOLID و Clean Code
|
||||
- هیچ کامنت غیرضروری اضافه نکن
|
||||
- نامگذاری معنادار
|
||||
- error handling فقط در boundaries واقعی (نه defensive programming اضافی)
|
||||
|
||||
---
|
||||
|
||||
## روند اجرای هر قابلیت
|
||||
|
||||
### مرحله ① — تحلیل
|
||||
|
||||
قبل از هر چیز:
|
||||
- فایلهای مرتبط را بخوان
|
||||
- endpoint های موجود را بررسی کن: `php bin/console debug:router | grep api`
|
||||
- Entity های مرتبط را شناسایی کن
|
||||
- اگر سوال یا ابهامی هست → همینجا بپرس، نه وسط پیادهسازی
|
||||
|
||||
### مرحله ② — طراحی
|
||||
|
||||
قبل از کدنویسی، طرح کوتاه را توضیح بده:
|
||||
- چه فایلهایی ایجاد/تغییر میکنند
|
||||
- چه API endpoint هایی اضافه/تغییر میکنند
|
||||
- چه migration لازم است (اگر Entity تغییر کرد)
|
||||
|
||||
### مرحله ③ — پیادهسازی
|
||||
|
||||
- **Backend اول**: Entity → Migration → Repository → Service → Controller
|
||||
- **Frontend بعد**: Types → API call → Component → Route
|
||||
- یک فایل در هر Edit/Write — نه bulk
|
||||
|
||||
### مرحله ④ — تست
|
||||
|
||||
بعد از هر قابلیت این چکلیست را اجرا کن:
|
||||
|
||||
```bash
|
||||
# PHP syntax
|
||||
ddev exec php -l src/Path/To/ChangedFile.php
|
||||
|
||||
# Symfony cache (اگر route یا config تغییر کرد)
|
||||
ddev exec php bin/console cache:clear
|
||||
|
||||
# Migration (اگر Entity تغییر کرد)
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
# Frontend build
|
||||
ddev exec yarn dev
|
||||
|
||||
# Route وجود دارد؟
|
||||
ddev exec php bin/console debug:router | grep "new-route-name"
|
||||
```
|
||||
|
||||
اگر frontend تغییر کرد، TypeScript errors را بررسی کن:
|
||||
```bash
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30
|
||||
```
|
||||
|
||||
### مرحله ⑤ — رفع خطا
|
||||
|
||||
- اگر خطا بود → **همینجا** رفع کن، به مرحله بعد نرو
|
||||
- اگر خطا در فایل دیگری بود → آن را هم رفع کن
|
||||
- بعد از رفع خطا → دوباره تست را اجرا کن
|
||||
|
||||
### مرحله ⑥ — مستندسازی
|
||||
|
||||
بعد از موفقیت تست، مستندات را بهروزرسانی کن:
|
||||
|
||||
| تغییر | فایل مستندات |
|
||||
|-------|-------------|
|
||||
| `src/Auth/*` | `docs/api/auth.md` |
|
||||
| `src/Doctor/*` | `docs/api/doctor.md` |
|
||||
| `src/Clinic/*` | `docs/api/clinic.md` |
|
||||
| `src/Appointment/Controller/AppointmentController.php` | `docs/api/appointment.md` |
|
||||
| `src/Appointment/Controller/AppointmentSettings*` | `docs/api/appointment-settings.md` |
|
||||
| `src/Payment/*` | `docs/api/payment.md` |
|
||||
| `src/Admin/*` | `docs/api/admin.md` |
|
||||
| `src/Secretary/*` | `docs/api/secretary.md` |
|
||||
| `src/Representation/*` | `docs/api/representation.md` |
|
||||
| `src/Blog/*` | `docs/api/blog.md` |
|
||||
| `src/Rating/*` | `docs/api/rating.md` |
|
||||
| `src/Settlement/*` | `docs/api/settlement.md` |
|
||||
| `src/Sms/*` | `docs/api/sms.md` |
|
||||
|
||||
الزامات مستندسازی:
|
||||
1. endpoint جدید با method، path، permission
|
||||
2. Request body با تمام فیلدها و نوع داده
|
||||
3. Response format با مثال واقعی JSON
|
||||
4. تمام status codes و error codes
|
||||
5. اگر پارامتر query داشت → همه را مستند کن
|
||||
|
||||
### مرحله ⑦ — گزارش
|
||||
|
||||
بعد از اتمام هر قابلیت، گزارش کوتاه:
|
||||
|
||||
```
|
||||
✅ قابلیت [شماره]: [نام] — تکمیل شد
|
||||
|
||||
فایلهای تغییر یافته:
|
||||
• src/...
|
||||
• assets/admin/...
|
||||
• docs/api/...
|
||||
|
||||
─────────────────────────────────
|
||||
▶ قابلیت بعدی: [شماره] — [نام]
|
||||
آیا ادامه دهم؟
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قوانین خاص این پروژه
|
||||
|
||||
### Backend
|
||||
- همه controller ها از `BaseController` ارث میبرند
|
||||
- پاسخها: `$this->success($data)` | `$this->paginated(...)` | `$this->error(...)`
|
||||
- لیستهای admin از DQL array hydration (`.getArrayResult()`) استفاده کنند
|
||||
- تاریخها Unix timestamp صحیح (نه DateTime object)
|
||||
- بعد از هر تغییر Entity: حتماً migration بساز و اجرا کن
|
||||
|
||||
### Frontend
|
||||
- دادههای paginated: items از `data?.data`، total از `data?.meta?.totalRecords`
|
||||
- دادههای single resource: از `data?.data` (ممکن است double-nested باشد)
|
||||
- Category API همیشه triple-nested: `data?.data?.data ?? []`
|
||||
- JWT در `localStorage['clinicpro-auth']` → `state.token`
|
||||
- همه تاریخها با `formatDate()` شمسی نمایش داده شوند (`fa-IR-u-ca-persian`)
|
||||
- Form: React Hook Form + Zod resolver
|
||||
- State management: TanStack Query v5 برای server state، Zustand برای client state
|
||||
|
||||
### CSS / UI
|
||||
- از کلاسهای موجود استفاده کن: `btn primary sm`، `badge green`، `card`، `toolbar`، `field`، `avatar`، `appt-status`، ...
|
||||
- هیچ کتابخانه CSS جدید اضافه نکن مگر ضرورت قطعی داشته باشد
|
||||
- RTL رعایت شود
|
||||
|
||||
---
|
||||
|
||||
## مثال اجرا
|
||||
|
||||
```
|
||||
کاربر: /run-prompt .claude/prompt/appointments-redesign.md
|
||||
|
||||
→ فایل را میخوانم...
|
||||
→ تحلیل میکنم...
|
||||
|
||||
📋 قابلیتهای شناساییشده:
|
||||
۱. Stats Bar — آمار امروز (backend + frontend)
|
||||
۲. تقویم شمسی Popup (PersianCalendar component)
|
||||
۳. بازطراحی Toolbar با date navigator
|
||||
۴. نمای جدولی با inline status change
|
||||
۵. نمای زمانبندی (Schedule View)
|
||||
۶. ثبت نوبت از slot خالی
|
||||
|
||||
🚀 شروع با قابلیت ۱ (Stats Bar) — آیا ادامه دهم؟
|
||||
```
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'رزرو شده', color: '#3b82f6' },
|
||||
confirmed: { label: 'تأیید شده', color: '#22c55e' },
|
||||
completed: { label: 'تکمیل شده', color: '#16a34a' },
|
||||
cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444' },
|
||||
cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444' },
|
||||
no_show: { label: 'غیبت', color: '#9ca3af' },
|
||||
expired: { label: 'منقضی شده', color: '#9ca3af' },
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
pending: ['confirmed', 'cancelled_by_doctor', 'cancelled_by_user', 'expired'],
|
||||
confirmed: ['completed', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
uuid: string;
|
||||
currentStatus: string;
|
||||
version: number;
|
||||
queryKey: unknown[];
|
||||
}
|
||||
|
||||
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newStatus: string) =>
|
||||
api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error('خطا در تغییر وضعیت'),
|
||||
});
|
||||
|
||||
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
|
||||
const nextStatuses = TRANSITIONS[currentStatus] ?? [];
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
disabled={nextStatuses.length === 0}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 10px', borderRadius: 99, fontSize: 12, fontWeight: 700,
|
||||
border: `1.5px solid ${meta.color}30`,
|
||||
background: `${meta.color}15`,
|
||||
color: meta.color,
|
||||
cursor: nextStatuses.length > 0 ? 'pointer' : 'default',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: meta.color, flexShrink: 0 }} />
|
||||
{meta.label}
|
||||
{nextStatuses.length > 0 && (
|
||||
<ChevronDownIcon style={{ width: 12, height: 12, flexShrink: 0 }} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && nextStatuses.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 100,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}>
|
||||
{nextStatuses.map(s => {
|
||||
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
|
||||
const isCurrent = s === currentStatus;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => mutation.mutate(s)}
|
||||
disabled={mutation.isPending}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
width: '100%', padding: '8px 12px', fontSize: 13,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: sm.color, fontWeight: isCurrent ? 700 : 400,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
|
||||
background: isCurrent ? sm.color : 'transparent',
|
||||
border: `2px solid ${sm.color}`,
|
||||
}} />
|
||||
{sm.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface Props {
|
||||
value: string; // YYYY-MM-DD Gregorian
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||
|
||||
const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' });
|
||||
|
||||
function toJalali(d: Date): { year: number; month: number; day: number } {
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', calendar: 'persian',
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10);
|
||||
return { year: get('year'), month: get('month'), day: get('day') };
|
||||
}
|
||||
|
||||
function jalaliFirstWeekday(year: number, month: number): number {
|
||||
// Build a Gregorian date for the 1st of this Jalali month by iterating
|
||||
// Use the Intl API: find Gregorian date whose Jalali = year/month/1
|
||||
// Approximate: use known offset
|
||||
const approxGreg = jalaliToGregorian(year, month, 1);
|
||||
const d = new Date(approxGreg + 'T12:00:00');
|
||||
// JS getDay(): 0=Sun,1=Mon,...,6=Sat → convert to Sat=0
|
||||
return (d.getDay() + 1) % 7; // Sat=0, Sun=1, ..., Fri=6
|
||||
}
|
||||
|
||||
function jalaliToGregorian(jy: number, jm: number, jd: number): string {
|
||||
const jy2 = jy - 979;
|
||||
const jm2 = jm - 1;
|
||||
let jDay = 365 * jy2 + Math.floor(jy2 / 33) * 8 + Math.floor(((jy2 % 33) + 3) / 4);
|
||||
for (let i = 0; i < jm2; i++) jDay += (i < 6 ? 31 : 30);
|
||||
jDay += jd - 1;
|
||||
let gDay = jDay + 79;
|
||||
let gy2 = 1600 + 400 * Math.floor(gDay / 146097);
|
||||
gDay %= 146097;
|
||||
let leap = true;
|
||||
if (gDay >= 36525) {
|
||||
gDay--;
|
||||
const gi = Math.floor(gDay / 36524);
|
||||
gDay %= 36524;
|
||||
gy2 += gi * 100;
|
||||
if (gDay >= 365) { gDay++; leap = false; }
|
||||
}
|
||||
gy2 += Math.floor(gDay / 1461) * 4;
|
||||
gDay %= 1461;
|
||||
if (gDay >= 366) {
|
||||
leap = false;
|
||||
gDay--;
|
||||
gy2 += Math.floor(gDay / 365);
|
||||
gDay %= 365;
|
||||
}
|
||||
const gMonthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
let gm = 0;
|
||||
for (; gm < 12; gm++) {
|
||||
if (gDay < gMonthDays[gm]) break;
|
||||
gDay -= gMonthDays[gm];
|
||||
}
|
||||
return `${gy2}-${String(gm + 1).padStart(2, '0')}-${String(gDay + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
|
||||
|
||||
export default function PersianCalendar({ value, onChange, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const todayGreg = new Date().toISOString().slice(0, 10);
|
||||
const todayJ = toJalali(new Date(todayGreg + 'T12:00:00'));
|
||||
const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ;
|
||||
|
||||
const [viewYear, setViewYear] = useState(valueJ.year);
|
||||
const [viewMonth, setViewMonth] = useState(valueJ.month);
|
||||
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
const daysCount = viewMonth <= 6 ? 31 : viewMonth <= 11 ? 30 : 29;
|
||||
const firstWd = jalaliFirstWeekday(viewYear, viewMonth);
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 1) { setViewYear(y => y - 1); setViewMonth(12); }
|
||||
else setViewMonth(m => m - 1);
|
||||
}
|
||||
function nextMonth() {
|
||||
if (viewMonth === 12) { setViewYear(y => y + 1); setViewMonth(1); }
|
||||
else setViewMonth(m => m + 1);
|
||||
}
|
||||
|
||||
function selectDay(day: number) {
|
||||
const greg = jalaliToGregorian(viewYear, viewMonth, day);
|
||||
onChange(greg);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const isToday = (day: number) =>
|
||||
todayJ.year === viewYear && todayJ.month === viewMonth && todayJ.day === day;
|
||||
|
||||
const isSelected = (day: number) =>
|
||||
value !== '' && valueJ.year === viewYear && valueJ.month === viewMonth && valueJ.day === day;
|
||||
|
||||
const cells: (number | null)[] = [...Array(firstWd).fill(null), ...Array.from({ length: daysCount }, (_, i) => i + 1)];
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||
padding: '12px', width: 268, direction: 'rtl',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<button onClick={nextMonth} style={navBtnStyle}>
|
||||
<ChevronRightIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: 14 }}>
|
||||
{JALALI_MONTHS[viewMonth - 1]} {viewYear.toLocaleString('fa-IR')}
|
||||
</span>
|
||||
<button onClick={prevMonth} style={navBtnStyle}>
|
||||
<ChevronLeftIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Week day headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
|
||||
{WEEK_DAYS.map(d => (
|
||||
<div key={d} style={{ textAlign: 'center', fontSize: 11, color: 'var(--text-3)', fontWeight: 600, padding: '2px 0' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={i} />;
|
||||
const sel = isSelected(day);
|
||||
const tod = isToday(day) && !sel;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => selectDay(day)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: '50%', fontSize: 12,
|
||||
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: sel || tod ? 700 : 400,
|
||||
background: sel ? 'var(--primary)' : tod ? 'var(--surface-2)' : 'transparent',
|
||||
color: sel ? '#fff' : tod ? 'var(--text)' : 'var(--text)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{day.toLocaleString('fa-IR')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navBtnStyle: React.CSSProperties = {
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-2)', padding: 4, borderRadius: 'var(--r-sm)',
|
||||
display: 'flex', alignItems: 'center',
|
||||
};
|
||||
@@ -4,18 +4,13 @@ import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementSta
|
||||
type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray';
|
||||
|
||||
const appointmentMap: Record<AppointmentStatus, { color: BadgeColor; label: string }> = {
|
||||
waiting_for_payment: { color: 'amber', label: 'انتظار پرداخت' },
|
||||
reserved: { color: 'blue', label: 'رزرو شده' },
|
||||
checked_in: { color: 'violet', label: 'ورود به مطب' },
|
||||
waiting: { color: 'amber', label: 'صف انتظار' },
|
||||
in_progress: { color: 'blue', label: 'در حال ویزیت' },
|
||||
visited: { color: 'green', label: 'ویزیت شده' },
|
||||
pending: { color: 'blue', label: 'رزرو شده' },
|
||||
confirmed: { color: 'green', label: 'تأیید شده' },
|
||||
completed: { color: 'green', label: 'تکمیل شده' },
|
||||
cancelled_by_user: { color: 'red', label: 'لغو بیمار' },
|
||||
cancelled_by_doctor: { color: 'red', label: 'لغو پزشک' },
|
||||
cancelled_by_admin: { color: 'red', label: 'لغو ادمین' },
|
||||
auto_cancel_unpaid: { color: 'gray', label: 'لغو خودکار' },
|
||||
no_show: { color: 'gray', label: 'غیبت' },
|
||||
expired: { color: 'gray', label: 'منقضی' },
|
||||
};
|
||||
|
||||
const paymentMap: Record<PaymentStatus, { color: BadgeColor; label: string }> = {
|
||||
|
||||
@@ -6,21 +6,19 @@ import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment, AppointmentStatus } from '../types';
|
||||
import { formatDate, formatDateTime, formatRial } from '../lib/utils';
|
||||
import { formatDate, formatDateTime } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
|
||||
{ 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: 'cancelled_by_admin', label: 'لغو توسط ادمین' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
{ value: 'pending', label: 'رزرو شده' },
|
||||
{ value: 'confirmed', label: 'تأیید شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'cancelled_by_doctor', label: 'لغو پزشک' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو بیمار' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
{ value: 'expired', label: 'منقضی' },
|
||||
];
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
@@ -98,10 +96,9 @@ export default function AppointmentDetailPage() {
|
||||
<InfoRow label="نام بیمار" value={appt.patient_name} />
|
||||
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
|
||||
<InfoRow label="پزشک" value={`دکتر ${appt.doctor_name}`} />
|
||||
<InfoRow label="کلینیک" value={appt.clinic_name} />
|
||||
<InfoRow label="تاریخ نوبت" value={formatDate(appt.appointment_date)} />
|
||||
<InfoRow label="ساعت" value={appt.appointment_time} />
|
||||
<InfoRow label="مبلغ" value={formatRial(appt.amount)} />
|
||||
<InfoRow label="ساعت شروع" value={appt.appointment_time} />
|
||||
<InfoRow label="ساعت پایان" value={appt.end_time} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+10
-12
@@ -72,29 +72,27 @@ export interface ClinicDetail {
|
||||
}
|
||||
|
||||
export type AppointmentStatus =
|
||||
| 'waiting_for_payment'
|
||||
| 'reserved'
|
||||
| 'checked_in'
|
||||
| 'waiting'
|
||||
| 'in_progress'
|
||||
| 'visited'
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'completed'
|
||||
| 'cancelled_by_user'
|
||||
| 'cancelled_by_doctor'
|
||||
| 'cancelled_by_admin'
|
||||
| 'auto_cancel_unpaid'
|
||||
| 'no_show';
|
||||
| 'cancelled_by_user'
|
||||
| 'no_show'
|
||||
| 'expired';
|
||||
|
||||
export interface Appointment {
|
||||
uuid: string;
|
||||
patient_name: string;
|
||||
patient_mobile: string;
|
||||
doctor_uuid: string;
|
||||
doctor_name: string;
|
||||
clinic_name: string | null;
|
||||
slot_start: number;
|
||||
slot_end: number;
|
||||
appointment_date: string;
|
||||
appointment_time: string;
|
||||
end_time: string;
|
||||
status: AppointmentStatus;
|
||||
amount: number;
|
||||
version: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
+84
-7
@@ -424,9 +424,35 @@ Delete a clinic.
|
||||
|
||||
## Appointment Management
|
||||
|
||||
### GET `/api/v1/admin/appointments/today-stats`
|
||||
|
||||
Get appointment statistics for a specific date (defaults to today).
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `date` | string (YYYY-MM-DD) | ❌ | Default: today |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 47,
|
||||
"completed": 20,
|
||||
"waiting": 18,
|
||||
"cancelled": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/appointments`
|
||||
|
||||
List all appointments.
|
||||
List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
@@ -434,9 +460,11 @@ List all appointments.
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 20 |
|
||||
| `search` | string | ❌ | Search by doctor/patient name |
|
||||
| `limit` | integer | ❌ | Default: 15, max: 500 |
|
||||
| `search` | string | ❌ | Search by patient name/mobile or doctor name |
|
||||
| `status` | string | ❌ | Filter by status |
|
||||
| `date` | string (YYYY-MM-DD) | ❌ | Filter by slot date |
|
||||
| `doctor_uuid` | string | ❌ | Filter by doctor UUID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
@@ -444,18 +472,67 @@ List all appointments.
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"doctor_title": "دکتر علی احمدی",
|
||||
"uuid": "appt-uuid",
|
||||
"patient_name": "محمد رضایی",
|
||||
"patient_mobile": "09123456789",
|
||||
"doctor_uuid": "doctor-uuid",
|
||||
"doctor_name": "دکتر علی احمدی",
|
||||
"slot_start": 1718438400,
|
||||
"slot_end": 1718439600,
|
||||
"appointment_date": "2025-06-15",
|
||||
"appointment_time": "09:00",
|
||||
"end_time": "09:20",
|
||||
"status": "confirmed",
|
||||
"price": 500000
|
||||
"version": 1,
|
||||
"created_at": "2025-06-14T10:30:00+03:30"
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 8540, "totalPages": 427, "currentPage": 1 }
|
||||
"meta": { "totalRecords": 47, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `pending` | `confirmed` | `completed` | `cancelled_by_doctor` | `cancelled_by_user` | `no_show` | `expired`
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/v1/admin/appointment`
|
||||
|
||||
Create a new appointment for a patient identified by mobile number.
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"doctor_uuid": "doctor-uuid",
|
||||
"slot_start": 1718438400,
|
||||
"slot_end": 1718439600,
|
||||
"patient_mobile": "09123456789",
|
||||
"note": "optional note"
|
||||
}
|
||||
```
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "appt-uuid",
|
||||
"slot_start": 1718438400,
|
||||
"slot_end": 1718439600,
|
||||
"status": "pending"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `VALIDATION` | 422 | Missing required fields |
|
||||
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
|
||||
| `USER_NOT_FOUND` | 404 | No user with that mobile number |
|
||||
| `SLOT_TAKEN` | 409 | Slot already booked |
|
||||
|
||||
---
|
||||
|
||||
## Payment Management
|
||||
|
||||
@@ -510,15 +510,84 @@ class AdminApiController extends BaseController
|
||||
|
||||
// ── Appointments ──────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/appointments/today-stats',
|
||||
summary: 'Get today appointment stats',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Date (YYYY-MM-DD), defaults to today'),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Appointment stats for the given date',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||||
new OA\Property(property: 'data', properties: [
|
||||
new OA\Property(property: 'total', type: 'integer'),
|
||||
new OA\Property(property: 'completed', type: 'integer'),
|
||||
new OA\Property(property: 'waiting', type: 'integer'),
|
||||
new OA\Property(property: 'cancelled', type: 'integer'),
|
||||
], type: 'object'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/appointments/today-stats', methods: ['GET'])]
|
||||
public function appointmentsTodayStats(Request $request): 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');
|
||||
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select('a.status, COUNT(a.id) AS cnt')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
|
||||
->setParameter('dayStart', $dayStart)
|
||||
->setParameter('dayEnd', $dayEnd)
|
||||
->groupBy('a.status')
|
||||
->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['cancelled_by_admin'] ?? 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,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/appointments',
|
||||
summary: 'List all appointments (paginated)',
|
||||
summary: 'List appointments (paginated, filterable by date and doctor)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
|
||||
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Filter by slot date (YYYY-MM-DD)'),
|
||||
new OA\Parameter(name: 'doctor_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string'), description: 'Filter by doctor UUID'),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
@@ -532,11 +601,15 @@ class AdminApiController extends BaseController
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'patient_name', type: 'string'),
|
||||
new OA\Property(property: 'patient_mobile', type: 'string'),
|
||||
new OA\Property(property: 'doctor_uuid', type: 'string'),
|
||||
new OA\Property(property: 'doctor_name', type: 'string'),
|
||||
new OA\Property(property: 'slot_start', type: 'integer', description: 'Unix timestamp'),
|
||||
new OA\Property(property: 'slot_end', type: 'integer', description: 'Unix timestamp'),
|
||||
new OA\Property(property: 'appointment_date', type: 'string', format: 'date'),
|
||||
new OA\Property(property: 'appointment_time', type: 'string', example: '14:30'),
|
||||
new OA\Property(property: 'end_time', type: 'string', example: '14:50'),
|
||||
new OA\Property(property: 'status', type: 'string'),
|
||||
new OA\Property(property: 'amount', type: 'integer'),
|
||||
new OA\Property(property: 'version', type: 'integer'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
]
|
||||
)),
|
||||
@@ -555,29 +628,41 @@ class AdminApiController extends BaseController
|
||||
#[Route('/api/v1/admin/appointments', methods: ['GET'])]
|
||||
public function appointments(Request $request): 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', ''));
|
||||
$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(
|
||||
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||
'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',
|
||||
)
|
||||
->from(Appointment::class, 'a')
|
||||
->join('a.doctor', 'd')
|
||||
->join('a.user', 'u')
|
||||
->orderBy('a.createdAt', 'DESC');
|
||||
->orderBy('a.slotStart', 'ASC');
|
||||
|
||||
if ($search !== '') {
|
||||
$qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s')
|
||||
$qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s OR u.realName LIKE :s')
|
||||
->setParameter('s', '%' . $search . '%');
|
||||
}
|
||||
if ($status !== '') {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -588,11 +673,15 @@ class AdminApiController 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'],
|
||||
'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']),
|
||||
'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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user