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
+11 -1
View File
@@ -227,9 +227,16 @@ async function withPage(url, opts, fn) {
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
version: 0, version: 0,
}; };
// `--ui '{"darkMode":true,"density":"compact"}'` تم را پیش از اولین رندر می‌کارد.
const ui = opts.ui
? `localStorage.setItem('clinicpro-ui', ${JSON.stringify(
JSON.stringify({ state: JSON.parse(opts.ui), version: 0 }),
)});`
: '';
await S('Runtime.evaluate', { await S('Runtime.evaluate', {
expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))}); expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
localStorage.setItem('pwa-dismissed','1');`, localStorage.setItem('pwa-dismissed','1');${ui}`,
}); });
// Errors before this point belong to the login page, not the page under test. // Errors before this point belong to the login page, not the page under test.
@@ -458,6 +465,9 @@ const opts = {
wait: Number(flag('wait', 4000)), wait: Number(flag('wait', 4000)),
full: argv.includes('--full'), full: argv.includes('--full'),
body: flag('body', null), body: flag('body', null),
// تم و چگالی در `localStorage['clinicpro-ui']` می‌نشینند و بدون seed کردنشان
// دارک‌مود و حالت فشرده اصلاً قابل اسکرین‌شات نیستند.
ui: flag('ui', null),
}; };
try { try {
@@ -37,7 +37,8 @@ describe('AppointmentSegmentsCard', () => {
renderWithProviders(<AppointmentSegmentsCard appointmentUuid="a-1" />); 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 React from 'react';
import { useAppointmentSegments } from '../hooks/useResourceBooking'; import { useAppointmentSegments } from '../hooks/useResourceBooking';
import { formatNumber } from '../lib/utils';
const timeOf = (ts: number) => const timeOf = (ts: number) =>
new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); 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="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-[var(--text)]">بخشهای نوبت</h3> <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>
<div style={{ display: 'flex', gap: 2, marginBottom: 14 }}> <div style={{ display: 'flex', gap: 2, marginBottom: 14 }}>
{segments.map((s) => ( {segments.map((s) => (
<div <div
key={s.sequence} key={s.sequence}
title={`${s.name}${s.duration_minutes} دقیقه`} title={`${s.name}${formatNumber(s.duration_minutes)} دقیقه`}
style={{ style={{
flex: s.duration_minutes, flex: s.duration_minutes,
height: 10, height: 10,
@@ -45,7 +46,7 @@ export default function AppointmentSegmentsCard({ appointmentUuid }: { appointme
<ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}> <ol style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13 }}>
{segments.map((s) => ( {segments.map((s) => (
<li key={s.sequence} style={{ display: 'flex', gap: 10, alignItems: 'center' }}> <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 style={{ flex: 1 }}>{s.name}</span>
<span dir="ltr" style={{ color: 'var(--text-2)' }}> <span dir="ltr" style={{ color: 'var(--text-2)' }}>
{timeOf(s.starts_at)} {timeOf(s.ends_at)} {timeOf(s.starts_at)} {timeOf(s.ends_at)}
@@ -89,7 +89,7 @@ export default function ResourceBlocksModal({ resourceUuid, resourceName, onClos
</p> </p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}> <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> <label>روز</label>
<PersianDateInput value={day} onChange={setDay} /> <PersianDateInput value={day} onChange={setDay} />
</div> </div>
@@ -121,7 +121,7 @@ export default function ResourceBlocksModal({ resourceUuid, resourceName, onClos
</label> </label>
</div> </div>
<div className="field" style={{ margin: 0 }}> <div className="field-block" style={{ margin: 0 }}>
<label htmlFor="block-reason">دلیل</label> <label htmlFor="block-reason">دلیل</label>
<input <input
id="block-reason" id="block-reason"
+5 -5
View File
@@ -61,7 +61,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}> <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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>گروههای انتخاب</h3> <h3 style={{ fontSize: 15, margin: 0 }}>گروههای انتخاب</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}> <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -71,7 +71,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
{canEdit && ( {canEdit && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}> <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> <label htmlFor="new-group">گروه تازه</label>
<input <input
id="new-group" id="new-group"
@@ -175,7 +175,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
)} )}
</div> </div>
<div className="field" style={{ margin: 0 }}> <div className="field-block" style={{ margin: 0 }}>
<label>آیتمهای این گروه</label> <label>آیتمهای این گروه</label>
<SearchableSelect <SearchableSelect
value={null} value={null}
@@ -222,7 +222,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
</section> </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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>ناسازگاری و پیشنیاز</h3> <h3 style={{ fontSize: 15, margin: 0 }}>ناسازگاری و پیشنیاز</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}> <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -299,7 +299,7 @@ export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelation
</section> </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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>پیشنمایش انتخاب</h3> <h3 style={{ fontSize: 15, margin: 0 }}>پیشنمایش انتخاب</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}> <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -141,7 +141,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}> <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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>بخشهای نوبت</h3> <h3 style={{ fontSize: 15, margin: 0 }}>بخشهای نوبت</h3>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}> <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
@@ -378,9 +378,9 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
</section> </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 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> <label>شعبه برای پیشنمایش</label>
<SearchableSelect <SearchableSelect
value={branchUuid} value={branchUuid}
@@ -49,10 +49,10 @@ export default function CancellationPolicyPage() {
backTo="/admin/settings-menu" 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>} {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> <label htmlFor="cp-window">پنجرهٔ لغو رایگان (ساعت)</label>
<input <input
id="cp-window" id="cp-window"
@@ -67,7 +67,7 @@ export default function CancellationPolicyPage() {
</span> </span>
</div> </div>
<div className="field" style={{ maxWidth: 260 }}> <div className="field-block" style={{ maxWidth: 260 }}>
<label>نوع جریمه پس از پنجرهٔ رایگان</label> <label>نوع جریمه پس از پنجرهٔ رایگان</label>
<SearchableSelect <SearchableSelect
value={mode} value={mode}
@@ -77,7 +77,7 @@ export default function CancellationPolicyPage() {
</div> </div>
{mode === 'percent' && ( {mode === 'percent' && (
<div className="field" style={{ maxWidth: 200 }}> <div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-percent">درصد جریمه</label> <label htmlFor="cp-percent">درصد جریمه</label>
<input <input
id="cp-percent" id="cp-percent"
@@ -95,7 +95,7 @@ export default function CancellationPolicyPage() {
)} )}
{mode === 'fixed' && ( {mode === 'fixed' && (
<div className="field" style={{ maxWidth: 260 }}> <div className="field-block" style={{ maxWidth: 260 }}>
<label>مبلغ جریمه</label> <label>مبلغ جریمه</label>
<PriceInput value={value} onChange={setValue} suffix="ریال" /> <PriceInput value={value} onChange={setValue} suffix="ریال" />
</div> </div>
@@ -123,7 +123,7 @@ export default function CancellationPolicyPage() {
اعتبار پکیج پس از لغو برمیگردد اعتبار پکیج پس از لغو برمیگردد
</label> </label>
<div className="field" style={{ maxWidth: 220 }}> <div className="field-block" style={{ maxWidth: 220 }}>
<label htmlFor="cp-threshold">آستانهٔ عدم حضور</label> <label htmlFor="cp-threshold">آستانهٔ عدم حضور</label>
<input <input
id="cp-threshold" id="cp-threshold"
+3 -3
View File
@@ -219,7 +219,7 @@ export default function CourseProtocolsPage() {
{draft && ( {draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!draft.uuid && ( {!draft.uuid && (
<div className="field"> <div className="field-block">
<label>سرویس</label> <label>سرویس</label>
<SearchableSelect <SearchableSelect
value={draft.service_uuid} value={draft.service_uuid}
@@ -231,7 +231,7 @@ export default function CourseProtocolsPage() {
</div> </div>
)} )}
<div className="field" style={{ maxWidth: 200 }}> <div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-sessions">تعداد جلسه</label> <label htmlFor="cp-sessions">تعداد جلسه</label>
<input <input
id="cp-sessions" id="cp-sessions"
@@ -254,7 +254,7 @@ export default function CourseProtocolsPage() {
['ideal_days', 'ایده‌آل (روز)'], ['ideal_days', 'ایده‌آل (روز)'],
['max_days', 'حداکثر (روز)'], ['max_days', 'حداکثر (روز)'],
] as const).map(([key, label]) => ( ] 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> <label htmlFor={`cp-${key}`}>{label}</label>
<input <input
id={`cp-${key}`} id={`cp-${key}`}
+5 -5
View File
@@ -189,7 +189,7 @@ export default function PackagesPage() {
> >
{draft && ( {draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field"> <div className="field-block">
<label htmlFor="pkg-name">نام پکیج</label> <label htmlFor="pkg-name">نام پکیج</label>
<input <input
id="pkg-name" id="pkg-name"
@@ -200,7 +200,7 @@ export default function PackagesPage() {
/> />
</div> </div>
<div className="field"> <div className="field-block">
<label htmlFor="pkg-sessions">تعداد جلسه</label> <label htmlFor="pkg-sessions">تعداد جلسه</label>
<input <input
id="pkg-sessions" id="pkg-sessions"
@@ -212,7 +212,7 @@ export default function PackagesPage() {
/> />
</div> </div>
<div className="field"> <div className="field-block">
<label>قیمت</label> <label>قیمت</label>
<PriceInput <PriceInput
value={draft.price_rials} value={draft.price_rials}
@@ -221,7 +221,7 @@ export default function PackagesPage() {
/> />
</div> </div>
<div className="field"> <div className="field-block">
<label htmlFor="pkg-validity">اعتبار (روز)</label> <label htmlFor="pkg-validity">اعتبار (روز)</label>
<input <input
id="pkg-validity" id="pkg-validity"
@@ -236,7 +236,7 @@ export default function PackagesPage() {
/> />
</div> </div>
<div className="field"> <div className="field-block">
<label>سرویسهای پوششدادهشده</label> <label>سرویسهای پوششدادهشده</label>
<SearchableSelect <SearchableSelect
value={null} value={null}
+2 -2
View File
@@ -100,8 +100,8 @@ export default function PlanAccuracyPage() {
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
/> />
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 200, margin: 0 }}> <div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>بازه</label> <label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} /> <SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
</div> </div>
+6 -6
View File
@@ -72,7 +72,7 @@ export default function PolicyFormPage() {
backTo="/admin/policies" 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 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button
type="button" type="button"
@@ -90,7 +90,7 @@ export default function PolicyFormPage() {
</button> </button>
</div> </div>
<div className="field"> <div className="field-block">
<label htmlFor="policy-name">نام قانون</label> <label htmlFor="policy-name">نام قانون</label>
<input <input
id="policy-name" id="policy-name"
@@ -104,7 +104,7 @@ export default function PolicyFormPage() {
</span> </span>
</div> </div>
<div className="field" style={{ maxWidth: 200 }}> <div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="policy-priority">اولویت</label> <label htmlFor="policy-priority">اولویت</label>
<input <input
id="policy-priority" id="policy-priority"
@@ -120,7 +120,7 @@ export default function PolicyFormPage() {
{mode === 'template' ? ( {mode === 'template' ? (
<> <>
<div className="field"> <div className="field-block">
<label>الگو</label> <label>الگو</label>
<SearchableSelect <SearchableSelect
value={templateKey} value={templateKey}
@@ -137,7 +137,7 @@ export default function PolicyFormPage() {
</div> </div>
{template?.inputs.map((input) => ( {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> <label htmlFor={`tpl-${input.key}`}>{input.label}</label>
<input <input
id={`tpl-${input.key}`} 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> <label>دسته</label>
<SearchableSelect <SearchableSelect
value={category} value={category}
+4 -4
View File
@@ -238,7 +238,7 @@ export default function PriceListsPage() {
> >
{draft && ( {draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field"> <div className="field-block">
<label htmlFor="pl-name">نام لیست</label> <label htmlFor="pl-name">نام لیست</label>
<input <input
id="pl-name" id="pl-name"
@@ -249,7 +249,7 @@ export default function PriceListsPage() {
/> />
</div> </div>
<div className="field"> <div className="field-block">
<label>شعبه</label> <label>شعبه</label>
<SearchableSelect <SearchableSelect
value={draft.address_uuid ?? ''} value={draft.address_uuid ?? ''}
@@ -265,14 +265,14 @@ export default function PriceListsPage() {
</div> </div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 180 }}> <div className="field-block" style={{ minWidth: 180 }}>
<label>از تاریخ</label> <label>از تاریخ</label>
<PersianDateInput <PersianDateInput
value={unixToIso(draft.valid_from)} value={unixToIso(draft.valid_from)}
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })} onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
/> />
</div> </div>
<div className="field" style={{ minWidth: 180 }}> <div className="field-block" style={{ minWidth: 180 }}>
<label>تا تاریخ</label> <label>تا تاریخ</label>
<PersianDateInput <PersianDateInput
value={unixToIso(draft.valid_to)} 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 PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import HoldCountdown from '../components/HoldCountdown'; import HoldCountdown from '../components/HoldCountdown';
import { formatDate } from '../lib/utils'; import { formatDate, formatNumber } from '../lib/utils';
import { useBranches } from '../hooks/useBranches'; import { useBranches } from '../hooks/useBranches';
import { useAllServiceItems } from '../hooks/useServiceCatalog'; import { useAllServiceItems } from '../hooks/useServiceCatalog';
import { import {
@@ -140,8 +140,8 @@ export default function ResourceBookingPage() {
backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'} backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'}
/> />
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}> <div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}> <div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>خدمت</label> <label>خدمت</label>
<SearchableSelect <SearchableSelect
value={serviceUuid} value={serviceUuid}
@@ -154,7 +154,7 @@ export default function ResourceBookingPage() {
/> />
</div> </div>
<div className="field" style={{ minWidth: 200, margin: 0 }}> <div className="field-block" style={{ minWidth: 200, margin: 0 }}>
<label>شعبه</label> <label>شعبه</label>
<SearchableSelect <SearchableSelect
value={branchUuid} value={branchUuid}
@@ -167,7 +167,7 @@ export default function ResourceBookingPage() {
/> />
</div> </div>
<div className="field" style={{ minWidth: 160, margin: 0 }}> <div className="field-block" style={{ minWidth: 160, margin: 0 }}>
<label>بازه</label> <label>بازه</label>
<SearchableSelect <SearchableSelect
value={days} value={days}
@@ -191,19 +191,19 @@ export default function ResourceBookingPage() {
</div> </div>
{error && ( {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 : 'جستجوی وقت ناموفق بود'} {error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
</div> </div>
)} )}
{result && ( {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 }}> <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
<span> <span>
مدت نوبت: <strong>{result.plan.total_minutes}</strong> دقیقه مدت نوبت: <strong>{formatNumber(result.plan.total_minutes)}</strong> دقیقه
</span> </span>
<span style={{ color: 'var(--text-2)' }}> <span style={{ color: 'var(--text-2)' }}>
{result.plan.segments.length} بخش · {result.slots.length} وقت پیدا شد {formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد
</span> </span>
</div> </div>
@@ -215,7 +215,7 @@ export default function ResourceBookingPage() {
)} )}
{result && result.slots.length > 0 && ( {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 }}> <table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', minWidth: 520 }}>
<thead> <thead>
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}> <tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
@@ -262,7 +262,7 @@ export default function ResourceBookingPage() {
)} )}
{pickedSlot && ( {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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}> <h3 style={{ fontSize: 15, margin: 0 }}>
{formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span> {formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span>
@@ -301,7 +301,7 @@ export default function ResourceBookingPage() {
{/* در جابه‌جایی پزشک عوض نمی‌شود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */} {/* در جابه‌جایی پزشک عوض نمی‌شود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
{!rebookUuid && ( {!rebookUuid && (
<div className="field" style={{ maxWidth: 280, margin: 0 }}> <div className="field-block" style={{ maxWidth: 280, margin: 0 }}>
<label>پزشک نوبت</label> <label>پزشک نوبت</label>
<SearchableSelect <SearchableSelect
value={doctorUuid} value={doctorUuid}
+1 -1
View File
@@ -163,7 +163,7 @@ function TypeModal({
<div style={{ display: 'grid', gap: 6 }}> <div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نام نمایشی</label> <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> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
@@ -100,12 +100,12 @@ export default function ResourceUtilizationPage() {
<div className="fade-in"> <div className="fade-in">
<PageHeader <PageHeader
title="بهره‌وری منابع" title="بهره‌وری منابع"
description="فاصلهٔ «اشغال» و «کار مفید» نشان می‌دهد بخش‌های نوبت درست تعریف شده‌اند یا نه." description="چقدر از ظرفیت هر منبع در بازهٔ انتخاب‌شده واقعاً استفاده شده است."
backTo="/admin/settings-menu" backTo="/admin/settings-menu"
/> />
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}> <div className="field-block" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه</label> <label>شعبه</label>
<SearchableSelect <SearchableSelect
value={branchUuid} value={branchUuid}
@@ -115,7 +115,7 @@ export default function ResourceUtilizationPage() {
/> />
</div> </div>
<div className="field" style={{ minWidth: 180, margin: 0 }}> <div className="field-block" style={{ minWidth: 180, margin: 0 }}>
<label>بازه</label> <label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} /> <SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
</div> </div>
+3 -3
View File
@@ -92,7 +92,7 @@ export default function TreatmentCoursePage() {
/> />
{course && ( {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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 15 }}> <span style={{ fontSize: 15 }}>
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده جلسهٔ <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 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> <label>شعبه برای پیشنهاد وقت</label>
<SearchableSelect <SearchableSelect
value={branchUuid} value={branchUuid}
@@ -180,7 +180,7 @@ export default function TreatmentCoursePage() {
setAbandoning(false); setAbandoning(false);
}} }}
> >
<div className="field"> <div className="field-block">
<label htmlFor="abandon-reason">دلیل</label> <label htmlFor="abandon-reason">دلیل</label>
<input <input
id="abandon-reason" id="abandon-reason"
@@ -72,14 +72,14 @@
|---|---|---|---| |---|---|---|---|
| ۴.۱ | تایمر شمارش معکوس hold | ✅ | ⭐ `HoldCountdown` — مبنا `expires_at` سرور است نه شمارندهٔ مرورگر؛ زیر یک دقیقه هشدار می‌شود. سه تست | | ۴.۱ | تایمر شمارش معکوس hold | ✅ | ⭐ `HoldCountdown` — مبنا `expires_at` سرور است نه شمارندهٔ مرورگر؛ زیر یک دقیقه هشدار می‌شود. سه تست |
| ۴.۲ | خطای `409` با لیست جایگزین | ✅ | ⭐ انتخاب کهنه کنار گذاشته و جستجو دوباره اجرا می‌شود؛ جایگزین‌ها بلافاصله روی صفحه‌اند | | ۴.۲ | خطای `409` با لیست جایگزین | ✅ | ⭐ انتخاب کهنه کنار گذاشته و جستجو دوباره اجرا می‌شود؛ جایگزین‌ها بلافاصله روی صفحه‌اند |
| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | | جریان جابه‌جایی نوبت در پنل ساخته نشد؛ `rebook` از API کامل است | | ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | | جابه‌جایی با `?rebook=<uuid>` روی همان صفحهٔ رزرو؛ رزرو جدید اول گرفته می‌شود، پس شکستش نوبت قدیم را دست‌نخورده می‌گذارد و پیام سرور همین را می‌گوید |
| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ✅ | `ResourceBlocksModal` + سه اندپوینت تازه؛ ۶ تست بک‌اند | | ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ✅ | `ResourceBlocksModal` + سه اندپوینت تازه؛ ۶ تست بک‌اند |
| ۴.۵ | تفکیک «مسدودسازی موردی» از «بلندمدت» در UI | ✅ | ⭐ تفاوتشان بالای همان مودال نوشته شده، نه فقط در مستندات | | ۴.۵ | تفکیک «مسدودسازی موردی» از «بلندمدت» در UI | ✅ | ⭐ تفاوتشان بالای همان مودال نوشته شده، نه فقط در مستندات |
| ۴.۶ | هیچ رنگ/شعاع hard-code | ✅ | | | ۴.۶ | هیچ رنگ/شعاع hard-code | ✅ | |
| ۴.۷ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | | ۴.۷ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد |
| ۴.۸ | RTL و موبایل | ✅ | جدول وقت‌ها اسکرول افقی داخلی دارد | | ۴.۸ | RTL و موبایل | ✅ | جدول وقت‌ها اسکرول افقی داخلی دارد |
| ۴.۹ | همهٔ رشته‌ها فارسی | ✅ | | | ۴.۹ | همهٔ رشته‌ها فارسی | ✅ | |
| ۴.۱۰ | بخش‌های نوبت در `AppointmentDetailPage` | | کارت فاکتور اضافه شد (تسک ۰۸) ولی بخش‌های نوبت نه | | ۴.۱۰ | بخش‌های نوبت در `AppointmentDetailPage` | | `GET /appointment/{uuid}/segments` + `AppointmentSegmentsCard`؛ بخشِ بدون حضور بیمار کم‌رنگ است و فهرست خالی یعنی نوبت اسلاتی (کارت رندر نمی‌شود) |
| ۴.۱۱ | جریان سه‌مرحله‌ای رزرو | ✅ | ⭐ `ResourceBookingPage`: جستجو → نگه‌داشتن → ثبت، با آزادسازی صریح | | ۴.۱۱ | جریان سه‌مرحله‌ای رزرو | ✅ | ⭐ `ResourceBookingPage`: جستجو → نگه‌داشتن → ثبت، با آزادسازی صریح |
| ۴.۱۲ | انتخاب پزشک پیش از ثبت | ✅ | ثبت نهایی بدون پزشک ممکن نیست؛ دکمه تا انتخاب نشدن غیرفعال است | | ۴.۱۲ | انتخاب پزشک پیش از ثبت | ✅ | ثبت نهایی بدون پزشک ممکن نیست؛ دکمه تا انتخاب نشدن غیرفعال است |
@@ -76,14 +76,14 @@
|---|---|---|---| |---|---|---|---|
| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویس‌ها ساخته نشد (اندپوینتش هست) | | ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویس‌ها ساخته نشد (اندپوینتش هست) |
| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) | | ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) |
| ۵.۳ | دکمهٔ لغو با محتوای preview | | هوک `useCancellationPreview` و `useCancelAppointment` آماده‌اند؛ اتصال به `AppointmentDetailPage` انجام نشد | | ۵.۳ | دکمهٔ لغو با محتوای preview | | `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان می‌دهد؛ سرویس لغو هم `reason` می‌گیرد و ردیف تایم‌لاین می‌نویسد (قبلاً این مسیر هیچ ردی نمی‌گذاشت) |
| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` می‌آید و در پرونده دیده می‌شود، ولی شمارش عدم حضور نمایش داده نمی‌شود | | ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` می‌آید و در پرونده دیده می‌شود، ولی شمارش عدم حضور نمایش داده نمی‌شود |
| ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دست‌ساز ساخته نشد | | ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دست‌ساز ساخته نشد |
| ۵.۶ | فیلتر در URL | ✅ | `useUrlState` | | ۵.۶ | فیلتر در URL | ✅ | `useUrlState` |
| ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` | | ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` |
| ۵.۸ | `backTo` | ✅ | | | ۵.۸ | `backTo` | ✅ | |
| ۵.۹ | هیچ رنگ hard-code | ✅ | | | ۵.۹ | هیچ رنگ hard-code | ✅ | |
| ۵.۱۰ | دارک‌مود و حالت فشرده | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی انجام نشد | | ۵.۱۰ | دارک‌مود و حالت فشرده | | اسکرین‌شات واقعی؛ دو ایراد پیدا و رفع شد (کارتِ بدون `card-pad` و برچسبِ داخل `.field`) |
| ۵.۱۱ | RTL و موبایل | ✅ | جدول لیست انتظار اسکرول افقی داخلی دارد | | ۵.۱۱ | RTL و موبایل | ✅ | جدول لیست انتظار اسکرول افقی داخلی دارد |
| ۵.۱۲ | رشته‌ها فارسی | ✅ | | | ۵.۱۲ | رشته‌ها فارسی | ✅ | |
| ۵.۱۳ | `ReserveAppointmentsPage` دست‌نخورده | ✅ | | | ۵.۱۳ | `ReserveAppointmentsPage` دست‌نخورده | ✅ | |
@@ -123,7 +123,7 @@
| ۸.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۲ تست | | ۸.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۲ تست |
| ۸.۶ | تست‌های tenant سبز | ✅ | | | ۸.۶ | تست‌های tenant سبز | ✅ | |
| ۸.۷ | `docs/api/*` به‌روز | ✅ | | | ۸.۷ | `docs/api/*` به‌روز | ✅ | |
| ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۳، ۵.۴، ۵.۱۰ | | ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۴ |
| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینت‌ها پنل‌محورند؛ اتصال `nobat724_front` بررسی نشد | | ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینت‌ها پنل‌محورند؛ اتصال `nobat724_front` بررسی نشد |
| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان | | ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان |
| ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
@@ -24,12 +24,12 @@
| ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** می‌شوند، نه سریال | | ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** می‌شوند، نه سریال |
| ۱.۳ | هر رویداد محیط دارد | ✅ | `TenantOwnedTrait` | | ۱.۳ | هر رویداد محیط دارد | ✅ | `TenantOwnedTrait` |
| ۱.۴ | outbox — `record()` فقط persist | ✅ | ⭐ تست rollback | | ۱.۴ | outbox — `record()` فقط persist | ✅ | ⭐ تست rollback |
| ۱.۵ | worker انتشار | ⚠️ | `app:events:publish` هست؛ ثبتش در `scheduler` انجام نشد (تصمیم استقرار، نه کد — نیازمند هماهنگی با Coolify) | | ۱.۵ | worker انتشار | | منطق از Command به `OutboxPublisher` رفت و `PublishDomainEventsMessage` هر دقیقه در `src/Schedule.php` صادر می‌شود؛ همان `worker-scheduler` موجود مصرفش می‌کند |
| ۱.۶ | سقف تلاش، بدون حذف ردیف شکست‌خورده | ✅ | تست دارد | | ۱.۶ | سقف تلاش، بدون حذف ردیف شکست‌خورده | ✅ | تست دارد |
| ۱.۷ | همهٔ نقاط به `record()` وصل شدند | ⚠️ | تسک‌های ۰۷ تا ۱۳ اصلاً `dispatch` نداشتند؛ هشت نقطهٔ واقعی وصل شد و چهار رویداد باقی‌مانده نقطهٔ ثبت ندارند (۱.۸) | | ۱.۷ | همهٔ نقاط به `record()` وصل شدند | | دوازده نقطه؛ چهار موردِ باقی‌مانده هم بسته شد |
| ۱.۸ | چهارده رویداد بند ۱۶ | ⚠️ | ده رویداد ثبت می‌شوند. `AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked`، `ResourceReleased` نام دارند ولی نقطهٔ ثبت ندارند — مسیرهایشان از تسک‌های قبلی‌اند و دست‌زدن به آن‌ها بیرون دامنه بود. فهرست وضعیت در `domain-events.md` | | ۱.۸ | چهارده رویداد بند ۱۶ | | ⭐ هر چهارده تا نقطهٔ ثبت دارند. `AppointmentCompleted` **بعد از** ذخیرهٔ موفق (انتقال ردشده رویداد نمی‌گذارد) · `AppointmentRescheduled` رویداد سوم است نه جایگزین `Booked`/`Cancelled` |
| ۱.۹ | idempotency در مصرف‌کننده | ✅ | `DomainEventMessage::$uuid` + توضیح صریح در docblock و سند | | ۱.۹ | idempotency در مصرف‌کننده | ✅ | `DomainEventMessage::$uuid` + توضیح صریح در docblock و سند |
| ۱.۱۰ | worker با loop-wrap برای Coolify | | با ۱.۵ یک بسته است | | ۱.۱۰ | worker با loop-wrap برای Coolify | | `worker-scheduler` از قبل loop-wrap دارد؛ سرویس تازه لازم نشد و همین در `deploy/coolify.md` نوشته شد |
| ۱.۱۱ | `app:events:prune` | ✅ | فقط ردیف **منتشرشده** حذف می‌شود؛ منتشرنشده مدرکِ گم‌شدن است | | ۱.۱۱ | `app:events:prune` | ✅ | فقط ردیف **منتشرشده** حذف می‌شود؛ منتشرنشده مدرکِ گم‌شدن است |
| ۱.۱۲ | `GET /domain-events` فقط ادمین | ✅ | تست ۴۰۳/۲۰۰ | | ۱.۱۲ | `GET /domain-events` فقط ادمین | ✅ | تست ۴۰۳/۲۰۰ |
@@ -76,8 +76,8 @@
| ۴.۹ | `DataTable` با skeleton و empty state | ✅ | | | ۴.۹ | `DataTable` با skeleton و empty state | ✅ | |
| ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) | | ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) |
| ۴.۱۱ | هیچ رنگ hard-code | ✅ | | | ۴.۱۱ | هیچ رنگ hard-code | ✅ | |
| ۴.۱۲ | دارک‌مود | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی نشد | | ۴.۱۲ | دارک‌مود | | با اسکرین‌شات واقعی دیده شد (CDP + seed کردن `clinicpro-ui`) |
| ۴.۱۳ | حالت فشرده | ⚠️ | همان | | ۴.۱۳ | حالت فشرده | | همان اجرا با `density: compact` |
| ۴.۱۴ | RTL و موبایل | ✅ | جدول‌ها اسکرول افقی داخلی دارند | | ۴.۱۴ | RTL و موبایل | ✅ | جدول‌ها اسکرول افقی داخلی دارند |
| ۴.۱۵ | رشته‌ها فارسی | ✅ | | | ۴.۱۵ | رشته‌ها فارسی | ✅ | |
| ۴.۱۶ | `backTo` | ✅ | | | ۴.۱۶ | `backTo` | ✅ | |
@@ -114,8 +114,8 @@
| ۷.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۴ تست | | ۷.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۴ تست |
| ۷.۶ | تست‌های tenant سبز | ✅ | | | ۷.۶ | تست‌های tenant سبز | ✅ | |
| ۷.۷ | `docs/api/*` به‌روز | ✅ | | | ۷.۷ | `docs/api/*` به‌روز | ✅ | |
| ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸، ۴.۱۲، ۴.۱۳ | | ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸ |
| ۷.۹ | پیامک‌های موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند | | ۷.۹ | پیامک‌های موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند |
| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد؛ گزارش‌ها پنل‌محورند | | ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد؛ گزارش‌ها پنل‌محورند |
| ۷.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | | ۷.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
| ۷.۱۲ | موارد به‌تعویق با دلیل | ✅ | چهار رویداد بی‌نقطهٔ ثبت (۱.۸) · scheduler/worker استقرار (۱.۵/۱.۱۰) · نمودار و URL-state (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) | | ۷.۱۲ | موارد به‌تعویق با دلیل | ✅ | نمودار و URL-state (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) |