Files
clinicpro/assets/admin/pages/AppointmentDetailPage.tsx
T
hamedandClaude Opus 5 4bca659939 feat(admin): price lists and the appointment invoice card
Task 08's pricing chain was reachable only through the API, so a clinic could
not define a price list or see what a booked appointment was actually charged.

Price lists
- Draft / active / expired are shown as three states because they mean three
  different things operationally: a draft has no effect on today's price at all
- Activation is a separate action rather than a checkbox in the form, matching
  the backend rule that creating a list must not change anything
- "Copy" seeds a new list from an existing one starting the day the old one
  ends, since most lists are last quarter's with a few numbers moved
- "All branches" is an explicit option, not an empty field

Invoice card
- Renders the recorded chain down to the final amount, hiding zero rows so the
  card stays readable
- A missing invoice renders as a normal state, not an error: an appointment
  that was never confirmed has no invoice
- Says outright that the numbers are from the appointment's own date and later
  tariff changes do not move them — otherwise someone who edited a price
  yesterday reads today's older number as a bug

Also corrects task 08's checklist: its test section carried a copy-pasted "no
UI was built" note against rows whose tests have existed since the task
shipped. Replaced with the real test names and the two that genuinely are not
covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:58:29 +03:30

267 lines
11 KiB
TypeScript

import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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, AppointmentEvent } from '../types';
import { formatDate, formatDateTime, toDate } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import AppointmentInvoiceCard from '../components/AppointmentInvoiceCard';
import StatusBadge from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import ConfirmAppointmentModal from '../components/appointments/ConfirmAppointmentModal';
const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [
{ 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: 'منقضی' },
];
// روزِ محلیِ نوبت (YYYY-MM-DD) برای بازگشت به همان تاریخ در لیست.
const isoDay = (ts?: number | null) => {
if (!ts) return '';
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const timeOf = (ts?: number | null) => {
const d = toDate(ts ?? null);
return d
? new Intl.DateTimeFormat('fa-IR-u-nu-latn', { hour: '2-digit', minute: '2-digit', hour12: false }).format(d)
: '—';
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="cp-info-row">
<span className="cp-info-label text-sm">{label}</span>
<span className="cp-info-value">{value ?? '—'}</span>
</div>
);
}
export default function AppointmentDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const [cancelOpen, setCancelOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const [newStatus, setNewStatus] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['appointment', uuid],
queryFn: () => api.get<ApiResponse<Appointment>>(`/api/v1/appointment/${uuid}`),
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, version: appt?.version }),
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',
version: appt?.version,
...(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),
});
// پاسخ single تودرتو است: { data: { data: {...} } }
const appt: any = (data?.data as any)?.data ?? data?.data;
// قطعی‌کردن هزینه و پرداخت دارد؛ از مسیر مودال می‌رود، نه PATCH وضعیت.
function applyStatus() {
if (!newStatus) return;
if (newStatus === 'confirmed') {
setConfirmOpen(true);
return;
}
statusMutation.mutate(newStatus);
}
// بازگشت به همان روزِ نوبت (نه امروز).
const day = isoDay(appt?.slot_start);
const backTo = day ? `/admin/appointments?date=${day}` : '/admin/appointments';
return (
<div>
<PageHeader
backTo="/admin/appointments"
title="جزئیات نوبت"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نوبت‌ها', to: backTo },
{ label: 'جزئیات' },
]}
/>
{isLoading ? (
<div className="cp-card p-6 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-8 rounded-lg skeleton" />
))}
</div>
) : appt ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">اطلاعات بیمار</h3>
<InfoRow label="نام بیمار" value={appt.patient_name} />
<InfoRow label="موبایل" value={<span dir="ltr">{appt.patient_mobile}</span>} />
<InfoRow label="پزشک" value={appt.doctor?.name ?? null} />
<InfoRow label="تاریخ نوبت" value={formatDate(appt.slot_start)} />
<InfoRow label="ساعت شروع" value={timeOf(appt.slot_start)} />
<InfoRow label="ساعت پایان" value={timeOf(appt.slot_end)} />
{appt.patient_reason && <InfoRow label="علت مراجعه" value={appt.patient_reason} />}
{appt.note && <InfoRow label="توضیحات" value={appt.note} />}
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
</div>
<AppointmentInvoiceCard appointmentUuid={appt.uuid} />
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
<div className="mb-4">
<p className="text-sm text-[var(--text-2)] mb-2">وضعیت فعلی:</p>
<StatusBadge type="appointment" value={appt.status} />
</div>
{appt.status === 'pending' && (
<button
onClick={() => setConfirmOpen(true)}
className="btn primary w-full"
>
قطعی کردن نوبت
</button>
)}
<div className="mt-6">
<label className="cp-label mb-2">تغییر وضعیت:</label>
<div className="flex gap-2">
<div style={{ flex: 1 }}>
<SearchableSelect
options={ALL_STATUSES.map(s => ({ value: s.value, label: s.label }))}
value={newStatus || null}
onChange={(v) => setNewStatus(v ? String(v) : '')}
placeholder="انتخاب وضعیت..."
isClearable
/>
</div>
<button
onClick={() => applyStatus()}
disabled={!newStatus || statusMutation.isPending}
className="btn primary sm"
>
اعمال
</button>
</div>
</div>
<div className="mt-4 pt-4 border-t border-[var(--border)]">
<button
onClick={() => setCancelOpen(true)}
className="w-full py-2 border border-[var(--danger)] text-[var(--danger)] text-sm rounded-[10px] hover:bg-[var(--danger-bg)] transition-colors"
>
لغو نوبت
</button>
</div>
</div>
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6 lg:col-span-2">
<h3 className="font-semibold text-[var(--text)] mb-4">تاریخچه رویدادها</h3>
{eventsQuery.isLoading ? (
<div className="h-6 w-40 rounded skeleton" />
) : events.length === 0 ? (
<p className="text-sm text-[var(--text-3)]">رویدادی برای این نوبت ثبت نشده است.</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-[var(--danger)] shrink-0" />
<div className="flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-[var(--text)]">{ev.title}</span>
<span className="text-xs text-[var(--text-3)]">{formatDateTime(ev.created_at)}</span>
</div>
{ev.actor_name && (
<div className="text-xs text-[var(--text-2)] mt-0.5">توسط: {ev.actor_name}</div>
)}
{ev.reason && (
<div className="text-xs text-[var(--text-2)] mt-0.5">دلیل: {ev.reason}</div>
)}
</div>
</li>
))}
</ol>
)}
</div>
</div>
) : (
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] p-16 text-center text-[var(--text-3)]">
نوبتی یافت نشد
</div>
)}
<ConfirmDialog
open={cancelOpen}
title="لغو نوبت"
message="آیا از لغو این نوبت اطمینان دارید؟"
confirmLabel="لغو نوبت"
danger
loading={cancelMutation.isPending}
onConfirm={() => cancelMutation.mutate()}
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>
<ConfirmAppointmentModal
open={confirmOpen}
appointmentUuid={uuid!}
appointment={appt}
onClose={() => setConfirmOpen(false)}
queryKey={['appointment', uuid]}
/>
</div>
);
}