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 صدا می‌زند. */