refactor(admin): restructure the new-appointment modal around its real steps

The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.

- Group the form into numbered steps (service+time, patient) so the order of
  decisions is visible. The optional visit-price collapse stays unnumbered —
  numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
  disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
  appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
  `.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
  the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
  fall through to "not enough free time", which sent users to another day for
  no reason.
- Raise the service remove button (18px), the duration pill and the time chips
  to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
  full 40px box so the whole frame is clickable.

Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.

The redesign-page probe now names the offending elements instead of only
counting them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 12:08:25 +03:30
co-authored by Claude Opus 5
parent 1f4f7e6927
commit 103d913cd0
6 changed files with 266 additions and 90 deletions
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon } from '@heroicons/react/24/outline';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../../lib/utils';
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils';
import { useAuthStore } from '../../stores/authStore';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
@@ -184,6 +185,34 @@ export default function NewAppointmentModal({
setMobile(''); setNationalCode('');
}
// هدفِ نوبت به‌صورت «برچسب: مقدار» — دو اسمِ لخت کنار هم معلوم نمی‌کند کدام دستگاه
// است و کدام پزشک. تاریخ هم اینجاست چون تنها جای مودال بود که اصلاً دیده نمی‌شد.
const targetFacts: { label: string; value: string }[] = [
resource
? { label: 'دستگاه', value: resource.name }
: pickerMode
? { label: 'پزشک', value: slot.doctor_name }
: { label: 'ساعت', value: `${slot.start_time} تا ${slot.end_time}` },
...((resource || !pickerMode) && slot.doctor_name
? [{ label: resource ? 'پزشک ناظر' : 'پزشک', value: slot.doctor_name }]
: []),
...(date ? [{ label: 'تاریخ', value: formatDate(date) }] : []),
];
// اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ
// بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است.
const blockReason = !serviceTimingValid
? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید')
: lookup === null
? 'ابتدا بیمار را جستجو کنید'
: needsDetails && !detailsValid
? 'مشخصات بیمار را کامل کنید'
: !mobileValid
? 'شماره موبایل بیمار معتبر نیست'
: !visitPriceValid
? 'هزینه ویزیت الزامی است'
: null;
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
@@ -211,24 +240,22 @@ export default function NewAppointmentModal({
>
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
{resource
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
{resource ? resource.name : pickerMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
</span>
{(resource || !pickerMode) && slot.doctor_name && (
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
{slot.doctor_name}
{targetFacts.map(f => (
<span key={f.label} style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>{f.label}: </span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{f.value}</span>
</span>
)}
))}
</div>
{pickerMode && date && (
<div style={{ marginBottom: 18 }}>
<Step n={1} title="خدمت و زمان">
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
resourceUuid={resource?.uuid}
@@ -237,65 +264,71 @@ export default function NewAppointmentModal({
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
</Step>
)}
<div className="field-block" style={{ marginBottom: 14 }}>
<label>جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<Step n={pickerMode ? 2 : null} title="بیمار">
<div className="field-block" style={{ marginBottom: 14 }}>
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={searchBy === mode ? 'on' : ''}
aria-pressed={searchBy === mode}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1, justifyContent: 'center' }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
id="appt-patient-search"
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus={!pickerMode}
/>
) : (
<input
id="appt-patient-search"
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
// در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار
// اینجا کاربر را از مرحلهٔ یک رد می‌کرد.
autoFocus={!pickerMode}
/>
)}
</div>
<button
key={mode}
type="button"
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1 }}
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus
/>
) : (
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
autoFocus
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
@@ -321,9 +354,10 @@ export default function NewAppointmentModal({
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
<label htmlFor="appt-patient-name">نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-name"
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
@@ -334,9 +368,10 @@ export default function NewAppointmentModal({
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>شماره موبایل بیمار <span className="req">*</span></label>
<label htmlFor="appt-patient-mobile">شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-mobile"
type="tel"
inputMode="numeric"
maxLength={11}
@@ -351,9 +386,10 @@ export default function NewAppointmentModal({
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>کد ملی بیمار <span className="req">*</span></label>
<label htmlFor="appt-patient-national">کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
id="appt-patient-national"
type="text"
inputMode="numeric"
lang="en"
@@ -368,7 +404,10 @@ export default function NewAppointmentModal({
)}
</>
)}
</Step>
{/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و
شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
<div className="field-block">
{requireVisit ? (
<label>
@@ -381,9 +420,11 @@ export default function NewAppointmentModal({
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
// سرِ کلپس تمام عرض و ۳۶px است: با padding صفر ارتفاعش اندازهٔ یک خط متن
// می‌شد و روی موبایل عملاً قابل زدن نبود.
display: 'flex', alignItems: 'center', gap: 6, width: '100%', minHeight: 36,
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)',
padding: 0, color: 'var(--text)', textAlign: 'start',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
@@ -424,6 +465,38 @@ export default function NewAppointmentModal({
</>
)}
</div>
{blockReason && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 16,
fontSize: 12.5, color: 'var(--text-3)',
}}>
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
برای ثبت نوبت: {blockReason}
</div>
)}
</Modal>
);
}
/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */
function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) {
return (
<section style={{ marginBottom: 18 }}>
<h3 style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px',
fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
}}>
{/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */}
{n !== null && (
<span style={{
display: 'grid', placeItems: 'center', width: 20, height: 20, borderRadius: 999,
background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 11.5, flexShrink: 0,
}}>{n}</span>
)}
{title}
</h3>
{children}
</section>
);
}