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:
@@ -227,9 +227,16 @@ async function withPage(url, opts, fn) {
|
||||
state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
|
||||
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', {
|
||||
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.
|
||||
@@ -458,6 +465,9 @@ const opts = {
|
||||
wait: Number(flag('wait', 4000)),
|
||||
full: argv.includes('--full'),
|
||||
body: flag('body', null),
|
||||
// تم و چگالی در `localStorage['clinicpro-ui']` مینشینند و بدون seed کردنشان
|
||||
// دارکمود و حالت فشرده اصلاً قابل اسکرینشات نیستند.
|
||||
ui: flag('ui', null),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | تایمر شمارش معکوس hold | ✅ | ⭐ `HoldCountdown` — مبنا `expires_at` سرور است نه شمارندهٔ مرورگر؛ زیر یک دقیقه هشدار میشود. سه تست |
|
||||
| ۴.۲ | خطای `409` با لیست جایگزین | ✅ | ⭐ انتخاب کهنه کنار گذاشته و جستجو دوباره اجرا میشود؛ جایگزینها بلافاصله روی صفحهاند |
|
||||
| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ⏳ | جریان جابهجایی نوبت در پنل ساخته نشد؛ `rebook` از API کامل است |
|
||||
| ۴.۳ | خطای `reschedule` شامل «نوبت فعلی تغییری نکرد» | ✅ | ⭐ جابهجایی با `?rebook=<uuid>` روی همان صفحهٔ رزرو؛ رزرو جدید اول گرفته میشود، پس شکستش نوبت قدیم را دستنخورده میگذارد و پیام سرور همین را میگوید |
|
||||
| ۴.۴ | مسدودسازی موردی منبع از صفحهٔ منابع | ✅ | `ResourceBlocksModal` + سه اندپوینت تازه؛ ۶ تست بکاند |
|
||||
| ۴.۵ | تفکیک «مسدودسازی موردی» از «بلندمدت» در UI | ✅ | ⭐ تفاوتشان بالای همان مودال نوشته شده، نه فقط در مستندات |
|
||||
| ۴.۶ | هیچ رنگ/شعاع hard-code | ✅ | |
|
||||
| ۴.۷ | دارکمود و حالت فشرده | ⚠️ | فقط توکنها؛ بازبینی چشمی انجام نشد |
|
||||
| ۴.۸ | RTL و موبایل | ✅ | جدول وقتها اسکرول افقی داخلی دارد |
|
||||
| ۴.۹ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۴.۱۰ | بخشهای نوبت در `AppointmentDetailPage` | ⏳ | کارت فاکتور اضافه شد (تسک ۰۸) ولی بخشهای نوبت نه |
|
||||
| ۴.۱۰ | بخشهای نوبت در `AppointmentDetailPage` | ✅ | `GET /appointment/{uuid}/segments` + `AppointmentSegmentsCard`؛ بخشِ بدون حضور بیمار کمرنگ است و فهرست خالی یعنی نوبت اسلاتی (کارت رندر نمیشود) |
|
||||
| ۴.۱۱ | جریان سهمرحلهای رزرو | ✅ | ⭐ `ResourceBookingPage`: جستجو → نگهداشتن → ثبت، با آزادسازی صریح |
|
||||
| ۴.۱۲ | انتخاب پزشک پیش از ثبت | ✅ | ثبت نهایی بدون پزشک ممکن نیست؛ دکمه تا انتخاب نشدن غیرفعال است |
|
||||
|
||||
|
||||
@@ -76,14 +76,14 @@
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویسها ساخته نشد (اندپوینتش هست) |
|
||||
| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) |
|
||||
| ۵.۳ | دکمهٔ لغو با محتوای preview | ⏳ | هوک `useCancellationPreview` و `useCancelAppointment` آمادهاند؛ اتصال به `AppointmentDetailPage` انجام نشد |
|
||||
| ۵.۳ | دکمهٔ لغو با محتوای preview | ✅ | ⭐ `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان میدهد؛ سرویس لغو هم `reason` میگیرد و ردیف تایملاین مینویسد (قبلاً این مسیر هیچ ردی نمیگذاشت) |
|
||||
| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` میآید و در پرونده دیده میشود، ولی شمارش عدم حضور نمایش داده نمیشود |
|
||||
| ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دستساز ساخته نشد |
|
||||
| ۵.۶ | فیلتر در URL | ✅ | `useUrlState` |
|
||||
| ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` |
|
||||
| ۵.۸ | `backTo` | ✅ | |
|
||||
| ۵.۹ | هیچ رنگ hard-code | ✅ | |
|
||||
| ۵.۱۰ | دارکمود و حالت فشرده | ⚠️ | فقط توکنها؛ بازبینی چشمی انجام نشد |
|
||||
| ۵.۱۰ | دارکمود و حالت فشرده | ✅ | اسکرینشات واقعی؛ دو ایراد پیدا و رفع شد (کارتِ بدون `card-pad` و برچسبِ داخل `.field`) |
|
||||
| ۵.۱۱ | RTL و موبایل | ✅ | جدول لیست انتظار اسکرول افقی داخلی دارد |
|
||||
| ۵.۱۲ | رشتهها فارسی | ✅ | |
|
||||
| ۵.۱۳ | `ReserveAppointmentsPage` دستنخورده | ✅ | |
|
||||
@@ -123,7 +123,7 @@
|
||||
| ۸.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۲ تست |
|
||||
| ۸.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۸.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۸.۸ | چکلیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۳، ۵.۴، ۵.۱۰ |
|
||||
| ۸.۸ | چکلیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۴ |
|
||||
| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینتها پنلمحورند؛ اتصال `nobat724_front` بررسی نشد |
|
||||
| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان |
|
||||
| ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
| ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** میشوند، نه سریال |
|
||||
| ۱.۳ | هر رویداد محیط دارد | ✅ | `TenantOwnedTrait` |
|
||||
| ۱.۴ | outbox — `record()` فقط persist | ✅ | ⭐ تست rollback |
|
||||
| ۱.۵ | worker انتشار | ⚠️ | `app:events:publish` هست؛ ثبتش در `scheduler` انجام نشد (تصمیم استقرار، نه کد — نیازمند هماهنگی با Coolify) |
|
||||
| ۱.۵ | worker انتشار | ✅ | منطق از Command به `OutboxPublisher` رفت و `PublishDomainEventsMessage` هر دقیقه در `src/Schedule.php` صادر میشود؛ همان `worker-scheduler` موجود مصرفش میکند |
|
||||
| ۱.۶ | سقف تلاش، بدون حذف ردیف شکستخورده | ✅ | تست دارد |
|
||||
| ۱.۷ | همهٔ نقاط به `record()` وصل شدند | ⚠️ | تسکهای ۰۷ تا ۱۳ اصلاً `dispatch` نداشتند؛ هشت نقطهٔ واقعی وصل شد و چهار رویداد باقیمانده نقطهٔ ثبت ندارند (۱.۸) |
|
||||
| ۱.۸ | چهارده رویداد بند ۱۶ | ⚠️ | ده رویداد ثبت میشوند. `AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked`، `ResourceReleased` نام دارند ولی نقطهٔ ثبت ندارند — مسیرهایشان از تسکهای قبلیاند و دستزدن به آنها بیرون دامنه بود. فهرست وضعیت در `domain-events.md` |
|
||||
| ۱.۷ | همهٔ نقاط به `record()` وصل شدند | ✅ | دوازده نقطه؛ چهار موردِ باقیمانده هم بسته شد |
|
||||
| ۱.۸ | چهارده رویداد بند ۱۶ | ✅ | ⭐ هر چهارده تا نقطهٔ ثبت دارند. `AppointmentCompleted` **بعد از** ذخیرهٔ موفق (انتقال ردشده رویداد نمیگذارد) · `AppointmentRescheduled` رویداد سوم است نه جایگزین `Booked`/`Cancelled` |
|
||||
| ۱.۹ | idempotency در مصرفکننده | ✅ | `DomainEventMessage::$uuid` + توضیح صریح در docblock و سند |
|
||||
| ۱.۱۰ | worker با loop-wrap برای Coolify | ⏳ | با ۱.۵ یک بسته است |
|
||||
| ۱.۱۰ | worker با loop-wrap برای Coolify | ✅ | `worker-scheduler` از قبل loop-wrap دارد؛ سرویس تازه لازم نشد و همین در `deploy/coolify.md` نوشته شد |
|
||||
| ۱.۱۱ | `app:events:prune` | ✅ | فقط ردیف **منتشرشده** حذف میشود؛ منتشرنشده مدرکِ گمشدن است |
|
||||
| ۱.۱۲ | `GET /domain-events` فقط ادمین | ✅ | تست ۴۰۳/۲۰۰ |
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
| ۴.۹ | `DataTable` با skeleton و empty state | ✅ | |
|
||||
| ۴.۱۰ | رنگ نمودار از توکنها | — | نمودار ندارد (۴.۱) |
|
||||
| ۴.۱۱ | هیچ رنگ hard-code | ✅ | |
|
||||
| ۴.۱۲ | دارکمود | ⚠️ | فقط توکنها؛ بازبینی چشمی نشد |
|
||||
| ۴.۱۳ | حالت فشرده | ⚠️ | همان |
|
||||
| ۴.۱۲ | دارکمود | ✅ | با اسکرینشات واقعی دیده شد (CDP + seed کردن `clinicpro-ui`) |
|
||||
| ۴.۱۳ | حالت فشرده | ✅ | همان اجرا با `density: compact` |
|
||||
| ۴.۱۴ | RTL و موبایل | ✅ | جدولها اسکرول افقی داخلی دارند |
|
||||
| ۴.۱۵ | رشتهها فارسی | ✅ | |
|
||||
| ۴.۱۶ | `backTo` | ✅ | |
|
||||
@@ -114,8 +114,8 @@
|
||||
| ۷.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۴ تست |
|
||||
| ۷.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۷.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۷.۸ | چکلیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸، ۴.۱۲، ۴.۱۳ |
|
||||
| ۷.۸ | چکلیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸ |
|
||||
| ۷.۹ | پیامکهای موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند |
|
||||
| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومیای عوض نشد؛ گزارشها پنلمحورند |
|
||||
| ۷.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
||||
| ۷.۱۲ | موارد بهتعویق با دلیل | ✅ | چهار رویداد بینقطهٔ ثبت (۱.۸) · scheduler/worker استقرار (۱.۵/۱.۱۰) · نمودار و URL-state (۴.۱/۴.۸) · تست کوئریشماری (۵.۶) |
|
||||
| ۷.۱۲ | موارد بهتعویق با دلیل | ✅ | نمودار و URL-state (۴.۱/۴.۸) · تست کوئریشماری (۵.۶) |
|
||||
|
||||
Reference in New Issue
Block a user