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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user