feat(appointment): log cancellation events and show them in a Timeline
Introduce the first per-appointment event system. On cancel (via the status
or general update endpoints) an AppointmentEvent (type=cancelled, «نوبت لغو
شد») is recorded with the actor, cancel time, and an optional cancel_reason,
plus a warning-level app_log entry. New GET /appointment/{uuid}/events
returns the ordered event list. The admin appointment detail page renders a
Timeline section and the cancel dialog now collects an optional reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment, AppointmentStatus } from '../types';
|
||||
import type { Appointment, AppointmentStatus, AppointmentEvent } from '../types';
|
||||
import { formatDate, formatDateTime, toDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
@@ -50,6 +50,7 @@ export default function AppointmentDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -58,22 +59,35 @@ export default function AppointmentDetailPage() {
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['appointment-events', uuid],
|
||||
queryFn: () => api.get<ApiResponse<AppointmentEvent[]>>(`/api/v1/appointment/${uuid}/events`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const events: AppointmentEvent[] = (eventsQuery.data?.data as any) ?? [];
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) =>
|
||||
api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status }),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت نوبت بروزرسانی شد');
|
||||
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['appointment-events', uuid] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, { status: 'cancelled_by_doctor' }),
|
||||
mutationFn: () => api.patch<ApiResponse<null>>(`/api/v1/appointment/${uuid}/status`, {
|
||||
status: 'cancelled_by_doctor',
|
||||
...(cancelReason.trim() ? { cancel_reason: cancelReason.trim() } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت لغو شد');
|
||||
setCancelOpen(false);
|
||||
setCancelReason('');
|
||||
qc.invalidateQueries({ queryKey: ['appointment', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['appointment-events', uuid] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
@@ -161,6 +175,35 @@ export default function AppointmentDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 lg:col-span-2">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">تاریخچه رویدادها</h3>
|
||||
{eventsQuery.isLoading ? (
|
||||
<div className="h-6 w-40 rounded skeleton" />
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">رویدادی برای این نوبت ثبت نشده است.</p>
|
||||
) : (
|
||||
<ol className="space-y-4">
|
||||
{events.map((ev, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<span className="mt-1.5 w-2.5 h-2.5 rounded-full bg-red-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-800">{ev.title}</span>
|
||||
<span className="text-xs text-gray-400">{formatDateTime(ev.created_at)}</span>
|
||||
</div>
|
||||
{ev.actor_name && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">توسط: {ev.actor_name}</div>
|
||||
)}
|
||||
{ev.reason && (
|
||||
<div className="text-xs text-gray-600 mt-0.5">دلیل: {ev.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
@@ -176,8 +219,20 @@ export default function AppointmentDetailPage() {
|
||||
danger
|
||||
loading={cancelMutation.isPending}
|
||||
onConfirm={() => cancelMutation.mutate()}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
/>
|
||||
onCancel={() => { setCancelOpen(false); setCancelReason(''); }}
|
||||
>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="cp-label mb-2">دلیل لغو (اختیاری)</label>
|
||||
<textarea
|
||||
className="cp-input"
|
||||
rows={2}
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="دلیل لغو نوبت را وارد کنید..."
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user