From 103d913cd03106b6b46e912ad54f4d8fe2312a4d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 12:08:25 +0330 Subject: [PATCH] refactor(admin): restructure the new-appointment modal around its real steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/redesign-page/driver.mjs | 63 +++++- .../appointments/NewAppointmentModal.tsx | 209 ++++++++++++------ .../ResourceBookingModal.test.tsx | 47 +++- .../appointments/ServiceSlotPicker.tsx | 30 ++- assets/admin/components/ui/Modal.tsx | 2 +- assets/admin/styles.css | 5 +- 6 files changed, 266 insertions(+), 90 deletions(-) diff --git a/.claude/skills/redesign-page/driver.mjs b/.claude/skills/redesign-page/driver.mjs index 6d09d961..8a640495 100644 --- a/.claude/skills/redesign-page/driver.mjs +++ b/.claude/skills/redesign-page/driver.mjs @@ -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) { diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx index efc02f2e..d027fce7 100644 --- a/assets/admin/components/appointments/NewAppointmentModal.tsx +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -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({ > {/* هدف نوبت: منبع، یا اسلات/پزشک */}
{resource ? : } - - {resource ? resource.name : pickerMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`} - - {(resource || !pickerMode) && slot.doctor_name && ( - - {slot.doctor_name} + {targetFacts.map(f => ( + + {f.label}: + {f.value} - )} + ))}
{pickerMode && date && ( -
+ -
+ )} -
- - {/* انتخاب معیار جستجو: موبایل یا کد ملی */} -
- {(['national', 'mobile'] as const).map(mode => ( + +
+ + {/* انتخاب معیار جستجو: موبایل یا کد ملی */} +
+ {(['national', 'mobile'] as const).map(mode => ( + + ))} +
+
+
+ {searchBy === 'mobile' ? ( + onMobileChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} + placeholder="مثال: 09123456789" + style={{ direction: 'ltr' }} + autoFocus={!pickerMode} + /> + ) : ( + onNationalSearchChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} + placeholder="کد ملی ۱۰ رقمی" + style={{ direction: 'ltr' }} + // در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار + // اینجا کاربر را از مرحلهٔ یک رد می‌کرد. + autoFocus={!pickerMode} + /> + )} +
- ))} -
-
-
- {searchBy === 'mobile' ? ( - onMobileChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} - placeholder="مثال: 09123456789" - style={{ direction: 'ltr' }} - autoFocus - /> - ) : ( - onNationalSearchChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} - placeholder="کد ملی ۱۰ رقمی" - style={{ direction: 'ltr' }} - autoFocus - /> - )}
-
-
{foundWithNationalCode && (
- +
setPatientName(e.target.value)} @@ -334,9 +368,10 @@ export default function NewAppointmentModal({ {/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */} {searchBy === 'national' && (
- +
- +
)} + + {/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و + شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
{requireVisit ? (
+ + {blockReason && ( +
+ + برای ثبت نوبت: {blockReason} +
+ )} ); } + +/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */ +function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) { + return ( +
+

+ {/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */} + {n !== null && ( + {n} + )} + {title} +

+ {children} +
+ ); +} diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx index aac03463..4da9e3f9 100644 --- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -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( + {}} 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( + {}} onSuccess={() => {}} />, + ); + + // بدون این، کاربر روزِ در حال رزرو را هیچ‌جای مودال نمی‌دید. + expect(screen.getByText('تاریخ:')).toBeInTheDocument(); + expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument(); + }); + + it('معیار جستجو یک seg با حالتِ اعلام‌شده است', () => { + renderWithProviders( + {}} 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( {}} onSuccess={() => {}} />, diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx index 1fbd5e5c..b4115ed1 100644 --- a/assets/admin/components/appointments/ServiceSlotPicker.tsx +++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx @@ -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 (
{/* انتخاب بخش */} - +
p.uuid === s.uuid); return ( + {/* هدف کلیک ۳۲px است نه ۱۸px — این دکمه انتخابِ کاربر را پاک می‌کند و + خطا زدنش روی موبایل یعنی حذف ناخواستهٔ سرویس. */} +
))}
@@ -223,6 +225,13 @@ export default function ServiceSlotPicker({ {slotsQ.isLoading ? (
در حال محاسبه...
+ ) : slotsQ.isError ? ( + /* بدون این شاخه، خطای سرور به «زمان خالی نیست» ترجمه می‌شد — یعنی کاربر + روز درست را کنار می‌گذاشت، چون پاسخ دروغ بود. */ +
+ خواندن زمان‌های خالی ناموفق بود. + +
) : startTimes.length === 0 ? (
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید. @@ -233,9 +242,10 @@ export default function ServiceSlotPicker({ const active = pickedSlot?.start === s.start; return (
diff --git a/assets/admin/styles.css b/assets/admin/styles.css index 026b7235..4f4eac2a 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -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 صدا می‌زند. */