fix(admin): correct two design-system mismatches found by looking at the pages

Screenshotting the pages under dark mode and compact density (rather than
trusting that design tokens were enough) turned up two mistakes repeated across
every page this feature set added:

- `.card` carries only the surface, border and radius — padding comes from the
  separate `.card-pad`. Fifteen cards were rendering with their content flush
  against the edges.
- `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a
  control in it produced a joined addon rather than a label above its field.
  `.field-block` is the label-above layout, and thirty-seven wrappers now use it.

Both were invisible to type-checking and to the tests, which is exactly why the
visual pass was worth running. Numbers in the new UI now go through
formatNumber so they render as Persian digits, and the utilization page's
header no longer repeats the sentence that appears under its filters verbatim.

The QA driver gained a `--ui` flag: theme and density live in
localStorage['clinicpro-ui'], so without seeding them dark mode and compact
density cannot be screenshotted at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 21:42:11 +03:30
co-authored by Claude Opus 5
parent 29148b6bd3
commit 635bf3d2a8
19 changed files with 86 additions and 74 deletions
@@ -37,7 +37,8 @@ describe('AppointmentSegmentsCard', () => {
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />);
await waitFor(() => expect(screen.getByText('60 دقیقه')).toBeInTheDocument());
// ارقام فارسی‌اند: ۲۰ + ۴۰ = ۶۰
await waitFor(() => expect(screen.getByText('۶۰ دقیقه')).toBeInTheDocument());
});
/** ⭐ مدتی که بیمار روی صندلی نیست باید دیده شود، وگرنه «۹۰ دقیقه» گمراه‌کننده است. */
@@ -1,5 +1,6 @@
import React from 'react';
import { useAppointmentSegments } from '../hooks/useResourceBooking';
import { formatNumber } from '../lib/utils';
const timeOf = (ts: number) =>
new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
@@ -23,14 +24,14 @@ export default function AppointmentSegmentsCard({ appointmentUuid }: { appointme
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-[var(--text)]">بخشهای نوبت</h3>
<span className="text-sm text-[var(--text-2)]">{total} دقیقه</span>
<span className="text-sm text-[var(--text-2)]">{formatNumber(total)} دقیقه</span>
</div>
<div style={{ display: 'flex', gap: 2, marginBottom: 14 }}>
{segments.map((s) => (
<div
key={s.sequence}
title={`${s.name}${s.duration_minutes} دقیقه`}
title={`${s.name}${formatNumber(s.duration_minutes)} دقیقه`}
style={{
flex: s.duration_minutes,
height: 10,
@@ -45,7 +46,7 @@ export default function AppointmentSegmentsCard({ appointmentUuid }: { appointme
<ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{segments.map((s) => (
<li key={s.sequence} style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: 'var(--text-3)', minWidth: 18 }}>{s.sequence}</span>
<span style={{ color: 'var(--text-3)', minWidth: 18 }}>{formatNumber(s.sequence)}</span>
<span style={{ flex: 1 }}>{s.name}</span>
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
{timeOf(s.starts_at)} {timeOf(s.ends_at)}
@@ -89,7 +89,7 @@ export default function ResourceBlocksModal({ resourceUuid, resourceName, onClos
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field" style={{ minWidth: 170, margin: 0 }}>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>روز</label>
<PersianDateInput value={day} onChange={setDay} />
</div>
@@ -121,7 +121,7 @@ export default function ResourceBlocksModal({ resourceUuid, resourceName, onClos
</label>
</div>
<div className="field" style={{ margin: 0 }}>
<div className="field-block" style={{ margin: 0 }}>
<label htmlFor="block-reason">دلیل</label>
<input
id="block-reason"
+5 -5
View File
@@ -61,7 +61,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* ── گروه‌ها ───────────────────────────────────────────────────────── */}
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>گروههای انتخاب</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -71,7 +71,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
{canEdit && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 240, margin: 0 }}>
<div className="field-block" style={{ minWidth: 240, margin: 0 }}>
<label htmlFor="new-group">گروه تازه</label>
<input
id="new-group"
@@ -175,7 +175,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
)}
</div>
<div className="field" style={{ margin: 0 }}>
<div className="field-block" style={{ margin: 0 }}>
<label>آیتمهای این گروه</label>
<SearchableSelect
value={null}
@@ -222,7 +222,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
</section>
{/* ── روابط ─────────────────────────────────────────────────────────── */}
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>ناسازگاری و پیشنیاز</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -299,7 +299,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
</section>
{/* ── پیش‌نمایش زنده ────────────────────────────────────────────────── */}
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>پیشنمایش انتخاب</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -141,7 +141,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>بخشهای نوبت</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -378,9 +378,9 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
</section>
{/* ── نوار پیش‌نمایش زمانی ──────────────────────────────────────────── */}
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه برای پیشنمایش</label>
<SearchableSelect
value={branchUuid}
@@ -49,10 +49,10 @@ export default function CancellationPolicyPage() {
backTo="/admin/settings-menu"
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
<div className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640 }}>
{loading && <span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>}
<div className="field" style={{ maxWidth: 220 }}>
<div className="field-block" style={{ maxWidth: 220 }}>
<label htmlFor="cp-window">پنجرهٔ لغو رایگان (ساعت)</label>
<input
id="cp-window"
@@ -67,7 +67,7 @@ export default function CancellationPolicyPage() {
</span>
</div>
<div className="field" style={{ maxWidth: 260 }}>
<div className="field-block" style={{ maxWidth: 260 }}>
<label>نوع جریمه پس از پنجرهٔ رایگان</label>
<SearchableSelect
value={mode}
@@ -77,7 +77,7 @@ export default function CancellationPolicyPage() {
</div>
{mode === 'percent' && (
<div className="field" style={{ maxWidth: 200 }}>
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-percent">درصد جریمه</label>
<input
id="cp-percent"
@@ -95,7 +95,7 @@ export default function CancellationPolicyPage() {
)}
{mode === 'fixed' && (
<div className="field" style={{ maxWidth: 260 }}>
<div className="field-block" style={{ maxWidth: 260 }}>
<label>مبلغ جریمه</label>
<PriceInput value={value} onChange={setValue} suffix="ریال" />
</div>
@@ -123,7 +123,7 @@ export default function CancellationPolicyPage() {
اعتبار پکیج پس از لغو برمیگردد
</label>
<div className="field" style={{ maxWidth: 220 }}>
<div className="field-block" style={{ maxWidth: 220 }}>
<label htmlFor="cp-threshold">آستانهٔ عدم حضور</label>
<input
id="cp-threshold"
+3 -3
View File
@@ -219,7 +219,7 @@ export default function CourseProtocolsPage() {
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!draft.uuid && (
<div className="field">
<div className="field-block">
<label>سرویس</label>
<SearchableSelect
value={draft.service_uuid}
@@ -231,7 +231,7 @@ export default function CourseProtocolsPage() {
</div>
)}
<div className="field" style={{ maxWidth: 200 }}>
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-sessions">تعداد جلسه</label>
<input
id="cp-sessions"
@@ -254,7 +254,7 @@ export default function CourseProtocolsPage() {
['ideal_days', 'ایده‌آل (روز)'],
['max_days', 'حداکثر (روز)'],
] as const).map(([key, label]) => (
<div className="field" key={key} style={{ maxWidth: 150 }}>
<div className="field-block" key={key} style={{ maxWidth: 150 }}>
<label htmlFor={`cp-${key}`}>{label}</label>
<input
id={`cp-${key}`}
+5 -5
View File
@@ -189,7 +189,7 @@ export default function PackagesPage() {
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<div className="field-block">
<label htmlFor="pkg-name">نام پکیج</label>
<input
id="pkg-name"
@@ -200,7 +200,7 @@ export default function PackagesPage() {
/>
</div>
<div className="field">
<div className="field-block">
<label htmlFor="pkg-sessions">تعداد جلسه</label>
<input
id="pkg-sessions"
@@ -212,7 +212,7 @@ export default function PackagesPage() {
/>
</div>
<div className="field">
<div className="field-block">
<label>قیمت</label>
<PriceInput
value={draft.price_rials}
@@ -221,7 +221,7 @@ export default function PackagesPage() {
/>
</div>
<div className="field">
<div className="field-block">
<label htmlFor="pkg-validity">اعتبار (روز)</label>
<input
id="pkg-validity"
@@ -236,7 +236,7 @@ export default function PackagesPage() {
/>
</div>
<div className="field">
<div className="field-block">
<label>سرویسهای پوششدادهشده</label>
<SearchableSelect
value={null}
+2 -2
View File
@@ -100,8 +100,8 @@ export default function PlanAccuracyPage() {
backTo="/admin/settings-menu"
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 200, margin: 0 }}>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
</div>
+6 -6
View File
@@ -72,7 +72,7 @@ export default function PolicyFormPage() {
backTo="/admin/policies"
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
@@ -90,7 +90,7 @@ export default function PolicyFormPage() {
</button>
</div>
<div className="field">
<div className="field-block">
<label htmlFor="policy-name">نام قانون</label>
<input
id="policy-name"
@@ -104,7 +104,7 @@ export default function PolicyFormPage() {
</span>
</div>
<div className="field" style={{ maxWidth: 200 }}>
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="policy-priority">اولویت</label>
<input
id="policy-priority"
@@ -120,7 +120,7 @@ export default function PolicyFormPage() {
{mode === 'template' ? (
<>
<div className="field">
<div className="field-block">
<label>الگو</label>
<SearchableSelect
value={templateKey}
@@ -137,7 +137,7 @@ export default function PolicyFormPage() {
</div>
{template?.inputs.map((input) => (
<div className="field" key={input.key} style={{ maxWidth: 260 }}>
<div className="field-block" key={input.key} style={{ maxWidth: 260 }}>
<label htmlFor={`tpl-${input.key}`}>{input.label}</label>
<input
id={`tpl-${input.key}`}
@@ -153,7 +153,7 @@ export default function PolicyFormPage() {
</>
) : (
<>
<div className="field" style={{ maxWidth: 260 }}>
<div className="field-block" style={{ maxWidth: 260 }}>
<label>دسته</label>
<SearchableSelect
value={category}
+4 -4
View File
@@ -238,7 +238,7 @@ export default function PriceListsPage() {
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<div className="field-block">
<label htmlFor="pl-name">نام لیست</label>
<input
id="pl-name"
@@ -249,7 +249,7 @@ export default function PriceListsPage() {
/>
</div>
<div className="field">
<div className="field-block">
<label>شعبه</label>
<SearchableSelect
value={draft.address_uuid ?? ''}
@@ -265,14 +265,14 @@ export default function PriceListsPage() {
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 180 }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>از تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_from)}
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
/>
</div>
<div className="field" style={{ minWidth: 180 }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>تا تاریخ</label>
<PersianDateInput
value={unixToIso(draft.valid_to)}
+12 -12
View File
@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
import HoldCountdown from '../components/HoldCountdown';
import { formatDate } from '../lib/utils';
import { formatDate, formatNumber } from '../lib/utils';
import { useBranches } from '../hooks/useBranches';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import {
@@ -140,8 +140,8 @@ export default function ResourceBookingPage() {
backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'}
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>خدمت</label>
<SearchableSelect
value={serviceUuid}
@@ -154,7 +154,7 @@ export default function ResourceBookingPage() {
/>
</div>
<div className="field" style={{ minWidth: 200, margin: 0 }}>
<div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
@@ -167,7 +167,7 @@ export default function ResourceBookingPage() {
/>
</div>
<div className="field" style={{ minWidth: 160, margin: 0 }}>
<div className="field-block" style={{ minWidth: 160, margin: 0 }}>
<label>بازه</label>
<SearchableSelect
value={days}
@@ -191,19 +191,19 @@ export default function ResourceBookingPage() {
</div>
{error && (
<div className="card" style={{ marginBottom: 16, fontSize: 13, color: 'var(--danger)' }}>
<div className="card card-pad" style={{ marginBottom: 16, fontSize: 13, color: 'var(--danger)' }}>
{error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
</div>
)}
{result && (
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
<span>
مدت نوبت: <strong>{result.plan.total_minutes}</strong> دقیقه
مدت نوبت: <strong>{formatNumber(result.plan.total_minutes)}</strong> دقیقه
</span>
<span style={{ color: 'var(--text-2)' }}>
{result.plan.segments.length} بخش · {result.slots.length} وقت پیدا شد
{formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد
</span>
</div>
@@ -215,7 +215,7 @@ export default function ResourceBookingPage() {
)}
{result && result.slots.length > 0 && (
<div style={{ overflowX: 'auto' }} className="card">
<div style={{ overflowX: 'auto' }} className="card card-pad">
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', minWidth: 520 }}>
<thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
@@ -262,7 +262,7 @@ export default function ResourceBookingPage() {
)}
{pickedSlot && (
<div className="card" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="card card-pad" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>
{formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span>
@@ -301,7 +301,7 @@ export default function ResourceBookingPage() {
{/* در جابه‌جایی پزشک عوض نمی‌شود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
{!rebookUuid && (
<div className="field" style={{ maxWidth: 280, margin: 0 }}>
<div className="field-block" style={{ maxWidth: 280, margin: 0 }}>
<label>پزشک نوبت</label>
<SearchableSelect
value={doctorUuid}
+1 -1
View File
@@ -163,7 +163,7 @@ function TypeModal({
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نام نمایشی</label>
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="دستگاه لیزر" />
<input className="field-block" value={name} onChange={(e) => setName(e.target.value)} placeholder="دستگاه لیزر" />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
@@ -100,12 +100,12 @@ export default function ResourceUtilizationPage() {
<div className="fade-in">
<PageHeader
title="بهره‌وری منابع"
description="فاصلهٔ «اشغال» و «کار مفید» نشان می‌دهد بخش‌های نوبت درست تعریف شده‌اند یا نه."
description="چقدر از ظرفیت هر منبع در بازهٔ انتخاب‌شده واقعاً استفاده شده است."
backTo="/admin/settings-menu"
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
@@ -115,7 +115,7 @@ export default function ResourceUtilizationPage() {
/>
</div>
<div className="field" style={{ minWidth: 180, margin: 0 }}>
<div className="field-block" style={{ minWidth: 180, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
</div>
+3 -3
View File
@@ -92,7 +92,7 @@ export default function TreatmentCoursePage() {
/>
{course && (
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 15 }}>
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده
@@ -116,7 +116,7 @@ export default function TreatmentCoursePage() {
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه برای پیشنهاد وقت</label>
<SearchableSelect
value={branchUuid}
@@ -180,7 +180,7 @@ export default function TreatmentCoursePage() {
setAbandoning(false);
}}
>
<div className="field">
<div className="field-block">
<label htmlFor="abandon-reason">دلیل</label>
<input
id="abandon-reason"