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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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="کلینیک ۲۴ ساعته (۷ روز هفته)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'یونیت' },
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* محتوای بازشونده */}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user