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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user