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:
hamed
2026-06-11 14:13:42 +03:30
parent 92a258832b
commit 45ee725820
9 changed files with 1241 additions and 636 deletions
@@ -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',
};
+3 -8
View File
@@ -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 }> = {