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:
@@ -188,6 +188,32 @@ async function shot(url, opts) {
|
||||
await S('Page.navigate', { url });
|
||||
await new Promise((r) => setTimeout(r, opts.wait));
|
||||
|
||||
// مودالها فقط با تعامل باز میشوند و بدون این، نقدشان ممکن نیست: --click یک
|
||||
// متنِ دیدنی یا سلکتور میگیرد، اولین تطابق را میزند و منتظر رندر میماند.
|
||||
if (opts.click) {
|
||||
const clicked = await S('Runtime.evaluate', {
|
||||
returnByValue: true,
|
||||
expression: `(() => {
|
||||
const q = ${JSON.stringify(opts.click)};
|
||||
let el = null;
|
||||
try { el = document.querySelector(q); } catch {}
|
||||
if (!el) {
|
||||
// دکمه بر لینک مقدم است: نامِ یکسان معمولاً هم در سایدبار (a) هست هم
|
||||
// روی خودِ صفحه (button)، و منظورِ نقد همیشه دومی است.
|
||||
const hits = [...document.querySelectorAll('button,[role=button],a,td,.slot,.tl-slot')]
|
||||
.filter((n) => (n.innerText || '').trim().includes(q) && n.offsetParent !== null);
|
||||
el = hits.find((n) => n.closest('nav,.sidebar') === null) ?? hits[0];
|
||||
}
|
||||
if (!el) return 'not found: ' + q;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
el.click();
|
||||
return 'clicked: ' + (el.innerText || el.className || el.tagName).slice(0, 60);
|
||||
})()`,
|
||||
});
|
||||
console.log(' CLICK', clicked?.result?.value ?? '—');
|
||||
await new Promise((r) => setTimeout(r, opts.clickWait ?? 1800));
|
||||
}
|
||||
|
||||
// تم بعد از hydrate ممکن است از استور دوباره خوانده شود؛ آخرین کلام با ما.
|
||||
await S('Runtime.evaluate', {
|
||||
expression: `
|
||||
@@ -244,19 +270,33 @@ async function probeRuntime(S) {
|
||||
out.push('horizontal scroll: page is ' + document.documentElement.scrollWidth
|
||||
+ 'px wide in a ' + window.innerWidth + 'px viewport');
|
||||
}
|
||||
// شمارش تنها میگوید «۲ تا»، نه «کدام دو تا» — و حدس زدنش وقت تلف کردن است.
|
||||
const where = (el) => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
|
||||
const txt = (el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
||||
const near = el.closest('[class]');
|
||||
return tag + (el.id ? '#' + el.id : '') + (cls.length ? '.' + cls.join('.') : '')
|
||||
+ (txt ? ' «' + txt + '»' : '')
|
||||
+ (near && near !== el && typeof near.className === 'string'
|
||||
? ' ← in .' + near.className.trim().split(/\s+/)[0] : '');
|
||||
};
|
||||
const list = (arr) => arr.map(where).join(' · ');
|
||||
|
||||
const nameless = [...document.querySelectorAll('button, a[role="button"]')]
|
||||
.filter(b => !(b.innerText || '').trim()
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title')).length;
|
||||
if (nameless) out.push(nameless + ' icon-only control(s) with no accessible name');
|
||||
&& !b.getAttribute('aria-label') && !b.getAttribute('title'));
|
||||
if (nameless.length) out.push(nameless.length + ' icon-only control(s) with no accessible name\n ' + list(nameless));
|
||||
const unlabelled = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
|
||||
.filter(i => !i.getAttribute('aria-label') && !i.getAttribute('aria-labelledby')
|
||||
&& !(i.id && document.querySelector('label[for="' + i.id + '"]'))
|
||||
&& !i.closest('label')).length;
|
||||
if (unlabelled) out.push(unlabelled + ' form field(s) with no label');
|
||||
&& !i.closest('label'));
|
||||
if (unlabelled.length) out.push(unlabelled.length + ' form field(s) with no label\n ' + list(unlabelled));
|
||||
const tiny = [...document.querySelectorAll('button, a')]
|
||||
.filter(b => { const r = b.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; }).length;
|
||||
if (tiny) out.push(tiny + ' control(s) under 32px tall (44px is the touch target)');
|
||||
return r.width > 0 && r.height > 0 && r.height < 32; });
|
||||
if (tiny.length) out.push(tiny.length + ' control(s) under 32px tall (44px is the touch target)\n '
|
||||
+ tiny.map(b => where(b) + ' [' + Math.round(b.getBoundingClientRect().height) + 'px]').join(' · '));
|
||||
return out;
|
||||
})()`,
|
||||
});
|
||||
@@ -266,7 +306,7 @@ async function probeRuntime(S) {
|
||||
}
|
||||
|
||||
/** چهار نمای اجباریِ هر بازطراحی: روشن، تیره، فشرده، موبایل. */
|
||||
async function variants(url, dir) {
|
||||
async function variants(url, dir, extra = {}) {
|
||||
const slug = new URL(url).pathname.replace(/^\/admin\/?/, '').replace(/\W+/g, '-') || 'page';
|
||||
const runs = [
|
||||
{ name: 'light', w: 1440, h: 900, theme: 'light', density: 'comfortable' },
|
||||
@@ -280,7 +320,7 @@ async function variants(url, dir) {
|
||||
await shot(url, {
|
||||
out: `${dir}/${slug}-${r.name}.png`,
|
||||
w: r.w, h: r.h, wait: 5000, full: true, probe: true,
|
||||
theme: r.theme, density: r.density, context: null,
|
||||
theme: r.theme, density: r.density, context: null, ...extra,
|
||||
});
|
||||
}
|
||||
console.log(`\nنگاه کردن به هر چهار فایل اجباری است: ${dir}/${slug}-*.png`);
|
||||
@@ -408,9 +448,14 @@ if (cmd === 'shot' && arg) {
|
||||
theme: flag('theme', 'light'),
|
||||
density: flag('density', 'comfortable'),
|
||||
context: flag('context', null),
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'variants' && arg) {
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'));
|
||||
await variants(arg, flag('dir', '/tmp/clinicpro-review'), {
|
||||
click: flag('click', null),
|
||||
clickWait: Number(flag('click-wait', 1800)),
|
||||
});
|
||||
} else if (cmd === 'inspect' && arg) {
|
||||
inspect(arg);
|
||||
} else if (cmd === 'audit' && arg) {
|
||||
|
||||
@@ -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}`}
|
||||
{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>
|
||||
{(resource || !pickerMode) && slot.doctor_name && (
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
|
||||
{slot.doctor_name}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pickerMode && date && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<Step n={1} title="خدمت و زمان">
|
||||
<ServiceSlotPicker
|
||||
doctorUuid={slot.doctor_uuid}
|
||||
resourceUuid={resource?.uuid}
|
||||
@@ -237,20 +264,22 @@ export default function NewAppointmentModal({
|
||||
onSelect={setPick}
|
||||
clinicUuidOverride={clinicUuid}
|
||||
/>
|
||||
</div>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
<Step n={pickerMode ? 2 : null} title="بیمار">
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>جستجوی بیمار <span className="req">*</span></label>
|
||||
<label htmlFor="appt-patient-search">جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<div className="seg" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
{(['national', 'mobile'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
||||
className={searchBy === mode ? 'on' : ''}
|
||||
aria-pressed={searchBy === mode}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1 }}
|
||||
style={{ flex: 1, justifyContent: 'center' }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
@@ -260,6 +289,7 @@ export default function NewAppointmentModal({
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
id="appt-patient-search"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
@@ -268,10 +298,11 @@ export default function NewAppointmentModal({
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
autoFocus={!pickerMode}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id="appt-patient-search"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
@@ -281,7 +312,9 @@ export default function NewAppointmentModal({
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
// در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار
|
||||
// اینجا کاربر را از مرحلهٔ یک رد میکرد.
|
||||
autoFocus={!pickerMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ function mockApi() {
|
||||
async function pickServiceAndTime() {
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /کرایوتراپی/ }));
|
||||
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
|
||||
// «انتخابشده/نشده» اعلام شود.
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
||||
}
|
||||
|
||||
@@ -114,6 +116,49 @@ describe('مودال ثبت نوبتِ منبع', () => {
|
||||
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('دلیلِ غیرفعال بودنِ ثبت را میگوید و با پیشرفتِ فرم عوض میشود', async () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
|
||||
|
||||
// سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
|
||||
expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
|
||||
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('تاریخ نوبت بهصورت جلالی بالای فرم میآید', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
// بدون این، کاربر روزِ در حال رزرو را هیچجای مودال نمیدید.
|
||||
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
|
||||
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('معیار جستجو یک seg با حالتِ اعلامشده است', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
);
|
||||
|
||||
const national = screen.getByRole('button', { name: 'کد ملی' });
|
||||
const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
|
||||
expect(national).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(mobile).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(mobile);
|
||||
expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('منبعِ بدون سرویس، پیام راهنما میدهد نه فهرست خالی', () => {
|
||||
renderWithProviders(
|
||||
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
|
||||
|
||||
@@ -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)',
|
||||
}}>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function Modal({ open, title, size = 'md', onClose, children, foo
|
||||
>
|
||||
<div className="modal-head">
|
||||
<h2>{title}</h2>
|
||||
<button type="button" className="mini-btn" onClick={onClose}>
|
||||
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
|
||||
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -649,7 +649,10 @@ table.t tbody tr:last-child td { border-bottom: none; }
|
||||
transition: .15s;
|
||||
}
|
||||
.field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 4px var(--ring); }
|
||||
.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; color: var(--text); font-family: inherit; font-size: 14px; }
|
||||
/* کنترل باید کل ارتفاع ۴۰px قاب را بگیرد: هم هدف لمسی کامل میشود، هم کلیک روی
|
||||
حاشیهٔ داخلیِ قاب فوکوس میدهد. بدون این، خودِ input حدود ۲۱px است. */
|
||||
.field input, .field select, .field textarea { border: none; outline: none; background: none; flex: 1; min-width: 60px; align-self: stretch; color: var(--text); font-family: inherit; font-size: 14px; }
|
||||
.field textarea { align-self: auto; }
|
||||
.seg { display: inline-flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
|
||||
/* `a` هم پذیرفته میشود: نوار تبهایی که بین صفحهها جابهجا میکنند باید لینک واقعی باشند
|
||||
(باز کردن در تب جدید، کلیک وسط)، نه دکمهای که navigate صدا میزند. */
|
||||
|
||||
Reference in New Issue
Block a user