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
@@ -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>