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>
267 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|