feat(stepper): implement multi-step wizard for appointment confirmation process

This commit is contained in:
hamed
2026-08-09 09:12:00 +03:30
parent 948f59827a
commit 3ffab2bbd0
7 changed files with 544 additions and 81 deletions
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
CpuChipIcon, MagnifyingGlassIcon, XMarkIcon,
CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon,
} from '@heroicons/react/24/outline';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import StatusBadge from '../ui/StatusBadge';
import TreatmentCaseEditModal from '../TreatmentCaseEditModal';
import Pagination from '../ui/Pagination';
import NewAppointmentModal from '../appointments/NewAppointmentModal';
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
@@ -33,6 +35,15 @@ interface PlanResponse {
sessions: PlanSession[];
}
/**
* آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بک‌اند.
*
* قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را می‌گیرد که جوابش از پیش معلوم
* است — دکمه‌ای که می‌دانیم ۴۲۲ می‌گیرد نباید فعال بماند.
*/
const MIN_SESSIONS = 2;
const MAX_SESSIONS = 60;
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
active: 'در جریان',
completed: 'تمام شده',
@@ -208,8 +219,36 @@ function CaseDetail({ summary, view, onView, onBack }: {
}) {
const [term, setTerm] = useState('');
const [page, setPage] = useState(1);
const [editing, setEditing] = useState(false);
const qc = useQueryClient();
useEffect(() => setPage(1), [term, view]);
/**
* کم/زیاد کردن جلسات همین دوره.
*
* پیش از این فقط از صفحهٔ «دوره‌های درمان» و پشت یک مودال ممکن بود، در حالی که
* تصمیمش سرِ پروندهٔ بیمار گرفته می‌شود. سرور جلسهٔ انجام‌شده یا نوبت‌دار را حذف
* نمی‌کند و ۴۰۹ برمی‌گرداند؛ پیام همان خطا نشان داده می‌شود چون دلیلش را دقیق‌تر
* از هر متن ثابتی می‌گوید (چند جلسه قفل است).
*/
const setTotalSessions = useMutation({
mutationFn: (total: number) =>
api.patch<ApiResponse<unknown>>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
},
onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'),
});
const total = summary.total_sessions;
const completed = summary.completed_sessions;
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
const busy = setTotalSessions.isPending;
// کفِ واقعی، جلسات قفل‌شده است؛ سرور همان را با ۴۰۹ می‌گوید ولی دکمهٔ فعالِ
// بی‌اثر بدتر از دکمهٔ خاموش است.
const minAllowed = Math.max(MIN_SESSIONS, completed);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['treatment-case-plan', summary.uuid],
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
@@ -234,9 +273,75 @@ function CaseDetail({ summary, view, onView, onBack }: {
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
</span>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
</span>
<button
type="button"
className="btn secondary sm"
style={{ marginInlineStart: 'auto' }}
onClick={() => setEditing(true)}
>
<PencilSquareIcon style={{ width: 15, height: 15 }} />
ویرایش دوره
</button>
</div>
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته می‌شود، پس همین‌جا هم تغییر می‌کند. */}
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, fontWeight: 700 }}>تعداد جلسات</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="mini-btn"
aria-label="کم کردن یک جلسه"
disabled={busy || total <= minAllowed}
onClick={() => setTotalSessions.mutate(total - 1)}
>
<MinusIcon style={{ width: 15, height: 15 }} />
</button>
<span
aria-live="polite"
style={{ minWidth: 32, textAlign: 'center', fontSize: 15, fontWeight: 700 }}
>
{formatNumber(total)}
</span>
<button
type="button"
className="mini-btn"
aria-label="افزودن یک جلسه"
disabled={busy || total >= MAX_SESSIONS}
onClick={() => setTotalSessions.mutate(total + 1)}
>
<PlusIcon style={{ width: 15, height: 15 }} />
</button>
</div>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
</span>
</div>
<div
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={total}
aria-label={`پیشرفت دوره: ${completed} از ${total}`}
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
>
<div style={{
width: `${percent}%`, height: '100%',
background: summary.status === 'completed' ? 'var(--success)' : 'var(--primary)',
}} />
</div>
{total <= minAllowed && (
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
{completed >= total
? 'همهٔ جلسه‌ها انجام شده‌اند؛ کم کردن ممکن نیست.'
: `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`}
</span>
)}
</div>
<div className="seg" style={{ alignSelf: 'start' }}>
@@ -301,6 +406,10 @@ function CaseDetail({ summary, view, onView, onBack }: {
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
</div>
)}
{editing && (
<TreatmentCaseEditModal caseUuid={summary.uuid} onClose={() => setEditing(false)} />
)}
</div>
);
}