- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
270 lines
11 KiB
TypeScript
270 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 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
|
|
title="جزئیات نوبت"
|
|
breadcrumbs={[
|
|
{ label: 'داشبورد', to: '/admin/dashboard' },
|
|
{ label: 'نوبتها', to: backTo },
|
|
{ label: 'جزئیات' },
|
|
]}
|
|
action={
|
|
<button onClick={() => navigate(backTo)}
|
|
className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
|
<ArrowRightIcon className="w-4 h-4" />
|
|
بازگشت
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{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-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
<h3 className="font-semibold text-gray-800 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>
|
|
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
|
<h3 className="font-semibold text-gray-800 mb-4">وضعیت و اقدامات</h3>
|
|
<div className="mb-4">
|
|
<p className="text-sm text-gray-500 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-gray-100">
|
|
<button
|
|
onClick={() => setCancelOpen(true)}
|
|
className="w-full py-2 border border-red-300 text-red-600 text-sm rounded-[10px] hover:bg-red-50 transition-colors"
|
|
>
|
|
لغو نوبت
|
|
</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">
|
|
نوبتی یافت نشد
|
|
</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>
|
|
);
|
|
}
|