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.
This commit is contained in:
hamed
2026-08-05 16:09:46 +03:30
parent f9bbdf1e7c
commit 0e7970d6e0
38 changed files with 634 additions and 350 deletions
+7 -18
View File
@@ -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,
}}
>
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 13,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={depositRequired}
onChange={(e) =>
setDepositRequired(e.target.checked)
}
/>
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<WalletChargeLink mobile={effectiveMobile} />
)}
@@ -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 }: {
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{STATUS_OPTIONS.map(([v, l]) => (
<label key={v} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.statuses.includes(v)} onChange={() => toggleStatus(v)} /> {l}
</label>
<Switch
key={v}
inline
checked={f.statuses.includes(v)}
onChange={() => toggleStatus(v)}
label={l}
/>
))}
</div>
+3 -6
View File
@@ -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<DiscountRuleType, string> = {
patient_tag: 'تگ بیمار',
@@ -333,12 +334,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
</div>
<div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.combinable} onChange={(e) => set('combinable', e.target.checked)} /> قابل ترکیب با سایر تخفیفها
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.active} onChange={(e) => set('active', e.target.checked)} /> فعال
</label>
<Switch inline checked={f.combinable} onChange={(v) => set('combinable', v)} label="قابل ترکیب با سایر تخفیف‌ها" />
<Switch inline checked={f.active} onChange={(v) => set('active', v)} label="فعال" />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
+9 -17
View File
@@ -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
<p style={{ fontSize: 12, color: 'var(--danger)', margin: '6px 0 0' }}>{error}</p>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 13, cursor: 'pointer', marginTop: 16 }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: required ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
}}>
<input
type="checkbox" checked={required} role="switch" aria-label="الزامی کردن هزینه ویزیت"
onChange={(e) => { setRequired(e.target.checked); setError(''); }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: required ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
الزامی کردن هزینه ویزیت
</label>
<div style={{ marginTop: 16 }}>
<Switch
inline
checked={required}
onChange={(v) => { setRequired(v); setError(''); }}
label="الزامی کردن هزینه ویزیت"
/>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '6px 0 0', lineHeight: 1.7 }}>
با فعال شدن این گزینه، وارد کردن هزینه ویزیت در تنظیمات، ثبت مراجعه (سرویس)، فاکتور سرویس و ثبت نوبت الزامی میشود و بدون آن امکان ذخیره وجود ندارد.
</p>
@@ -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() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 18 }}>
{rows.map((row) => (
<label
<Switch
key={row.key}
style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: canUpdate ? 'pointer' : 'default' }}
>
<span className="switch">
<input
type="checkbox"
aria-label={row.label}
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>{row.label}</span>
</label>
inline
checked={row.enabled}
disabled={!canUpdate || save.isPending}
onChange={() => toggle(row.key)}
label={row.label}
/>
))}
</div>
</div>
@@ -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
<>
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
</div>
{depositRequired && (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
@@ -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 }: {
<div>
<label style={label}>وضعیت پرونده</label>
<label className="switch" title="فقط پرونده‌های دارای بدهی" style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
<input type="checkbox" checked={!!f.has_debt} onChange={(e) => set('has_debt', e.target.checked)} aria-label="فقط پرونده‌های دارای بدهی" />
<span className="switch-track"><span className="switch-thumb" /></span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>فقط پروندههای دارای بدهی</span>
</label>
<Switch
inline
checked={!!f.has_debt}
onChange={(v) => set('has_debt', v)}
label="فقط پرونده‌های دارای بدهی"
/>
</div>
<div>
@@ -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}٪
</div>
</div>
<label className="switch">
<input
type="checkbox"
checked={draft.covered}
disabled={isLoading}
onChange={(e) => setDraft((d) => ({ ...d, covered: e.target.checked }))}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<Switch
checked={draft.covered}
disabled={isLoading}
onChange={(v) => setDraft((d) => ({ ...d, covered: v }))}
ariaLabel="پوشش بیمه برای این سرویس"
/>
</div>
{/* بدنه — فقط وقتی پوشش فعال است */}
@@ -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
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
<span className="switch">
<input
type="checkbox"
checked={form.watch('bookable') ?? false}
onChange={(e) => form.setValue('bookable', e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
</label>
<div style={{ padding: '9px 0' }}>
<Switch
inline
checked={form.watch('bookable') ?? false}
onChange={(v) => form.setValue('bookable', v, { shouldDirty: true })}
label="نمایش در نوبت‌دهی"
/>
</div>
</div>
<div>
@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="switch" title={label}>
<input
type="checkbox"
checked={active}
onChange={onToggle}
disabled={disabled}
aria-label={label}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<span title={label}>
<Switch checked={active} onChange={onToggle} disabled={disabled} ariaLabel={label} />
</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{label}</span>
</div>
);
@@ -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 (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش منبع' : 'افزودن منبع'} size="lg">
<div style={{ display: 'grid', gap: 14 }}>
<Field label="نام منبع">
<input className="field" value={name} onChange={(e) => setName(e.target.value)} placeholder="لیزر آلکساندرایت ۱" />
<Field label="نام منبع" htmlFor="resource-name">
<Input
id="resource-name"
value={name}
autoFocus
onChange={(e) => setName(e.target.value)}
placeholder="لیزر آلکساندرایت ۱"
/>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
@@ -134,14 +144,17 @@ export default function ResourceFormModal({
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
<Field label="ظرفیت هم‌زمان">
<input className="field" type="number" min={1} value={capacity} onChange={(e) => setCapacity(e.target.value)} />
{/* `numeric` به‌جای `type="number"`: ورودیِ عددیِ فارسی در `type="number"`
نامعتبر است و مرورگر رشتهٔ خالی می‌دهد — یعنی «۳» تایپ‌شده به ۰ می‌رسید.
`Input numeric` ارقام را زنده به لاتین برمی‌گرداند. */}
<Field label="ظرفیت هم‌زمان" htmlFor="resource-capacity">
<Input id="resource-capacity" numeric value={capacity} onChange={(e) => setCapacity(e.target.value)} />
</Field>
<Field label="آماده‌سازی (دقیقه)">
<input className="field" type="number" min={0} value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
<Field label="آماده‌سازی (دقیقه)" htmlFor="resource-setup">
<Input id="resource-setup" numeric value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
</Field>
<Field label="تمیزکاری (دقیقه)">
<input className="field" type="number" min={0} value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
<Field label="تمیزکاری (دقیقه)" htmlFor="resource-cleanup">
<Input id="resource-cleanup" numeric value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
</Field>
</div>
@@ -152,7 +165,7 @@ export default function ResourceFormModal({
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>ویژگیها (اختیاری)</label>
<span className="cp-label">ویژگیها (اختیاری)</span>
<button
type="button"
className="btn secondary sm"
@@ -164,32 +177,33 @@ export default function ResourceFormModal({
{attributes.map((row, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="field"
<Input
list="resource-attribute-keys"
value={row.key}
placeholder="gender"
aria-label={`کلید ویژگی ${formatNumber(index + 1)}`}
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<input
className="field"
<Input
value={row.value}
placeholder="female"
aria-label={`مقدار ویژگی ${formatNumber(index + 1)}`}
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
{/* دکمهٔ فقط-آیکون → `mini-btn`، نه `btn secondary sm` با یک ✕ متنی */}
<button
type="button"
className="btn secondary sm"
className="mini-btn danger"
onClick={() => setAttributes((a) => a.filter((_, i) => i !== index))}
aria-label="حذف ویژگی"
aria-label={`حذف ویژگی ${formatNumber(index + 1)}`}
>
<XMarkIcon style={{ width: 16 }} />
</button>
</div>
))}
@@ -199,10 +213,13 @@ export default function ResourceFormModal({
</datalist>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
منبع فعال است
</label>
<Switch
id="resource-active"
checked={active}
onChange={setActive}
label="منبع فعال است"
hint="منبع غیرفعال در جستجوی وقت و رزرو نوبت نمی‌آید."
/>
{/* غیرفعال‌کردن نوبت‌های ثبت‌شده را لغو نمی‌کند؛ فقط از جستجوی وقتِ بعدی حذف
می‌شود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
@@ -233,12 +250,3 @@ export default function ResourceFormModal({
</Modal>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
{children}
</div>
);
}
@@ -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({
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
<input
type="checkbox"
checked={line.active}
onChange={(e) => patch(index, { active: e.target.checked })}
/>
فعال
</label>
<Switch
inline
checked={line.active}
onChange={(v) => patch(index, { active: v })}
label="فعال"
ariaLabel={`فعال بودن سرویس ${line.service_name}`}
/>
<button
type="button"
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
import Switch from '../ui/Switch';
import { formatNumber } from '../../lib/utils';
/** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */
@@ -197,15 +198,16 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
)}
</label>
<label className="wh-eod">
<input
type="checkbox"
<span className="wh-eod">
<Switch
inline
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
onChange={(v) => editRange(day, index, { endOfDay: v })}
label="تا پایان روز"
ariaLabel={`تا پایان روز برای شیفت ${formatNumber(index + 1)} روز ${label}`}
/>
تا پایان روز
</label>
</span>
{canUpdate && (
<button
@@ -15,6 +15,7 @@ import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import Switch from '../ui/Switch';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
@@ -789,22 +790,17 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
{/* ─ نوبت‌دهی آنلاین */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
{/* header + toggle */}
<label className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer bg-[var(--surface-2)]">
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-2)]">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" />
<span className="text-sm font-medium text-[var(--text)]">نوبتدهی آنلاین</span>
</div>
<span className="relative inline-flex shrink-0">
<input
type="checkbox"
className="peer sr-only"
checked={meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, online_booking_enabled: e.target.checked }))}
/>
<span className="w-10 h-6 rounded-full bg-[var(--surface-3)] transition-colors peer-checked:bg-[var(--success)]" />
<span className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-[var(--surface)] shadow transition-transform peer-checked:-translate-x-4" />
</span>
</label>
<Switch
checked={meta.online_booking_enabled}
onChange={(v) => setMeta(m => ({ ...m, online_booking_enabled: v }))}
ariaLabel="نوبت‌دهی آنلاین"
/>
</div>
{/* booking window control */}
<div className={`px-4 py-3 transition-opacity ${meta.online_booking_enabled ? '' : 'opacity-50 pointer-events-none'}`}>
@@ -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
<p className="muted">در حال بارگذاری...</p>
) : (
<>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<input
type="checkbox"
<div style={{ marginBottom: 14 }}>
<Switch
checked={active}
onChange={() => setActive(v => !v)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
onChange={setActive}
label="دسترسی این پزشک به کلینیک فعال باشد"
hint="خاموش‌کردنش کل جدول زیر را بی‌اثر می‌کند."
/>
<span>دسترسی این پزشک به کلینیک فعال باشد</span>
</label>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="t">
@@ -177,14 +177,17 @@ export default function DoctorPermissionsModal({ clinicUuid, doctorUuid, doctorN
return <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action} style={{ textAlign: 'center' }}>
<input
type="checkbox"
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
<td key={action}>
{/* سوییچ در سلول جدول: بدون برچسبِ دیداری، پس نامِ
دسترسی‌پذیر از ترکیب بخش و ستون ساخته می‌شود. */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Switch
disabled={!active}
checked={resources[resource]?.[action] ?? false}
onChange={() => toggle(resource, action)}
ariaLabel={`${config.label}${ACTION_HEADERS[ACTION_COLUMNS.indexOf(action)]}`}
/>
</div>
</td>
);
})}
@@ -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(<Switch checked={false} onChange={() => {}} label="فعال است" />);
expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
it('ظاهرش از کلاس‌های دیزاین‌سیستم می‌آید', () => {
const { container } = render(<Switch checked onChange={() => {}} ariaLabel="وضعیت" />);
expect(container.querySelector('.switch')).not.toBeNull();
expect(container.querySelector('.switch-track .switch-thumb')).not.toBeNull();
});
it('کلیک مقدار تازه را می‌دهد، نه رویداد خام', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} label="فعال است" />);
fireEvent.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenCalledWith(true);
});
/** متنِ `<label>` شاملِ hint می‌شود؛ نامِ دسترسی‌پذیر باید همان برچسبِ کوتاه بماند. */
it('hint وارد نام دسترسی‌پذیر نمی‌شود', () => {
render(<Switch checked onChange={() => {}} label="فعال است" hint="توضیح بلند و اضافی" />);
expect(screen.getByRole('switch', { name: 'فعال است' })).toBeInTheDocument();
expect(screen.getByText('توضیح بلند و اضافی')).toBeInTheDocument();
});
it('کلیک روی برچسب هم سوییچ را می‌زند', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} label="فعال است" />);
fireEvent.click(screen.getByText('فعال است'));
expect(onChange).toHaveBeenCalledWith(true);
});
/* `fireEvent.click` در jsdom قید `disabled` را دور می‌زند، پس خودِ صفت سنجیده
می‌شود — همان چیزی که مرورگر واقعی به آن تکیه می‌کند. */
it('در حالت غیرفعال، ورودی قفل است', () => {
render(<Switch checked={false} onChange={() => {}} label="فعال است" disabled />);
expect(screen.getByRole('switch')).toBeDisabled();
});
/** بدون برچسبِ دیداری — سلول جدول و ردیف فشرده — نام از `ariaLabel` می‌آید. */
it('بدون label، نام از ariaLabel می‌آید', () => {
render(<Switch checked onChange={() => {}} ariaLabel="دسترسی نوبت‌ها" />);
expect(screen.getByRole('switch', { name: 'دسترسی نوبت‌ها' })).toBeInTheDocument();
});
});
+87
View File
@@ -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 = (
<span className="switch">
<input
id={inputId}
type="checkbox"
role="switch"
checked={checked}
disabled={disabled}
/* نامِ دسترسی‌پذیر صریح: متنِ `<label>` شاملِ `hint` هم می‌شود و نامی می‌سازد
که هیچ‌کس به آن فکر نکرده. با `aria-label`، نام همان برچسبِ کوتاه می‌ماند. */
aria-label={ariaLabel ?? (typeof label === 'string' ? label : undefined)}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
);
if (label === undefined) return control;
return (
<label
htmlFor={inputId}
style={{
display: 'flex',
alignItems: 'center',
gap: inline ? 8 : 12,
justifyContent: inline ? 'flex-start' : 'space-between',
width: inline ? undefined : '100%',
fontSize: 13.5,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : undefined,
}}
>
{inline ? (
<>
{control}
<span style={{ color: 'var(--text)' }}>{label}</span>
</>
) : (
<>
<span>
<span style={{ display: 'block', color: 'var(--text)' }}>{label}</span>
{hint && <span className="field-hint" style={{ marginTop: 2 }}>{hint}</span>}
</span>
{control}
</>
)}
</label>
);
}
@@ -85,6 +85,13 @@ const PLAN_DISPLAY: Record<string, string> = {
// ── Switch toggle ─────────────────────────────────────────────────────────
/**
* نسخهٔ uncontrolled سوییچ برای `react-hook-form`.
*
* `components/ui/Switch` کنترل‌شده است (`checked` + `onChange`) و با اسپردِ
* `register()` — که `ref` و `onChange` نیتیو می‌دهد — جور در نمی‌آید. ظاهر هر دو از
* یک کلاس `.switch` می‌آید، پس تفاوت دیداری ندارند.
*/
const SwitchToggle = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function SwitchToggle(props, ref) {
return (
+7 -14
View File
@@ -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() {
{/* بیعانه */}
<div style={sectionTitle}>بیعانه</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: 'pointer' }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: depositRequired ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
}}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' }} />
<span style={{
position: 'absolute', top: 2, insetInlineStart: depositRequired ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s', boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 12, margin: '12px 0' }}>
<div style={{ width: 300, maxWidth: '100%' }}>
+7 -4
View File
@@ -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() {
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<>
<div style={{ minWidth: 220 }}>
+2 -4
View File
@@ -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({
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-2)' }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال
</label>
<Switch inline checked={active} onChange={setActive} label="فعال" />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
+10 -8
View File
@@ -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
? <p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجهای یافت نشد</p>
: filtered.map(o => (
<label key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px', cursor: 'pointer' }}>
<input type="checkbox" checked={selected.includes(o.id)} onChange={() => toggle(o.id)} />
<span style={{ fontSize: 13, color: 'var(--text)' }}>{o.name}</span>
</label>
<div key={o.id} style={{ padding: '7px 12px' }}>
<Switch inline checked={selected.includes(o.id)} onChange={() => toggle(o.id)} label={o.name} />
</div>
))}
</div>
</div>
@@ -281,10 +281,12 @@ function EditModal({ clinic, onClose, onSaved }: {
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
<textarea className="input" rows={3} style={{ resize: 'vertical' }} {...register('info')} />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" {...register('is_247')} />
<span style={{ fontSize: 13, fontWeight: 600 }}>کلینیک ۲۴ ساعته (۷ روز هفته)</span>
</label>
<Switch
inline
checked={watch('is_247') ?? false}
onChange={(v) => setValue('is_247', v, { shouldDirty: true })}
label="کلینیک ۲۴ ساعته (۷ روز هفته)"
/>
</>
)}
+6 -4
View File
@@ -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<typeof sectionSchema>;
@@ -172,10 +173,11 @@ function ClinicServicesPageInner() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>وضعیت:</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="switch" title={s.active ? 'فعال' : 'غیرفعال'}>
<input type="checkbox" checked={s.active} onChange={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })} />
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<Switch
checked={s.active}
onChange={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })}
ariaLabel={`وضعیت ${s.name}`}
/>
<span style={{ fontSize: 12.5, color: s.active ? 'var(--success)' : 'var(--text-3)' }}>{s.active ? 'فعال' : 'غیرفعال'}</span>
</span>
</div>
+7 -8
View File
@@ -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 => (
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggleSelect(s.id)}
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<Switch checked={selected.includes(s.id)} onChange={() => toggleSelect(s.id)} ariaLabel={s.name} />
<span className="text-sm text-[var(--text)]">{s.name}</span>
{s.parent_id !== null && (
<span className="text-[10px] text-[var(--text-3)] mr-auto truncate max-w-[100px]">
@@ -420,8 +420,9 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
? (isOpen
? <ChevronDownIcon className="w-4 h-4 text-[var(--text-3)] shrink-0" />
: <ChevronRightIcon className="w-4 h-4 text-[var(--text-3)] shrink-0" />)
: <input type="checkbox" checked={selected.includes(parent.id)} readOnly
className="rounded border-[var(--border-2)] text-[var(--primary)] pointer-events-none shrink-0" />
: <span className="pointer-events-none shrink-0">
<Switch checked={selected.includes(parent.id)} onChange={() => {}} disabled ariaLabel={parent.name} />
</span>
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-[var(--primary)] dark:text-[var(--primary)]' : 'text-[var(--text)]'}`}>
{parent.name}
@@ -434,8 +435,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
</div>
{isOpen && children.map(child => (
<label key={child.id} className="flex items-center gap-2.5 px-3 py-2 pr-9 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(child.id)} onChange={() => toggleSelect(child.id)}
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<Switch checked={selected.includes(child.id)} onChange={() => toggleSelect(child.id)} ariaLabel={child.name} />
<span className="text-sm text-[var(--text-2)]">{child.name}</span>
</label>
))}
@@ -528,8 +528,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
<div className="max-h-44 overflow-y-auto">
{visibleServices.map(svc => (
<label key={svc.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer">
<input type="checkbox" checked={selected.includes(svc.id)} onChange={() => toggle(svc.id)}
className="rounded border-[var(--border-2)] text-[var(--success)] shrink-0" />
<Switch checked={selected.includes(svc.id)} onChange={() => toggle(svc.id)} ariaLabel={svc.name} />
<span className="text-sm text-[var(--text)]">{svc.name}</span>
</label>
))}
+5 -13
View File
@@ -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]"
>
<input
type="checkbox"
<Switch
checked={sectionPerm?.[item.key] ?? false}
disabled={disabled}
onChange={(e) =>
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}
/>
<span className="text-[var(--text-2)] text-[14px]">
{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)]")
}
>
<input
type="checkbox"
className="accent-[var(--primary)] w-[16px] h-[16px]"
checked={checked}
onChange={() => toggle(d.uuid)}
/>
<Switch checked={checked} onChange={() => toggle(d.uuid)} ariaLabel={d.name} />
<Avatar name={d.name} size={26} />
<span className="text-[13px] text-[var(--text)]">{d.name}</span>
</label>
@@ -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() {
</div>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: readOnly ? 'not-allowed' : 'pointer' }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: enabled ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
opacity: readOnly ? 0.6 : 1,
}}>
<input
type="checkbox"
role="switch"
aria-label="شماره‌گذاری خودکار پرونده"
checked={enabled}
disabled={readOnly}
onChange={(e) => setEnabled(e.target.checked)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'inherit' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: enabled ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s',
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
شمارهگذاری خودکار پرونده
</label>
<Switch
inline
checked={enabled}
disabled={readOnly}
onChange={setEnabled}
label="شماره‌گذاری خودکار پرونده"
/>
<div style={{ maxWidth: 420, marginTop: 18 }}>
<label className="field-label" htmlFor="record-number-pattern">الگوی شماره</label>
+8 -5
View File
@@ -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<FormData>({
const { register, handleSubmit, reset, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
@@ -203,10 +204,12 @@ export default function RepresentationsPage() {
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" {...register('is_global')} style={{ width: 16, height: 16 }} />
نماینده سراسری (دامنه اختصاصی فقط پزشکان/کلینیکهای خودش نمایش داده میشوند)
</label>
<Switch
inline
checked={watch('is_global') ?? false}
onChange={(v) => setValue('is_global', v, { shouldDirty: true })}
label="نماینده سراسری (دامنه اختصاصی — فقط پزشکان/کلینیک‌های خودش نمایش داده می‌شوند)"
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
+1 -1
View File
@@ -102,7 +102,7 @@ export default function ResourcePoolsPage() {
>
{p.active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button type="button" className="btn secondary sm" onClick={() => setToDelete(p)}>
<button type="button" className="btn danger sm" onClick={() => setToDelete(p)}>
حذف
</button>
</div>
@@ -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<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
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(<ResourceTypesPage />, { route: '/admin/resources/types' });
expect(await screen.findByText('دستگاه لیزر')).toBeInTheDocument();
expect(screen.getByText('laser')).toBeInTheDocument();
expect(screen.getByText('سیستمی')).toBeInTheDocument();
});
/** نوع سیستمی پلِ خودکار منابع است؛ حذفش باید بسته باشد، نه صرفاً پشیمان‌کننده. */
it('حذف نوع سیستمی غیرفعال است', async () => {
renderWithProviders(<ResourceTypesPage />, { route: '/admin/resources/types' });
await screen.findByText('پزشک');
const deletes = screen.getAllByRole('button', { name: 'حذف' });
expect(deletes[1]).toBeDisabled();
expect(deletes[0]).toBeEnabled();
});
/**
* مودال پیش از این فیلدهایش را دستی میساخت: `field-block` روی خودِ `<input>`
* (که رَپر است، نه اینپوت) اینپوت را بیکادر میکرد، و چکباکس نیتیو بود.
* فیلدها باید از `Input`/`Field` دیزاینسیستم بیایند و سوییچ از کلاس `.switch`.
*/
describe('مودال افزودن', () => {
it('فیلدها کلاس اینپوت دیزاین‌سیستم را دارند، نه کلاس رَپر', async () => {
const { container } = renderWithProviders(<ResourceTypesPage />, { 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(<ResourceTypesPage />, { 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(<ResourceTypesPage />, { route: '/admin/resources/types' });
await openCreate();
expect(screen.getByLabelText('کد (انگلیسی)')).toBe(screen.getByPlaceholderText('laser_device'));
expect(screen.getByLabelText('نام نمایشی')).toBe(screen.getByPlaceholderText('دستگاه لیزر'));
});
it('کد نامعتبر پیام خطا می‌دهد و ذخیره را می‌بندد', async () => {
renderWithProviders(<ResourceTypesPage />, { 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(<ResourceTypesPage />, { 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: 'یونیت' },
));
});
});
});
+71 -39
View File
@@ -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() {
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, type: t })}>
ویرایش
</button>
{/* کنش مخرب باید رنگ هشدار داشته باشد، نه ظاهرِ «ویرایش» */}
<button
type="button"
className="btn secondary sm"
className="btn danger sm"
disabled={t.is_system}
title={t.is_system ? 'نوع سیستمی حذف نمی‌شود' : undefined}
onClick={() => 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 (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش نوع منبع' : 'افزودن نوع منبع'}>
<div style={{ display: 'grid', gap: 14 }}>
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>کد (انگلیسی)</label>
<input
className="field"
value={code}
disabled={isEdit}
onChange={(e) => setCode(e.target.value)}
placeholder="laser_device"
style={{ direction: 'ltr' }}
/>
{!isEdit && code !== '' && !codeValid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
فقط حروف کوچک انگلیسی، عدد و زیرخط.
</span>
)}
{isEdit && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
کد پس از ساخت تغییر نمیکند؛ منابع موجود با همین کد پیدا میشوند.
</span>
)}
</div>
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>نام نمایشی</label>
<input className="field-block" value={name} onChange={(e) => setName(e.target.value)} placeholder="دستگاه لیزر" />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال است
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Modal
open={open}
onClose={onClose}
title={isEdit ? 'ویرایش نوع منبع' : 'افزودن نوع منبع'}
/* دکمهها در `footer` مینشینند نه در بدنه: `.modal-foot` خطِ جداکننده و
پدینگ خودش را دارد، و ردیف دستیِ قبلی چسبیده به آخرین فیلد بود. */
footer={
<div className="row-actions">
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
type="submit"
form="resource-type-form"
className="btn primary"
disabled={saving || invalid}
onClick={() => onSave({ code: code.trim(), name: name.trim(), active })}
>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
}
>
{/* `<form>` است تا Enter هم ذخیره کند — سه فیلد ساده، رفتن سراغ ماوس اضافه است. */}
<form
id="resource-type-form"
style={{ display: 'grid', gap: 16 }}
onSubmit={(e) => { e.preventDefault(); submit(); }}
>
<Field
label="کد (انگلیسی)"
htmlFor="resource-type-code"
error={!isEdit && code !== '' && !codeValid ? 'فقط حروف کوچک انگلیسی، عدد و زیرخط.' : undefined}
>
<Input
id="resource-type-code"
value={code}
disabled={isEdit}
autoFocus={!isEdit}
onChange={(e) => setCode(e.target.value)}
placeholder="laser_device"
dir="ltr"
hasError={!isEdit && code !== '' && !codeValid}
/* فیلد قفل باید قفل به نظر برسد؛ پیش از این با فیلد فعال یکسان بود. */
style={isEdit ? { opacity: 0.6, cursor: 'not-allowed' } : undefined}
/>
{isEdit && (
<span className="field-hint">
کد پس از ساخت تغییر نمیکند؛ منابع موجود با همین کد پیدا میشوند.
</span>
)}
</Field>
<Field label="نام نمایشی" htmlFor="resource-type-name">
<Input
id="resource-type-name"
value={name}
autoFocus={isEdit}
onChange={(e) => setName(e.target.value)}
placeholder="دستگاه لیزر"
/>
<span className="field-hint">همین نام در فهرست منابع و انتخابگرها دیده میشود.</span>
</Field>
<Switch
id="resource-type-active"
checked={active}
onChange={setActive}
label="فعال است"
hint="نوع غیرفعال در ساخت منبع تازه پیشنهاد نمی‌شود."
/>
</form>
</Modal>
);
}
+9 -7
View File
@@ -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 <td key={action} style={{ textAlign: 'center', color: 'var(--border)' }}></td>;
}
return (
<td key={action} style={{ textAlign: 'center' }}>
<input
type="checkbox"
checked={sectionPerms[action] ?? false}
onChange={() => toggle(section, action)}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
/>
<td key={action}>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Switch
checked={sectionPerms[action] ?? false}
onChange={() => toggle(section, action)}
ariaLabel={`${section}${action}`}
/>
</div>
</td>
);
})}
+6 -9
View File
@@ -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() {
</p>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', marginBottom: 14 }}>
<span className="switch">
<input
type="checkbox"
aria-label="محاسبه درآمد منشی از نوبت‌های آنلاین"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<Switch
checked={enabled}
onChange={setEnabled}
ariaLabel="محاسبه درآمد منشی از نوبت‌های آنلاین"
/>
<span style={{ fontSize: 13 }}>محاسبه درآمد از نوبتهای آنلاین فعال باشد</span>
</label>
+4 -4
View File
@@ -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 (
<label className="switch" title={label}>
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<span title={label}>
<Switch checked={checked} onChange={onChange} ariaLabel={label} />
</span>
);
}
+41 -21
View File
@@ -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() {
</button>
<button
type="button"
className="btn secondary sm"
className="btn danger sm"
disabled={(s.resources_count ?? 0) > 0}
title={(s.resources_count ?? 0) > 0 ? 'اول از منابع برداشته شود' : undefined}
onClick={() => setToDelete(s)}
@@ -131,30 +134,47 @@ function SkillModal({
}, [open, skill]);
return (
<Modal open={open} onClose={onClose} title={skill ? 'ویرایش مهارت' : 'افزودن مهارت'}>
<div style={{ display: 'grid', gap: 14 }}>
<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="لیزر آلکساندرایت" />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
فعال است
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Modal
open={open}
onClose={onClose}
title={skill ? 'ویرایش مهارت' : 'افزودن مهارت'}
footer={
<div className="row-actions">
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
<button
type="button"
className="btn primary"
disabled={saving || name.trim() === ''}
onClick={() => onSave({ name: name.trim(), active })}
>
<button type="submit" form="skill-form" className="btn primary" disabled={saving || name.trim() === ''}>
{saving ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
}
>
<form
id="skill-form"
style={{ display: 'grid', gap: 16 }}
onSubmit={(e) => {
e.preventDefault();
if (saving || name.trim() === '') return;
onSave({ name: name.trim(), active });
}}
>
<Field label="نام مهارت" htmlFor="skill-name">
<Input
id="skill-name"
value={name}
autoFocus
onChange={(e) => setName(e.target.value)}
placeholder="لیزر آلکساندرایت"
/>
<span className="field-hint">مهارت به منبع داده میشود و در جستجوی وقت شرط میگذارد.</span>
</Field>
<Switch
id="skill-active"
checked={active}
onChange={setActive}
label="فعال است"
hint="مهارت غیرفعال در انتخابگرها پیشنهاد نمی‌شود."
/>
</form>
</Modal>
);
}
+11 -12
View File
@@ -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() {
)}
</div>
</div>
<label className="switch" style={{ marginTop: 4, flexShrink: 0 }}>
<input
type="checkbox"
<span style={{ marginTop: 4, flexShrink: 0 }}>
<Switch
checked={currentSettings.reminder_enabled}
onChange={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
onChange={(v) => setLocalSettings({ ...currentSettings, reminder_enabled: v })}
ariaLabel="یادآوری نوبت"
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
</span>
</div>
{/* ردیف: پیامک بعد از ویزیت */}
@@ -285,14 +285,13 @@ function SmsWalletPageInner() {
<div style={{ color: 'var(--text-3)', fontSize: 12.5, marginTop: 3 }}>متن تشکر پس از پایان مراجعه برای بیمار ارسال میشود</div>
</div>
</div>
<label className="switch" style={{ flexShrink: 0 }}>
<input
type="checkbox"
<span style={{ flexShrink: 0 }}>
<Switch
checked={currentSettings.post_visit_enabled}
onChange={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
onChange={(v) => setLocalSettings({ ...currentSettings, post_visit_enabled: v })}
ariaLabel="پیام پس از مراجعه"
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
</span>
</div>
{/* محتوای باز‌شونده */}
+2 -1
View File
@@ -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({
+6 -4
View File
@@ -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() {
<div>
<label className="field-label" style={{ fontWeight: 700 }}>وضعیت برچسب</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<label className="switch" title="وضعیت برچسب">
<input type="checkbox" checked={form.watch('active')} onChange={(e) => form.setValue('active', e.target.checked)} aria-label="وضعیت برچسب" />
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<Switch
checked={form.watch('active')}
onChange={(v) => form.setValue('active', v, { shouldDirty: true })}
ariaLabel="وضعیت برچسب"
/>
<span style={{ fontSize: 14, color: 'var(--text-2)' }}>{form.watch('active') ? 'فعال' : 'غیرفعال'}</span>
</div>
</div>
+3 -1
View File
@@ -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; }