From 0e7970d6e091774389c4cae21c0ba8b051c7cf9e Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Wed, 5 Aug 2026 16:09:46 +0330
Subject: [PATCH] feat: replace checkboxes with Switch component for better UI
consistency
- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes.
- Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels.
- Added tests for the new Switch component to ensure functionality and accessibility compliance.
- Updated styles to accommodate the new Switch component design.
---
.../admin/components/AppointmentActions.tsx | 25 ++--
.../components/AppointmentFiltersModal.tsx | 11 +-
assets/admin/components/DiscountTab.tsx | 9 +-
assets/admin/components/FreeVisitPrice.tsx | 26 ++--
.../InsuranceServiceCategoriesCard.tsx | 23 ++--
.../admin/components/NewAppointmentDrawer.tsx | 11 +-
.../admin/components/PatientsFilterModal.tsx | 12 +-
.../components/ServiceInsuranceModal.tsx | 16 ++-
.../admin/components/ServiceItemFormModal.tsx | 20 ++-
.../paymentMethods/StatusToggle.tsx | 14 +--
.../resources/ResourceFormModal.tsx | 66 +++++-----
.../resources/ResourceServicesPanel.tsx | 16 +--
.../resources/ResourceWorkingHoursPanel.tsx | 14 ++-
.../components/schedule/ScheduleSection.tsx | 20 ++-
.../components/ui/DoctorPermissionsModal.tsx | 33 ++---
assets/admin/components/ui/Switch.test.tsx | 63 ++++++++++
assets/admin/components/ui/Switch.tsx | 87 +++++++++++++
assets/admin/pages/AdminSubscriptionPage.tsx | 7 ++
assets/admin/pages/AppointmentCreatePage.tsx | 21 ++--
assets/admin/pages/AppointmentEditPage.tsx | 11 +-
assets/admin/pages/CatalogCategoriesPage.tsx | 6 +-
assets/admin/pages/ClinicDetailPage.tsx | 18 +--
assets/admin/pages/ClinicServicesPage.tsx | 10 +-
assets/admin/pages/DoctorDetailPage.tsx | 15 ++-
assets/admin/pages/MySecretariesPage.tsx | 18 +--
.../admin/pages/RecordNumberSettingsPage.tsx | 31 ++---
assets/admin/pages/RepresentationsPage.tsx | 13 +-
assets/admin/pages/ResourcePoolsPage.tsx | 2 +-
assets/admin/pages/ResourceTypesPage.test.tsx | 115 ++++++++++++++++++
assets/admin/pages/ResourceTypesPage.tsx | 110 +++++++++++------
assets/admin/pages/SecretariesPage.tsx | 16 +--
assets/admin/pages/SecretaryDetailPage.tsx | 15 +--
assets/admin/pages/SettingsPage.tsx | 8 +-
assets/admin/pages/SkillsPage.tsx | 62 ++++++----
assets/admin/pages/SmsWalletPage.tsx | 23 ++--
assets/admin/pages/TagsSettingsPage.test.tsx | 3 +-
assets/admin/pages/TagsSettingsPage.tsx | 10 +-
assets/admin/styles.css | 4 +-
38 files changed, 634 insertions(+), 350 deletions(-)
create mode 100644 assets/admin/components/ui/Switch.test.tsx
create mode 100644 assets/admin/components/ui/Switch.tsx
create mode 100644 assets/admin/pages/ResourceTypesPage.test.tsx
diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx
index 41f5a570..e8c7deb4 100644
--- a/assets/admin/components/AppointmentActions.tsx
+++ b/assets/admin/components/AppointmentActions.tsx
@@ -30,6 +30,7 @@ import SearchableSelect from "./ui/SearchableSelect";
import ServiceSlotPicker from "./appointments/ServiceSlotPicker";
import type { PickedService, ServicePick } from "./appointments/ServiceSlotPicker";
import { useDoctorBookingServices } from "../hooks/useDoctorBookingServices";
+import Switch from './ui/Switch';
/** Row actions for the appointments table (Figma عملیات menu). */
type ModalKind = null | "info" | "move" | "transfer" | "replace";
@@ -1009,24 +1010,12 @@ export function ReplaceAppointmentModal({
marginBottom: 12,
}}
>
-
-
- setDepositRequired(e.target.checked)
- }
- />
- بیعانه مورد نیاز است.
-
+
{depositRequired && (
)}
diff --git a/assets/admin/components/AppointmentFiltersModal.tsx b/assets/admin/components/AppointmentFiltersModal.tsx
index 5354484f..89bffbd6 100644
--- a/assets/admin/components/AppointmentFiltersModal.tsx
+++ b/assets/admin/components/AppointmentFiltersModal.tsx
@@ -7,6 +7,7 @@ import type { Appointment } from '../types';
import Modal from './ui/Modal';
import SearchableSelect from './ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
+import Switch from './ui/Switch';
interface Option { uuid: string; name?: string }
@@ -120,9 +121,13 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
وضعیت نوبت
{STATUS_OPTIONS.map(([v, l]) => (
-
- toggleStatus(v)} /> {l}
-
+ toggleStatus(v)}
+ label={l}
+ />
))}
diff --git a/assets/admin/components/DiscountTab.tsx b/assets/admin/components/DiscountTab.tsx
index d5f7ad18..e9124dc9 100644
--- a/assets/admin/components/DiscountTab.tsx
+++ b/assets/admin/components/DiscountTab.tsx
@@ -13,6 +13,7 @@ import PriceInput from './ui/PriceInput';
import PersianDateInput from './ui/PersianDateInput';
import { digitsOnly } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
+import Switch from './ui/Switch';
const TYPE_LABELS: Record = {
patient_tag: 'تگ بیمار',
@@ -333,12 +334,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
-
- set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
-
-
- set('active', e.target.checked)} /> فعال
-
+ set('combinable', v)} label="قابل ترکیب با سایر تخفیفها" />
+ set('active', v)} label="فعال" />
diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx
index 94262e80..e22df190 100644
--- a/assets/admin/components/FreeVisitPrice.tsx
+++ b/assets/admin/components/FreeVisitPrice.tsx
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { digitsOnly } from '../lib/utils';
+import Switch from './ui/Switch';
interface Pricing { free_visit_price_rials: number; require_visit_price: boolean }
@@ -76,23 +77,14 @@ export default function FreeVisitPrice({ doctorUuid, readOnly = false }: { docto
{error}
)}
-
-
- { setRequired(e.target.checked); setError(''); }}
- style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
- />
-
-
- الزامی کردن هزینه ویزیت
-
+
+ { setRequired(v); setError(''); }}
+ label="الزامی کردن هزینه ویزیت"
+ />
+
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
diff --git a/assets/admin/components/InsuranceServiceCategoriesCard.tsx b/assets/admin/components/InsuranceServiceCategoriesCard.tsx
index e83ef2c3..e3697679 100644
--- a/assets/admin/components/InsuranceServiceCategoriesCard.tsx
+++ b/assets/admin/components/InsuranceServiceCategoriesCard.tsx
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { usePermissions } from '../hooks/usePermissions';
+import Switch from './ui/Switch';
interface ServiceCategoryRow {
key: string;
@@ -70,22 +71,14 @@ export default function InsuranceServiceCategoriesCard() {
{rows.map((row) => (
-
-
- toggle(row.key)}
- />
-
-
- {row.label}
-
+ inline
+ checked={row.enabled}
+ disabled={!canUpdate || save.isPending}
+ onChange={() => toggle(row.key)}
+ label={row.label}
+ />
))}
diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx
index 5744ef38..66ca01a0 100644
--- a/assets/admin/components/NewAppointmentDrawer.tsx
+++ b/assets/admin/components/NewAppointmentDrawer.tsx
@@ -11,6 +11,7 @@ import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import { WalletChargeLink } from './AppointmentActions';
import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils';
+import Switch from './ui/Switch';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
@@ -342,10 +343,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
<>
بیعانه:
-
- setDepositRequired(e.target.checked)} />
- بیعانه مورد نیاز است.
-
+
{depositRequired && (
diff --git a/assets/admin/components/PatientsFilterModal.tsx b/assets/admin/components/PatientsFilterModal.tsx
index 4af8c5b1..2543fc69 100644
--- a/assets/admin/components/PatientsFilterModal.tsx
+++ b/assets/admin/components/PatientsFilterModal.tsx
@@ -5,6 +5,7 @@ import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import SearchableSelect from './ui/SearchableSelect';
+import Switch from './ui/Switch';
export interface PatientFilters {
gender?: string; // male | female
@@ -111,11 +112,12 @@ export default function PatientsFilterModal({ open, onClose, value, onApply }: {
وضعیت پرونده
-
- set('has_debt', e.target.checked)} aria-label="فقط پروندههای دارای بدهی" />
-
- فقط پروندههای دارای بدهی
-
+ set('has_debt', v)}
+ label="فقط پروندههای دارای بدهی"
+ />
diff --git a/assets/admin/components/ServiceInsuranceModal.tsx b/assets/admin/components/ServiceInsuranceModal.tsx
index 9430a3a1..c3659d74 100644
--- a/assets/admin/components/ServiceInsuranceModal.tsx
+++ b/assets/admin/components/ServiceInsuranceModal.tsx
@@ -7,6 +7,7 @@ import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import type { ServiceItem } from '../types';
+import Switch from './ui/Switch';
interface TenantInsurance {
uuid: string;
@@ -111,15 +112,12 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
پوشش پیشفرض قرارداد: {contract.coverage_percent}٪
-
- setDraft((d) => ({ ...d, covered: e.target.checked }))}
- />
-
-
+ setDraft((d) => ({ ...d, covered: v }))}
+ ariaLabel="پوشش بیمه برای این سرویس"
+ />
{/* بدنه — فقط وقتی پوشش فعال است */}
diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx
index 9055daaa..1cb18bf5 100644
--- a/assets/admin/components/ServiceItemFormModal.tsx
+++ b/assets/admin/components/ServiceItemFormModal.tsx
@@ -15,6 +15,7 @@ import { useServiceCategories } from '../hooks/useServiceCategories';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
+import Switch from './ui/Switch';
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
@@ -238,17 +239,14 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan
-
-
- form.setValue('bookable', e.target.checked)}
- />
-
-
- نمایش در نوبتدهی
-
+
+ form.setValue('bookable', v, { shouldDirty: true })}
+ label="نمایش در نوبتدهی"
+ />
+
diff --git a/assets/admin/components/paymentMethods/StatusToggle.tsx b/assets/admin/components/paymentMethods/StatusToggle.tsx
index 585a2e59..37484ab3 100644
--- a/assets/admin/components/paymentMethods/StatusToggle.tsx
+++ b/assets/admin/components/paymentMethods/StatusToggle.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import Switch from '../ui/Switch';
/**
* سوییچ وضعیت فعال/غیرفعال — معادل MUI Switch مبدأ با دیزاینسیستم مقصد.
@@ -16,16 +17,9 @@ export default function StatusToggle({
const label = active ? 'فعال' : 'غیرفعال';
return (
-
-
-
-
+
+
+
{label}
);
diff --git a/assets/admin/components/resources/ResourceFormModal.tsx b/assets/admin/components/resources/ResourceFormModal.tsx
index 51e8c43c..4e5657e3 100644
--- a/assets/admin/components/resources/ResourceFormModal.tsx
+++ b/assets/admin/components/resources/ResourceFormModal.tsx
@@ -1,7 +1,11 @@
import React, { useEffect, useState } from 'react';
+import { XMarkIcon } from '@heroicons/react/24/outline';
import Modal from '../ui/Modal';
import { useQuery } from '@tanstack/react-query';
import SearchableSelect from '../ui/SearchableSelect';
+import Field from '../ui/Field';
+import Input from '../ui/Input';
+import Switch from '../ui/Switch';
import { api, type ApiResponse } from '../../lib/api';
import type { ClinicResource, ResourcePayload, ResourceType } from '../../types';
import { useResourceDetail } from '../../hooks/useResources';
@@ -98,8 +102,14 @@ export default function ResourceFormModal({
return (
-
- setName(e.target.value)} placeholder="لیزر آلکساندرایت ۱" />
+
+ setName(e.target.value)}
+ placeholder="لیزر آلکساندرایت ۱"
+ />
@@ -134,14 +144,17 @@ export default function ResourceFormModal({
)}
-
- setCapacity(e.target.value)} />
+ {/* `numeric` بهجای `type="number"`: ورودیِ عددیِ فارسی در `type="number"`
+ نامعتبر است و مرورگر رشتهٔ خالی میدهد — یعنی «۳» تایپشده به ۰ میرسید.
+ `Input numeric` ارقام را زنده به لاتین برمیگرداند. */}
+
+ setCapacity(e.target.value)} />
-
- setSetupMinutes(e.target.value)} />
+
+ setSetupMinutes(e.target.value)} />
-
- setCleanupMinutes(e.target.value)} />
+
+ setCleanupMinutes(e.target.value)} />
@@ -152,7 +165,7 @@ export default function ResourceFormModal({
-
- setActive(e.target.checked)} />
- منبع فعال است
-
+
{/* غیرفعالکردن نوبتهای ثبتشده را لغو نمیکند؛ فقط از جستجوی وقتِ بعدی حذف
میشود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
@@ -233,12 +250,3 @@ export default function ResourceFormModal({
);
}
-
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
- {label}
- {children}
-
- );
-}
diff --git a/assets/admin/components/resources/ResourceServicesPanel.tsx b/assets/admin/components/resources/ResourceServicesPanel.tsx
index 1eceba51..ebbc0f0b 100644
--- a/assets/admin/components/resources/ResourceServicesPanel.tsx
+++ b/assets/admin/components/resources/ResourceServicesPanel.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import SearchableSelect from '../ui/SearchableSelect';
+import Switch from '../ui/Switch';
import { formatRial } from '../../lib/utils';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
@@ -106,14 +107,13 @@ export default function ResourceServicesPanel({
{line.service_name}
-
- patch(index, { active: e.target.checked })}
- />
- فعال
-
+
patch(index, { active: v })}
+ label="فعال"
+ ariaLabel={`فعال بودن سرویس ${line.service_name}`}
+ />
-
-
+ editRange(day, index, { endOfDay: e.target.checked })}
+ onChange={(v) => editRange(day, index, { endOfDay: v })}
+ label="تا پایان روز"
+ ariaLabel={`تا پایان روز برای شیفت ${formatNumber(index + 1)} روز ${label}`}
/>
- تا پایان روز
-
+
{canUpdate && (
{/* header + toggle */}
-
+
نوبتدهی آنلاین
-
- setMeta(m => ({ ...m, online_booking_enabled: e.target.checked }))}
- />
-
-
-
-
+
setMeta(m => ({ ...m, online_booking_enabled: v }))}
+ ariaLabel="نوبتدهی آنلاین"
+ />
+
{/* booking window control */}
diff --git a/assets/admin/components/ui/DoctorPermissionsModal.tsx b/assets/admin/components/ui/DoctorPermissionsModal.tsx
index dcfdd420..e3fb829d 100644
--- a/assets/admin/components/ui/DoctorPermissionsModal.tsx
+++ b/assets/admin/components/ui/DoctorPermissionsModal.tsx
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import Modal from './Modal';
+import Switch from './Switch';
/** envelope کامل — همان چیزی که بکاند برمیگرداند، بدون flatten. */
export interface PermissionEnvelope {
@@ -146,15 +147,14 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
در حال بارگذاری...
) : (
<>
-
-
+ setActive(v => !v)}
- style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
+ onChange={setActive}
+ label="دسترسی این پزشک به کلینیک فعال باشد"
+ hint="خاموشکردنش کل جدول زیر را بیاثر میکند."
/>
- دسترسی این پزشک به کلینیک فعال باشد
-
+
@@ -177,14 +177,17 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
return — ;
}
return (
-
- toggle(resource, action)}
- style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
- />
+
+ {/* سوییچ در سلول جدول: بدون برچسبِ دیداری، پس نامِ
+ دسترسیپذیر از ترکیب بخش و ستون ساخته میشود. */}
+
+ toggle(resource, action)}
+ ariaLabel={`${config.label} — ${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
+ />
+
);
})}
diff --git a/assets/admin/components/ui/Switch.test.tsx b/assets/admin/components/ui/Switch.test.tsx
new file mode 100644
index 00000000..a57a01f1
--- /dev/null
+++ b/assets/admin/components/ui/Switch.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import Switch from './Switch';
+
+/**
+ * سوییچ تنها شکل مجاز ورودی boolean در پنل است؛ چکباکس نیتیو جایی ندارد.
+ * این تستها قرارداد آن را قفل میکنند: نقش `switch`، نامِ دسترسیپذیرِ کوتاه،
+ * و ظاهرِ آمده از کلاسهای دیزاینسیستم.
+ */
+describe('Switch', () => {
+ it('نقشش switch است نه checkbox', () => {
+ render( {}} label="فعال است" />);
+
+ expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
+ });
+
+ it('ظاهرش از کلاسهای دیزاینسیستم میآید', () => {
+ const { container } = render( {}} ariaLabel="وضعیت" />);
+
+ expect(container.querySelector('.switch')).not.toBeNull();
+ expect(container.querySelector('.switch-track .switch-thumb')).not.toBeNull();
+ });
+
+ it('کلیک مقدار تازه را میدهد، نه رویداد خام', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('switch'));
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ /** متنِ `` شاملِ hint میشود؛ نامِ دسترسیپذیر باید همان برچسبِ کوتاه بماند. */
+ it('hint وارد نام دسترسیپذیر نمیشود', () => {
+ render( {}} label="فعال است" hint="توضیح بلند و اضافی" />);
+
+ expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
+ expect(screen.getByText('توضیح بلند و اضافی')).toBeInTheDocument();
+ });
+
+ it('کلیک روی برچسب هم سوییچ را میزند', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByText('فعال است'));
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ /* `fireEvent.click` در jsdom قید `disabled` را دور میزند، پس خودِ صفت سنجیده
+ میشود — همان چیزی که مرورگر واقعی به آن تکیه میکند. */
+ it('در حالت غیرفعال، ورودی قفل است', () => {
+ render( {}} label="فعال است" disabled />);
+
+ expect(screen.getByRole('switch')).toBeDisabled();
+ });
+
+ /** بدون برچسبِ دیداری — سلول جدول و ردیف فشرده — نام از `ariaLabel` میآید. */
+ it('بدون label، نام از ariaLabel میآید', () => {
+ render( {}} ariaLabel="دسترسی نوبتها" />);
+
+ expect(screen.getByRole('switch', { name: 'دسترسی نوبتها' })).toBeInTheDocument();
+ });
+});
diff --git a/assets/admin/components/ui/Switch.tsx b/assets/admin/components/ui/Switch.tsx
new file mode 100644
index 00000000..265cd9ca
--- /dev/null
+++ b/assets/admin/components/ui/Switch.tsx
@@ -0,0 +1,87 @@
+import React, { useId } from 'react';
+
+interface Props {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ /** متن کنار سوییچ. بدون آن، `ariaLabel` الزامی است. */
+ label?: React.ReactNode;
+ /** توضیح یکخطی زیر برچسب. */
+ hint?: React.ReactNode;
+ disabled?: boolean;
+ /**
+ * چیدمان فشرده: سوییچ و متن کنار هم، بدون کشآمدن تا عرض والد.
+ * برای ردیف فهرست و سلول جدول. پیشفرض (`false`) ردیف تنظیمات است:
+ * متن راست، سوییچ چپ.
+ */
+ inline?: boolean;
+ ariaLabel?: string;
+ id?: string;
+}
+
+/**
+ * سوییچ فعال/غیرفعال — تنها شکل مجاز ورودی boolean در پنل.
+ *
+ * چکباکس نیتیو در این پنل استفاده نمیشود: ظاهرش را مرورگر تعیین میکند، با تم تیره
+ * و توکنهای رنگی نمیخواند، و ارتفاعش زیر هدف لمسی میماند. کلاسهای `.switch` در
+ * `styles.css` همین حالا ظاهر درست را دارند؛ این کامپوننت فقط آنها را یکجا و با
+ * برچسبِ متصل بستهبندی میکند.
+ *
+ * `input` نیتیو زیر لایهٔ ظاهری میماند تا کیبورد و صفحهخوان و `:focus-visible`
+ * دستنخورده کار کنند.
+ */
+export default function Switch({
+ checked, onChange, label, hint, disabled, inline, ariaLabel, id,
+}: Props) {
+ const autoId = useId();
+ const inputId = id ?? autoId;
+
+ const control = (
+
+ ` شاملِ `hint` هم میشود و نامی میسازد
+ که هیچکس به آن فکر نکرده. با `aria-label`، نام همان برچسبِ کوتاه میماند. */
+ aria-label={ariaLabel ?? (typeof label === 'string' ? label : undefined)}
+ onChange={(e) => onChange(e.target.checked)}
+ />
+
+
+ );
+
+ if (label === undefined) return control;
+
+ return (
+
+ {inline ? (
+ <>
+ {control}
+ {label}
+ >
+ ) : (
+ <>
+
+ {label}
+ {hint && {hint} }
+
+ {control}
+ >
+ )}
+
+ );
+}
diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx
index 703e31c1..1ce6980e 100644
--- a/assets/admin/pages/AdminSubscriptionPage.tsx
+++ b/assets/admin/pages/AdminSubscriptionPage.tsx
@@ -85,6 +85,13 @@ const PLAN_DISPLAY: Record = {
// ── Switch toggle ─────────────────────────────────────────────────────────
+/**
+ * نسخهٔ uncontrolled سوییچ برای `react-hook-form`.
+ *
+ * `components/ui/Switch` کنترلشده است (`checked` + `onChange`) و با اسپردِ
+ * `register()` — که `ref` و `onChange` نیتیو میدهد — جور در نمیآید. ظاهر هر دو از
+ * یک کلاس `.switch` میآید، پس تفاوت دیداری ندارند.
+ */
const SwitchToggle = React.forwardRef>(
function SwitchToggle(props, ref) {
return (
diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx
index f6a5a222..f0d379ce 100644
--- a/assets/admin/pages/AppointmentCreatePage.tsx
+++ b/assets/admin/pages/AppointmentCreatePage.tsx
@@ -20,6 +20,7 @@ import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPick
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { digitsOnly, todayIso } from '../lib/utils';
+import Switch from '../components/ui/Switch';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
@@ -611,20 +612,12 @@ export default function AppointmentCreatePage() {
{/* بیعانه */}
بیعانه
-
-
- setDepositRequired(e.target.checked)}
- style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }} />
-
-
- بیعانه مورد نیاز است.
-
+
{depositRequired && (
diff --git a/assets/admin/pages/AppointmentEditPage.tsx b/assets/admin/pages/AppointmentEditPage.tsx
index 0fa27c98..e05acdf0 100644
--- a/assets/admin/pages/AppointmentEditPage.tsx
+++ b/assets/admin/pages/AppointmentEditPage.tsx
@@ -16,6 +16,7 @@ import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker';
+import Switch from '../components/ui/Switch';
interface Option { uuid: string; name?: string; full_name?: string }
@@ -358,10 +359,12 @@ export default function AppointmentEditPage() {
بیعانه:
-
- setDepositRequired(e.target.checked)} />
- بیعانه مورد نیاز است.
-
+
{depositRequired && (
<>
diff --git a/assets/admin/pages/CatalogCategoriesPage.tsx b/assets/admin/pages/CatalogCategoriesPage.tsx
index de970b8b..d9347373 100644
--- a/assets/admin/pages/CatalogCategoriesPage.tsx
+++ b/assets/admin/pages/CatalogCategoriesPage.tsx
@@ -11,6 +11,7 @@ import { usePermissions } from '../hooks/usePermissions';
import { useCatalogCategories, useCategoryIncludes } from '../hooks/useCatalogCategories';
import type { CatalogCategory } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
+import Switch from '../components/ui/Switch';
/** یک ردیف از درخت، صافشده — با عمق، تا تورفتگی نشان دهد کجای درخت است. */
type Row = CatalogCategory & { depth: number };
@@ -204,10 +205,7 @@ function CategoryFormModal({
/>
-
- setActive(e.target.checked)} />
- فعال
-
+
diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx
index 57263dc9..15367ec7 100644
--- a/assets/admin/pages/ClinicDetailPage.tsx
+++ b/assets/admin/pages/ClinicDetailPage.tsx
@@ -26,6 +26,7 @@ import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore';
import { latinDigitsField } from '../lib/forms';
+import Switch from '../components/ui/Switch';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -107,10 +108,9 @@ function MultiCheckList({ options, selected, onChange, placeholder }: {
{filtered.length === 0
?
نتیجهای یافت نشد
: filtered.map(o => (
-
- toggle(o.id)} />
- {o.name}
-
+
+ toggle(o.id)} label={o.name} />
+
))}
@@ -281,10 +281,12 @@ function EditModal({ clinic, onClose, onSaved }: {
توضیحات
-
-
- کلینیک ۲۴ ساعته (۷ روز هفته)
-
+ setValue('is_247', v, { shouldDirty: true })}
+ label="کلینیک ۲۴ ساعته (۷ روز هفته)"
+ />
>
)}
diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx
index 934e51e5..e4fa0d77 100644
--- a/assets/admin/pages/ClinicServicesPage.tsx
+++ b/assets/admin/pages/ClinicServicesPage.tsx
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import FeatureGate from '../components/ui/FeatureGate';
+import Switch from '../components/ui/Switch';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
type SectionForm = z.infer;
@@ -172,10 +173,11 @@ function ClinicServicesPageInner() {
e.stopPropagation()}>
وضعیت:
-
- toggleSection.mutate({ uuid: s.uuid, active: !s.active })} />
-
-
+ toggleSection.mutate({ uuid: s.uuid, active: !s.active })}
+ ariaLabel={`وضعیت ${s.name}`}
+ />
{s.active ? 'فعال' : 'غیرفعال'}
diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx
index cb82ae96..69f97d3f 100644
--- a/assets/admin/pages/DoctorDetailPage.tsx
+++ b/assets/admin/pages/DoctorDetailPage.tsx
@@ -37,6 +37,7 @@ import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
+import Switch from '../components/ui/Switch';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -396,8 +397,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
filteredFlat.length > 0
? filteredFlat.map(s => (
- toggleSelect(s.id)}
- className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
+ toggleSelect(s.id)} ariaLabel={s.name} />
{s.name}
{s.parent_id !== null && (
@@ -420,8 +420,9 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
? (isOpen
?
: )
- :
+ :
+ {}} disabled ariaLabel={parent.name} />
+
}
0 ? 'text-[var(--primary)] dark:text-[var(--primary)]' : 'text-[var(--text)]'}`}>
{parent.name}
@@ -434,8 +435,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
{isOpen && children.map(child => (
- toggleSelect(child.id)}
- className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
+ toggleSelect(child.id)} ariaLabel={child.name} />
{child.name}
))}
@@ -528,8 +528,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
{visibleServices.map(svc => (
- toggle(svc.id)}
- className="rounded border-[var(--border-2)] text-[var(--success)] shrink-0" />
+ toggle(svc.id)} ariaLabel={svc.name} />
{svc.name}
))}
diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx
index 7df09272..9316d50b 100644
--- a/assets/admin/pages/MySecretariesPage.tsx
+++ b/assets/admin/pages/MySecretariesPage.tsx
@@ -11,6 +11,7 @@ import { formatDate, digitsOnly, IRAN_MOBILE_RE, IRAN_NATIONAL_CODE_RE } from ".
import { useSubscription } from "../hooks/useSubscription";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
+import Switch from '../components/ui/Switch';
// ── SVG icons (copied verbatim from clinic-pro-tauri) ───────────────────────
@@ -298,15 +299,11 @@ function PermissionAccordions({
key={item.key}
className="flex items-center gap-[11px] cursor-pointer py-[9px] min-h-[42px]"
>
-
- onChange(section.key, item.key, e.target.checked)
- }
- className="w-[20px] h-[20px] shrink-0"
- style={{ accentColor: "var(--primary)", cursor: "pointer" }}
+ onChange={(v) => onChange(section.key, item.key, v)}
+ ariaLabel={item.label}
/>
{item.label}
@@ -872,12 +869,7 @@ function DoctorMultiSelect({
(checked ? "bg-[var(--primary-soft)] dark:bg-[var(--surface-3)]" : "hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]")
}
>
- toggle(d.uuid)}
- />
+ toggle(d.uuid)} ariaLabel={d.name} />
{d.name}
diff --git a/assets/admin/pages/RecordNumberSettingsPage.tsx b/assets/admin/pages/RecordNumberSettingsPage.tsx
index 82bf0e39..c48224fd 100644
--- a/assets/admin/pages/RecordNumberSettingsPage.tsx
+++ b/assets/admin/pages/RecordNumberSettingsPage.tsx
@@ -3,6 +3,7 @@ import SettingsLayout from '../components/layout/SettingsLayout';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
+import Switch from '../components/ui/Switch';
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو میرود' },
@@ -71,29 +72,13 @@ export default function RecordNumberSettingsPage() {
)}
-
-
- setEnabled(e.target.checked)}
- style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'inherit' }}
- />
-
-
- شمارهگذاری خودکار پرونده
-
+
الگوی شماره
diff --git a/assets/admin/pages/RepresentationsPage.tsx b/assets/admin/pages/RepresentationsPage.tsx
index 0e2878e6..e37d9191 100644
--- a/assets/admin/pages/RepresentationsPage.tsx
+++ b/assets/admin/pages/RepresentationsPage.tsx
@@ -20,6 +20,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
+import Switch from '../components/ui/Switch';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
@@ -64,7 +65,7 @@ export default function RepresentationsPage() {
},
});
- const { register, handleSubmit, reset, control, watch, formState: { errors, isSubmitting } } = useForm
({
+ const { register, handleSubmit, reset, control, watch, setValue, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
@@ -203,10 +204,12 @@ export default function RepresentationsPage() {
{errors.mobile_number && {errors.mobile_number.message}
}
-
-
- نماینده سراسری (دامنه اختصاصی — فقط پزشکان/کلینیکهای خودش نمایش داده میشوند)
-
+ setValue('is_global', v, { shouldDirty: true })}
+ label="نماینده سراسری (دامنه اختصاصی — فقط پزشکان/کلینیکهای خودش نمایش داده میشوند)"
+ />
دامنه
diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx
index 17a18615..2bcdebb0 100644
--- a/assets/admin/pages/ResourcePoolsPage.tsx
+++ b/assets/admin/pages/ResourcePoolsPage.tsx
@@ -102,7 +102,7 @@ export default function ResourcePoolsPage() {
>
{p.active ? 'غیرفعال کردن' : 'فعال کردن'}
- setToDelete(p)}>
+ setToDelete(p)}>
حذف
diff --git a/assets/admin/pages/ResourceTypesPage.test.tsx b/assets/admin/pages/ResourceTypesPage.test.tsx
new file mode 100644
index 00000000..8ae96790
--- /dev/null
+++ b/assets/admin/pages/ResourceTypesPage.test.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from '../test/utils';
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+vi.mock('../hooks/usePermissions', () => ({
+ usePermissions: () => ({ can: () => true }),
+}));
+
+import { api } from '../lib/api';
+import ResourceTypesPage from './ResourceTypesPage';
+
+const get = api.get as ReturnType;
+const post = api.post as ReturnType;
+
+const LASER = { uuid: 't1', code: 'laser', name: 'دستگاه لیزر', is_system: false, active: true, resources_count: 2, created_at: 0, updated_at: 0 };
+const SYSTEM = { uuid: 't2', code: 'doctor', name: 'پزشک', is_system: true, active: true, resources_count: 5, created_at: 0, updated_at: 0 };
+
+function mockApi() {
+ get.mockResolvedValue({ success: true, data: [LASER, SYSTEM] });
+ post.mockResolvedValue({ success: true, data: LASER });
+}
+
+const openCreate = async () => {
+ fireEvent.click(await screen.findByRole('button', { name: /افزودن نوع/ }));
+ return screen.findByText('افزودن نوع منبع');
+};
+
+describe('ResourceTypesPage', () => {
+ beforeEach(() => { vi.clearAllMocks(); mockApi(); });
+
+ it('نوعها را با کد و نشان سیستمی فهرست میکند', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+
+ expect(await screen.findByText('دستگاه لیزر')).toBeInTheDocument();
+ expect(screen.getByText('laser')).toBeInTheDocument();
+ expect(screen.getByText('سیستمی')).toBeInTheDocument();
+ });
+
+ /** نوع سیستمی پلِ خودکار منابع است؛ حذفش باید بسته باشد، نه صرفاً پشیمانکننده. */
+ it('حذف نوع سیستمی غیرفعال است', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+
+ await screen.findByText('پزشک');
+ const deletes = screen.getAllByRole('button', { name: 'حذف' });
+ expect(deletes[1]).toBeDisabled();
+ expect(deletes[0]).toBeEnabled();
+ });
+
+ /**
+ * مودال پیش از این فیلدهایش را دستی میساخت: `field-block` روی خودِ ` `
+ * (که رَپر است، نه اینپوت) اینپوت را بیکادر میکرد، و چکباکس نیتیو بود.
+ * فیلدها باید از `Input`/`Field` دیزاینسیستم بیایند و سوییچ از کلاس `.switch`.
+ */
+ describe('مودال افزودن', () => {
+ it('فیلدها کلاس اینپوت دیزاینسیستم را دارند، نه کلاس رَپر', async () => {
+ const { container } = renderWithProviders( , { route: '/admin/resources/types' });
+ await openCreate();
+
+ const code = screen.getByPlaceholderText('laser_device');
+ const name = screen.getByPlaceholderText('دستگاه لیزر');
+
+ expect(code).toHaveClass('cp-input');
+ expect(name).toHaveClass('cp-input');
+ expect(container.querySelector('input.field-block')).toBeNull();
+ });
+
+ it('وضعیت فعال با سوییچ نمایش داده میشود نه چکباکس خام', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+ await openCreate();
+
+ const toggle = screen.getByLabelText(/فعال است/) as HTMLInputElement;
+ expect(toggle.type).toBe('checkbox');
+ expect(toggle.closest('.switch')).not.toBeNull();
+ });
+
+ it('هر فیلد لیبل متصل دارد', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+ await openCreate();
+
+ expect(screen.getByLabelText('کد (انگلیسی)')).toBe(screen.getByPlaceholderText('laser_device'));
+ expect(screen.getByLabelText('نام نمایشی')).toBe(screen.getByPlaceholderText('دستگاه لیزر'));
+ });
+
+ it('کد نامعتبر پیام خطا میدهد و ذخیره را میبندد', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+ await openCreate();
+
+ fireEvent.change(screen.getByPlaceholderText('laser_device'), { target: { value: 'Laser Device' } });
+
+ expect(await screen.findByText('فقط حروف کوچک انگلیسی، عدد و زیرخط.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
+ });
+
+ /** سه فیلد ساده؛ Enter باید ثبت کند و کاربر را سراغ ماوس نفرستد. */
+ it('Enter فرم را ثبت میکند', async () => {
+ renderWithProviders( , { route: '/admin/resources/types' });
+ await openCreate();
+
+ fireEvent.change(screen.getByPlaceholderText('laser_device'), { target: { value: 'unit_chair' } });
+ fireEvent.change(screen.getByPlaceholderText('دستگاه لیزر'), { target: { value: 'یونیت' } });
+ // مودال در Portal روی document.body است، نه داخل container رندر.
+ fireEvent.submit(document.querySelector('#resource-type-form')!);
+
+ await waitFor(() => expect(post).toHaveBeenCalledWith(
+ '/api/v1/resource-types',
+ { code: 'unit_chair', name: 'یونیت' },
+ ));
+ });
+ });
+});
diff --git a/assets/admin/pages/ResourceTypesPage.tsx b/assets/admin/pages/ResourceTypesPage.tsx
index 9261946b..fd8d53d7 100644
--- a/assets/admin/pages/ResourceTypesPage.tsx
+++ b/assets/admin/pages/ResourceTypesPage.tsx
@@ -4,6 +4,9 @@ import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
+import Field from '../components/ui/Field';
+import Input from '../components/ui/Input';
+import Switch from '../components/ui/Switch';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
@@ -75,9 +78,10 @@ export default function ResourceTypesPage() {
setEditing({ open: true, type: t })}>
ویرایش
+ {/* کنش مخرب باید رنگ هشدار داشته باشد، نه ظاهرِ «ویرایش» */}
setToDelete(t)}
@@ -139,53 +143,81 @@ function TypeModal({
const codeValid = /^[a-z0-9_]{1,40}$/.test(code);
const invalid = name.trim() === '' || (!isEdit && !codeValid);
+ const submit = () => {
+ if (saving || invalid) return;
+ onSave({ code: code.trim(), name: name.trim(), active });
+ };
+
return (
-
-
+ }
+ >
+ {/* `
);
}
diff --git a/assets/admin/pages/SecretariesPage.tsx b/assets/admin/pages/SecretariesPage.tsx
index 130b3e66..2d85ce08 100644
--- a/assets/admin/pages/SecretariesPage.tsx
+++ b/assets/admin/pages/SecretariesPage.tsx
@@ -13,6 +13,7 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
+import Switch from '../components/ui/Switch';
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
appointments: { view: true, create: false, cancel: false, update_status: false },
@@ -208,13 +209,14 @@ function PermissionsMatrix({
return — ;
}
return (
-
- toggle(section, action)}
- style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
- />
+
+
+ toggle(section, action)}
+ ariaLabel={`${section} — ${action}`}
+ />
+
);
})}
diff --git a/assets/admin/pages/SecretaryDetailPage.tsx b/assets/admin/pages/SecretaryDetailPage.tsx
index 103ea984..e895e43b 100644
--- a/assets/admin/pages/SecretaryDetailPage.tsx
+++ b/assets/admin/pages/SecretaryDetailPage.tsx
@@ -8,6 +8,7 @@ import type { ApiResponse } from '../lib/api';
import type { Secretary } from '../types';
import { digitsOnly, formatDate, formatNumber, formatRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
+import Switch from '../components/ui/Switch';
/** یک ردیف label:value با همان تم کارتهای موجود. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -95,15 +96,11 @@ export default function SecretaryDetailPage() {
-
- setEnabled(e.target.checked)}
- />
-
-
+
محاسبه درآمد از نوبتهای آنلاین فعال باشد
diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx
index c24e77ca..ea77e780 100644
--- a/assets/admin/pages/SettingsPage.tsx
+++ b/assets/admin/pages/SettingsPage.tsx
@@ -13,6 +13,7 @@ import {
WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline';
import ConfirmDialog from '../components/ui/ConfirmDialog';
+import Switch from '../components/ui/Switch';
interface TaxHistoryRow {
tax_percent: number;
@@ -131,10 +132,9 @@ function SectionHead({ s }: { s: SectionDef }) {
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
-
-
-
-
+
+
+
);
}
diff --git a/assets/admin/pages/SkillsPage.tsx b/assets/admin/pages/SkillsPage.tsx
index f06ffadc..14e930c5 100644
--- a/assets/admin/pages/SkillsPage.tsx
+++ b/assets/admin/pages/SkillsPage.tsx
@@ -4,6 +4,9 @@ import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
+import Field from '../components/ui/Field';
+import Input from '../components/ui/Input';
+import Switch from '../components/ui/Switch';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
@@ -74,7 +77,7 @@ export default function SkillsPage() {
0}
title={(s.resources_count ?? 0) > 0 ? 'اول از منابع برداشته شود' : undefined}
onClick={() => setToDelete(s)}
@@ -131,30 +134,47 @@ function SkillModal({
}, [open, skill]);
return (
-
-
-
- نام مهارت
- setName(e.target.value)} placeholder="لیزر آلکساندرایت" />
-
-
-
- setActive(e.target.checked)} />
- فعال است
-
-
-
+
انصراف
- onSave({ name: name.trim(), active })}
- >
+
{saving ? 'در حال ذخیره...' : 'ذخیره'}
-
+ }
+ >
+
);
}
diff --git a/assets/admin/pages/SmsWalletPage.tsx b/assets/admin/pages/SmsWalletPage.tsx
index ff6794a4..88923a40 100644
--- a/assets/admin/pages/SmsWalletPage.tsx
+++ b/assets/admin/pages/SmsWalletPage.tsx
@@ -22,6 +22,7 @@ import PageHeader from '../components/ui/PageHeader';
import FeatureGate from '../components/ui/FeatureGate';
import { usePermissions } from '../hooks/usePermissions';
import { numericField } from '../lib/forms';
+import Switch from '../components/ui/Switch';
const chargeSchema = z.object({
amount_rials: z.coerce.number().min(1000, 'حداقل مبلغ ۱٬۰۰۰ تومان است'),
@@ -262,14 +263,13 @@ function SmsWalletPageInner() {
)}
-
-
+ setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
+ onChange={(v) => setLocalSettings({ ...currentSettings, reminder_enabled: v })}
+ ariaLabel="یادآوری نوبت"
/>
-
-
+
{/* ردیف: پیامک بعد از ویزیت */}
@@ -285,14 +285,13 @@ function SmsWalletPageInner() {
متن تشکر پس از پایان مراجعه برای بیمار ارسال میشود
-
-
+ setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
+ onChange={(v) => setLocalSettings({ ...currentSettings, post_visit_enabled: v })}
+ ariaLabel="پیام پس از مراجعه"
/>
-
-
+
{/* محتوای بازشونده */}
diff --git a/assets/admin/pages/TagsSettingsPage.test.tsx b/assets/admin/pages/TagsSettingsPage.test.tsx
index 2f131258..4dde7f3d 100644
--- a/assets/admin/pages/TagsSettingsPage.test.tsx
+++ b/assets/admin/pages/TagsSettingsPage.test.tsx
@@ -51,7 +51,8 @@ describe('TagsSettingsPage', () => {
fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ }));
fireEvent.change(screen.getByPlaceholderText('نام برچسب را وارد کنید'), { target: { value: 'بدهکار' } });
fireEvent.click(screen.getByRole('button', { name: 'رنگ #0088ff' }));
- fireEvent.click(screen.getByRole('checkbox', { name: 'وضعیت برچسب' })); // toggle off (default active)
+ // `role="switch"` است نه checkbox — ورودی boolean در پنل همیشه سوییچ است.
+ fireEvent.click(screen.getByRole('switch', { name: 'وضعیت برچسب' })); // toggle off (default active)
fireEvent.click(screen.getByRole('button', { name: 'ثبت برچسب' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/tenant-tag', expect.objectContaining({
diff --git a/assets/admin/pages/TagsSettingsPage.tsx b/assets/admin/pages/TagsSettingsPage.tsx
index 36eaf746..fd71d08f 100644
--- a/assets/admin/pages/TagsSettingsPage.tsx
+++ b/assets/admin/pages/TagsSettingsPage.tsx
@@ -11,6 +11,7 @@ import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SettingsLayout from '../components/layout/SettingsLayout';
import { usePermissions } from '../hooks/usePermissions';
+import Switch from '../components/ui/Switch';
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
@@ -142,10 +143,11 @@ export default function TagsSettingsPage() {
diff --git a/assets/admin/styles.css b/assets/admin/styles.css
index e1467913..026b7235 100644
--- a/assets/admin/styles.css
+++ b/assets/admin/styles.css
@@ -997,8 +997,10 @@ html, body { max-width: 100%; overflow-x: hidden; }
.wh-time .lbl { font-size: 11.5px; color: var(--text-3); flex-shrink: 0; }
.wh-time input { font-size: 13.5px; }
/* چکباکس خام ~۱۳px است و زیر حداقلِ ارتفاعِ لمسی میافتد. */
+/* «تا پایان روز» حالا سوییچ است، نه چکباکس. قاعدهٔ ابعادِ input برداشته شد چون
+ بعد از `.switch input` در فایل میآمد و با همان specificity آن را بازنویسی
+ میکرد — سوییچ به یک مربع ۱۷px تبدیل میشد. */
.wh-eod { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 12.5px; color: var(--text-2); cursor: pointer; }
-.wh-eod input { width: 17px; height: 17px; accent-color: var(--primary); cursor: pointer; }
@media (max-width: 720px) {
.wh-day { grid-template-columns: minmax(0, 1fr); gap: 8px; }