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:
@@ -4,6 +4,7 @@ import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
import { useClinicContext } from '../../hooks/useClinicContext';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import DigitInput from '../ui/DigitInput';
|
||||
|
||||
@@ -118,7 +119,7 @@ export default function ServiceSlotPicker({
|
||||
return (
|
||||
<div>
|
||||
{/* انتخاب بخش */}
|
||||
<label style={label}>بخش</label>
|
||||
<label style={label} htmlFor="service-mode-section-select">بخش</label>
|
||||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
||||
<SearchableSelect
|
||||
inputId="service-mode-section-select"
|
||||
@@ -143,9 +144,10 @@ export default function ServiceSlotPicker({
|
||||
const active = selected.some(p => p.uuid === s.uuid);
|
||||
return (
|
||||
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
||||
role="checkbox" aria-checked={active}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
minHeight: 40, padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
@@ -190,7 +192,7 @@ export default function ServiceSlotPicker({
|
||||
{editableDuration ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
|
||||
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
||||
height: 36, padding: '0 8px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface)', border: '1px solid var(--border-2)',
|
||||
}}>
|
||||
<DigitInput
|
||||
@@ -205,12 +207,12 @@ export default function ServiceSlotPicker({
|
||||
) : (
|
||||
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
||||
)}
|
||||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{
|
||||
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
|
||||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: 'var(--on-primary)',
|
||||
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
|
||||
}}>×</button>
|
||||
{/* هدف کلیک ۳۲px است نه ۱۸px — این دکمه انتخابِ کاربر را پاک میکند و
|
||||
خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */}
|
||||
<button type="button" className="mini-btn" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{ flexShrink: 0, color: 'var(--primary-700)' }}>
|
||||
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -223,6 +225,13 @@ export default function ServiceSlotPicker({
|
||||
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
||||
{slotsQ.isLoading ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
|
||||
) : slotsQ.isError ? (
|
||||
/* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه میشد — یعنی کاربر
|
||||
روز درست را کنار میگذاشت، چون پاسخ دروغ بود. */
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--danger)' }}>خواندن زمانهای خالی ناموفق بود.</span>
|
||||
<button type="button" className="btn ghost sm" onClick={() => slotsQ.refetch()}>تلاش دوباره</button>
|
||||
</div>
|
||||
) : startTimes.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
|
||||
@@ -233,9 +242,10 @@ export default function ServiceSlotPicker({
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button key={s.start} type="button" dir="ltr"
|
||||
aria-pressed={active}
|
||||
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
||||
style={{
|
||||
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, minHeight: 36, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? 'var(--on-primary)' : 'var(--text)',
|
||||
}}>
|
||||
|
||||
Reference in New Issue
Block a user