Implement comprehensive dark/light mode overhaul for admin panel

- Refactor color palette in `ui-design-spec.md` to utilize CSS variables exclusively, eliminating fixed hex values and Tailwind utility classes.
- Complete dark mode implementation in `uiStore.ts`, ensuring proper theme application via `applyTheme()` and `applyBrand()`.
- Create `admin-theme-dark-light-audit.md` to document the transition process, outlining issues with inline styles and fixed colors.
- Introduce `theme-tokens.test.ts` to enforce rules against fixed hex colors and ensure compliance with the design system.
- Update various components and styles to replace inline styles and fixed colors with CSS variables, ensuring consistent theming across light and dark modes.
- Ensure all changes maintain visual integrity in both light and dark modes, with a focus on accessibility and contrast standards.
This commit is contained in:
hamed
2026-07-27 16:41:52 +03:30
parent c29035c21e
commit 55ab2f5dfc
73 changed files with 1485 additions and 1110 deletions
+119 -119
View File
@@ -90,8 +90,8 @@ const AVATAR_COLORS = [
'from-amber-500 to-orange-600',
];
const SPECIALTY_COLORS = [
'bg-red-100 dark:bg-red-400/10 text-red-700 dark:text-red-300',
'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300',
'bg-[var(--danger-bg)] dark:bg-red-400/10 text-[var(--danger)] dark:text-red-300',
'bg-[var(--info-bg)] dark:bg-blue-400/10 text-[var(--info)] dark:text-blue-300',
'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300',
'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300',
'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300',
@@ -136,13 +136,13 @@ function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
>
{img
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-white dark:ring-gray-900" />
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-[var(--on-primary)] text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
}
{onUpload && (
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading
? <span className="w-6 h-6 border-2 border-white/40 border-t-white rounded-full animate-spin" />
: <CameraIcon className="w-7 h-7 text-white" />
? <span className="w-6 h-6 border-2 border-[var(--surface)]/40 border-t-white rounded-full animate-spin" />
: <CameraIcon className="w-7 h-7 text-[var(--on-primary)]" />
}
</div>
)}
@@ -161,19 +161,19 @@ function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }:
mono?: boolean; copyable?: boolean;
}) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-slate-500 dark:text-slate-400" />
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] dark:bg-[var(--surface)]/60 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors group">
<div className="w-9 h-9 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-[var(--text-2)]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-slate-800 dark:text-slate-200 ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-slate-400 font-normal">ثبت نشده</span>}
<p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-[var(--text)] dark:text-[var(--text)] ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p>
</div>
{copyable && typeof value === 'string' && (
<button onClick={() => { navigator.clipboard.writeText(value); toast.success('کپی شد'); }}
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 transition-all shrink-0">
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] transition-all shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
)}
@@ -187,9 +187,9 @@ function StarRating({ rate }: { rate: number }) {
{Array.from({ length: 5 }).map((_, i) =>
i < Math.round(rate)
? <StarSolid key={i} className="w-4 h-4 text-amber-400" />
: <StarIcon key={i} className="w-4 h-4 text-slate-300 dark:text-slate-600" />
: <StarIcon key={i} className="w-4 h-4 text-[var(--text-3)]" />
)}
<span className="text-sm font-medium text-slate-600 dark:text-slate-400 mr-1">{rate.toFixed(1)}</span>
<span className="text-sm font-medium text-[var(--text-2)] mr-1">{rate.toFixed(1)}</span>
</div>
);
}
@@ -241,7 +241,7 @@ function MapPicker({ lat, lng, onChange, flyTarget }: {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = pos ?? IRAN_CENTER;
return (
<div className="rounded-xl overflow-hidden border border-slate-200 dark:border-gray-700" style={{ height: 300 }}>
<div className="rounded-xl overflow-hidden border border-[var(--border)]" style={{ height: 300 }}>
<MapContainer center={center} zoom={pos ? 13 : 5} style={{ height: '100%', width: '100%' }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
@@ -306,7 +306,7 @@ function SearchableSelect({ options, value, onChange, placeholder, disabled = fa
type="button"
disabled={disabled}
onClick={openDropdown}
className={`cp-input h-11 flex items-center justify-between text-right w-full ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${!selected ? 'text-slate-400 dark:text-slate-500' : ''}`}
className={`cp-input h-11 flex items-center justify-between text-right w-full ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${!selected ? 'text-[var(--text-3)] dark:text-[var(--text-2)]' : ''}`}
>
<span className="truncate flex-1">{selected?.label ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon className={`w-4 h-4 shrink-0 mr-2 transition-transform ${open ? 'rotate-180' : ''}`} />
@@ -314,26 +314,26 @@ function SearchableSelect({ options, value, onChange, placeholder, disabled = fa
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-xl shadow-2xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
className="bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-2xl overflow-hidden">
<div className="p-2 border-b border-[var(--border)]">
<input autoFocus type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="جستجو..." className="cp-input text-sm h-8" />
</div>
<div className="max-h-52 overflow-y-auto">
<button type="button"
onClick={() => { onChange(null, null); setOpen(false); setQ(''); }}
className="w-full text-right px-3 py-2 text-sm text-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800">
className="w-full text-right px-3 py-2 text-sm text-[var(--text-3)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]">
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.value} type="button"
onClick={() => { onChange(o.value, o.label); setOpen(false); setQ(''); }}
className={`w-full text-right px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-gray-800 ${value === o.value ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-500/10' : 'text-slate-700 dark:text-slate-300'}`}>
className={`w-full text-right px-3 py-2 text-sm hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] ${value === o.value ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-500/10' : 'text-[var(--text)] dark:text-[var(--text-3)]'}`}>
{o.label}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-3">نتیجهای یافت نشد</p>
<p className="text-xs text-[var(--text-3)] text-center py-3">نتیجهای یافت نشد</p>
)}
</div>
</div>,
@@ -379,13 +379,13 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
);
return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60">
<div className="border border-[var(--border)] rounded-xl overflow-hidden">
<div className="p-2 border-b border-[var(--border)] bg-[var(--surface-2)] dark:bg-[var(--surface)]/60">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی تخصص..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
<div className="px-3 py-2 border-b border-[var(--border)] flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{name}
@@ -398,18 +398,18 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
{q ? (
filteredFlat.length > 0
? filteredFlat.map(s => (
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<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-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
className="rounded border-[var(--border-2)] dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-[var(--text)]">{s.name}</span>
{s.parent_id !== null && (
<span className="text-[10px] text-slate-400 mr-auto truncate max-w-[100px]">
<span className="text-[10px] text-[var(--text-3)] mr-auto truncate max-w-[100px]">
{specialties.find(p => p.id === s.parent_id)?.name}
</span>
)}
</label>
))
: <p className="text-xs text-slate-400 text-center py-4">نتیجهای یافت نشد</p>
: <p className="text-xs text-[var(--text-3)] text-center py-4">نتیجهای یافت نشد</p>
) : (
parents.map(parent => {
const children = childMap.get(parent.id) ?? [];
@@ -417,16 +417,16 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
const numSel = children.filter(c => selected.includes(c.id)).length;
return (
<div key={parent.id}>
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer select-none"
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] cursor-pointer select-none"
onClick={() => children.length > 0 ? toggleParent(parent.id) : toggleSelect(parent.id)}>
{children.length > 0
? (isOpen
? <ChevronDownIcon className="w-4 h-4 text-slate-400 shrink-0" />
: <ChevronRightIcon className="w-4 h-4 text-slate-400 shrink-0" />)
? <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-slate-300 dark:border-gray-600 text-primary-600 pointer-events-none shrink-0" />
className="rounded border-[var(--border-2)] dark:border-gray-600 text-primary-600 pointer-events-none shrink-0" />
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-primary-600 dark:text-primary-400' : 'text-slate-700 dark:text-slate-300'}`}>
<span className={`text-sm font-medium ${numSel > 0 ? 'text-primary-600 dark:text-primary-400' : 'text-[var(--text)] dark:text-[var(--text-3)]'}`}>
{parent.name}
</span>
{numSel > 0 && (
@@ -436,10 +436,10 @@ 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-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<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-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-600 dark:text-slate-400">{child.name}</span>
className="rounded border-[var(--border-2)] dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-[var(--text-2)]">{child.name}</span>
</label>
))}
</div>
@@ -487,7 +487,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (selectedSpecialtyIds.length === 0) {
return (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
<div className="rounded-xl border border-dashed border-[var(--border-2)] dark:border-gray-600 py-8 text-center text-[var(--text-3)] text-sm">
ابتدا تخصصهایی را انتخاب کنید تا خدمات نمایش داده شوند
</div>
);
@@ -495,31 +495,31 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (relevantServices.length === 0) {
return (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
<div className="rounded-xl border border-dashed border-[var(--border-2)] dark:border-gray-600 py-8 text-center text-[var(--text-3)] text-sm">
خدمتی برای تخصصهای انتخابشده یافت نشد
</div>
);
}
return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="border border-[var(--border)] rounded-xl overflow-hidden">
{tabs.length > 1 && (
<div className="flex gap-1 px-2 py-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60 overflow-x-auto">
<div className="flex gap-1 px-2 py-2 border-b border-[var(--border)] bg-[var(--surface-2)] dark:bg-[var(--surface)]/60 overflow-x-auto">
{[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => (
<button key={String(t.id)} type="button"
onClick={() => setActiveTab(t.id as number | 'all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${activeTab === t.id ? 'bg-primary-600 text-white' : 'text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-gray-700'}`}>
className={`px-2.5 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${activeTab === t.id ? 'bg-primary-600 text-[var(--on-primary)]' : 'text-[var(--text-2)] dark:text-[var(--text-3)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]'}`}>
{t.name}
</button>
))}
</div>
)}
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<div className="p-2 border-b border-[var(--border)]">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی خدمت..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
<div className="px-3 py-2 border-b border-[var(--border)] flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300">
{name}
@@ -530,14 +530,14 @@ 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-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<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-slate-300 dark:border-gray-600 text-emerald-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{svc.name}</span>
className="rounded border-[var(--border-2)] dark:border-gray-600 text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--text)]">{svc.name}</span>
</label>
))}
{visibleServices.length === 0 && q && (
<p className="text-xs text-slate-400 text-center py-4">نتیجهای یافت نشد</p>
<p className="text-xs text-[var(--text-3)] text-center py-4">نتیجهای یافت نشد</p>
)}
</div>
</div>
@@ -644,32 +644,32 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
{/* Row 1: Name + Phone */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام مطب / کلینیک</label>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">نام مطب / کلینیک</label>
<input type="text" className="input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
تلفن <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
تلفن <span className="text-[var(--danger)]">*</span>
</label>
<input className="cp-input text-left" placeholder="021..." {...latinDigitsField(register('telephone'))} />
{errors.telephone && <p className="text-xs text-red-500 mt-1">{errors.telephone.message}</p>}
{errors.telephone && <p className="text-xs text-[var(--danger)] mt-1">{errors.telephone.message}</p>}
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
آدرس کامل <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
آدرس کامل <span className="text-[var(--danger)]">*</span>
</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
{errors.address && <p className="text-xs text-red-500 mt-1">{errors.address.message}</p>}
{errors.address && <p className="text-xs text-[var(--danger)] mt-1">{errors.address.message}</p>}
</div>
{/* Row 3: Province + City side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
استان <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
استان <span className="text-[var(--danger)]">*</span>
</label>
<SearchableSelect
options={provinces.map(p => ({ value: p.id, label: p.name }))}
@@ -681,11 +681,11 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
setMapFlyTarget(null);
}}
/>
{errors.province_id && <p className="text-xs text-red-500 mt-1">{errors.province_id.message}</p>}
{errors.province_id && <p className="text-xs text-[var(--danger)] mt-1">{errors.province_id.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
شهر <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">
شهر <span className="text-[var(--danger)]">*</span>
</label>
<SearchableSelect
options={cities.map(c => ({ value: c.id, label: c.name }))}
@@ -701,23 +701,23 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
}
}}
/>
{errors.city_id && <p className="text-xs text-red-500 mt-1">{errors.city_id.message}</p>}
{errors.city_id && <p className="text-xs text-[var(--danger)] mt-1">{errors.city_id.message}</p>}
</div>
</div>
{/* Row 4: Map */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
<label className="block text-sm font-medium text-[var(--text)]">
موقعیت روی نقشه
</label>
{lat && lng && (
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono" dir="ltr">
<span className="text-xs text-[var(--text-2)] font-mono" dir="ltr">
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">
<p className="text-xs text-[var(--text-3)] mb-2">
برای دقت بیشتر روی نقشه کلیک کنید
</p>
<MapPicker lat={lat} lng={lng} flyTarget={mapFlyTarget}
@@ -725,7 +725,7 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
{lat && lng && (
<button type="button"
onClick={() => { setValue('latitude', ''); setValue('longitude', ''); }}
className="mt-2 text-xs text-red-500 hover:underline flex items-center gap-1">
className="mt-2 text-xs text-[var(--danger)] hover:underline flex items-center gap-1">
<XMarkIcon className="w-3 h-3" />حذف موقعیت
</button>
)}
@@ -741,26 +741,26 @@ function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean;
}) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 border border-slate-100 dark:border-gray-700 group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0 mt-0.5">
<MapPinIcon className="w-4 h-4 text-slate-400" />
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] dark:bg-[var(--surface)]/60 border border-[var(--border)] group">
<div className="w-9 h-9 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0 mt-0.5">
<MapPinIcon className="w-4 h-4 text-[var(--text-3)]" />
</div>
<div className="flex-1 min-w-0">
{addr.name && <p className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-0.5">{addr.name}</p>}
{addr.address && <p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">{addr.address}</p>}
{addr.name && <p className="text-sm font-semibold text-[var(--text)] mb-0.5">{addr.name}</p>}
{addr.address && <p className="text-xs text-[var(--text-2)] leading-relaxed">{addr.address}</p>}
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1.5">
{addr.telephone && (
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 font-mono" dir="ltr">
<span className="text-xs text-[var(--text-2)] flex items-center gap-1 font-mono" dir="ltr">
<PhoneIcon className="w-3 h-3" />{addr.telephone}
</span>
)}
{addr.province && (
<span className="text-xs text-slate-500 dark:text-slate-400">
<span className="text-xs text-[var(--text-2)]">
{addr.province.name}{addr.city ? `${addr.city.name}` : ''}
</span>
)}
{addr.map.latitude && addr.map.longitude && (
<span className="text-xs text-slate-400 dark:text-slate-500 flex items-center gap-1" dir="ltr">
<span className="text-xs text-[var(--text-3)] flex items-center gap-1" dir="ltr">
<MapPinIcon className="w-3 h-3" />{parseFloat(addr.map.latitude).toFixed(4)}, {parseFloat(addr.map.longitude).toFixed(4)}
</span>
)}
@@ -769,11 +769,11 @@ function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
{!readOnly && (
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
<button onClick={onEdit}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-blue-500/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={onDelete}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
@@ -1362,10 +1362,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
if (isError || !doctor) {
return (
<div className="animate-slide-up cp-card p-16 flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-400/10 flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-red-500" />
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] dark:bg-red-400/10 flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div>
<p className="font-semibold text-slate-800 dark:text-slate-200">پزشک یافت نشد</p>
<p className="font-semibold text-[var(--text)]">پزشک یافت نشد</p>
<button onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary mt-2">
بازگشت به لیست پزشکان
</button>
@@ -1383,24 +1383,24 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="animate-slide-up space-y-5">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 text-sm text-[var(--text-2)]">
{isOwnProfile ? (
<>
<button onClick={() => navigate('/admin/dashboard')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
className="flex items-center gap-1.5 hover:text-[var(--text)] dark:hover:text-[var(--text)] transition-colors">
<ArrowRightIcon className="w-4 h-4" />داشبورد
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل من</span>
<span className="text-[var(--text)] font-medium">پروفایل من</span>
</>
) : (
<>
<button onClick={() => navigate(primaryRole === 'clinic' ? '/admin/dashboard' : '/admin/doctors')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
className="flex items-center gap-1.5 hover:text-[var(--text)] dark:hover:text-[var(--text)] transition-colors">
<ArrowRightIcon className="w-4 h-4" />{primaryRole === 'clinic' ? 'داشبورد' : 'پزشکان'}
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل پزشک</span>
<span className="text-[var(--text)] font-medium">پروفایل پزشک</span>
</>
)}
</div>
@@ -1422,27 +1422,27 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
/>
)}
{primaryRole !== 'clinic' && !isReadOnly && (
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
<span className="text-[10px] text-[var(--text-3)]">کلیک برای تغییر عکس</span>
)}
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">{displayDoctorName(doctor.name)}</h1>
<h1 className="text-xl font-bold text-[var(--text)] dark:text-slate-50">{displayDoctorName(doctor.name)}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */}
{doctor.active
? <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300">
<CheckCircleIcon className="w-3 h-3" />فعال
</span>
: <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400">
: <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-2)]">
<XCircleIcon className="w-3 h-3" />غیرفعال
</span>
}
{doctor.degree && (
<span className="inline-flex text-xs font-medium px-2.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300">{degreeLabel}</span>
<span className="inline-flex text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--info-bg)] dark:bg-blue-400/10 text-[var(--info)] dark:text-blue-300">{degreeLabel}</span>
)}
{doctor.gender && (
<span className="text-xs text-slate-500 dark:text-slate-400">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span>
<span className="text-xs text-[var(--text-2)]">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span>
)}
</div>
{doctor.specialties.length > 0 && (
@@ -1455,7 +1455,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{doctor.expertise.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{doctor.expertise.map(e => (
<span key={e.id} className="text-[11px] px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-600">{e.name}</span>
<span key={e.id} className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)] dark:border-gray-600">{e.name}</span>
))}
</div>
)}
@@ -1477,7 +1477,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
className={`inline-flex items-center gap-1.5 text-sm font-medium px-3 h-10 rounded-xl border transition-colors disabled:opacity-50 ${
doctor.active
? 'border-orange-300 dark:border-orange-500/40 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 hover:bg-orange-100 dark:hover:bg-orange-500/20'
: 'border-emerald-300 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20'
: 'border-emerald-300 dark:border-emerald-500/40 text-[var(--success)] dark:text-emerald-400 bg-[var(--success-bg)] dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20'
}`}
>
{toggleMut.isPending
@@ -1495,9 +1495,9 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-dmenu>
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-[var(--border)] py-1.5 animate-scale-in" data-dmenu>
<button onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--danger)] dark:text-red-400 hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10">
<TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
</button>
</div>
@@ -1515,7 +1515,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="lg:col-span-2 space-y-5">
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">اطلاعات پایه</h2>
<h2 className="text-sm font-semibold text-[var(--text)]">اطلاعات پایه</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoCard icon={UserIcon} label="نام کامل" value={doctor.name} />
<InfoCard icon={AcademicCapIcon} label="کد نظام پزشکی" value={doctor.medical_system_code} mono copyable />
@@ -1527,8 +1527,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
value={
<span className="flex items-center gap-2">
{doctor.representation
? <button type="button" style={{ color: '#2563eb', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
: <span className="text-slate-400">بدون نماینده</span>}
? <button type="button" style={{ color: 'var(--info)', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
: <span className="text-[var(--text-3)]">بدون نماینده</span>}
{isAdmin && (
<button type="button" className="btn ghost sm"
onClick={() => { setSelRepId(doctor.representation?.id ?? null); setRepModalOpen(true); }}>
@@ -1543,15 +1543,15 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{doctor.detail && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">بیوگرافی</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{doctor.detail}</p>
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">بیوگرافی</h2>
<p className="text-sm text-[var(--text-2)] leading-relaxed">{doctor.detail}</p>
</div>
)}
{/* Addresses */}
<div className="cp-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
<h2 className="text-sm font-semibold text-[var(--text)]">
آدرسهای مطب ({formatNumber(addresses.length)})
</h2>
{!isReadOnly && (
@@ -1571,7 +1571,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
))}
</div>
) : (
<div className="text-center py-8 text-slate-400 dark:text-slate-500">
<div className="text-center py-8 text-[var(--text-3)]">
<MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هنوز آدرسی ثبت نشده است</p>
{!isReadOnly && (
@@ -1590,18 +1590,18 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
کلینیکهای مرتبط ({formatNumber(doctor.clinics.length)})
</h2>
<div className="space-y-2">
{doctor.clinics.map(c => (
<div key={c.uuid} className="flex items-start gap-3 p-3 rounded-xl bg-slate-50 dark:bg-gray-800/60">
<div className="w-8 h-8 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<BuildingOfficeIcon className="w-4 h-4 text-slate-400" />
<div key={c.uuid} className="flex items-start gap-3 p-3 rounded-xl bg-[var(--surface-2)] dark:bg-[var(--surface)]/60">
<div className="w-8 h-8 rounded-lg bg-[var(--surface)] shadow-sm flex items-center justify-center shrink-0">
<BuildingOfficeIcon className="w-4 h-4 text-[var(--text-3)]" />
</div>
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{c.name}</p>
{c.address && <p className="text-xs text-slate-500 mt-0.5 flex items-center gap-1"><MapPinIcon className="w-3 h-3" />{c.address}</p>}
<p className="text-sm font-medium text-[var(--text)]">{c.name}</p>
{c.address && <p className="text-xs text-[var(--text-2)] mt-0.5 flex items-center gap-1"><MapPinIcon className="w-3 h-3" />{c.address}</p>}
</div>
</div>
))}
@@ -1613,7 +1613,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{/* Sidebar */}
<div className="space-y-5">
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
تخصصها ({formatNumber(doctor.specialties.length)})
</h2>
{doctor.specialties.length > 0
@@ -1622,7 +1622,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<span key={s.id} className={`text-xs font-medium px-2.5 py-1 rounded-lg ${SPECIALTY_COLORS[i % SPECIALTY_COLORS.length]}`}>{s.name}</span>
))}
</div>
: <p className="text-sm text-slate-400">تخصصی ثبت نشده</p>
: <p className="text-sm text-[var(--text-3)]">تخصصی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
@@ -1632,16 +1632,16 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">
خدمات ({formatNumber(doctor.expertise.length)})
</h2>
{doctor.expertise.length > 0
? <div className="flex flex-wrap gap-1.5">
{doctor.expertise.map(e => (
<span key={e.id} className="text-xs px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-700">{e.name}</span>
<span key={e.id} className="text-xs px-2.5 py-1 rounded-lg bg-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)]">{e.name}</span>
))}
</div>
: <p className="text-sm text-slate-400">خدمتی ثبت نشده</p>
: <p className="text-sm text-[var(--text-3)]">خدمتی ثبت نشده</p>
}
{!isReadOnly && (
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
@@ -1651,30 +1651,30 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">امتیاز و رضایت</h2>
<h2 className="text-sm font-semibold text-[var(--text)] mb-3">امتیاز و رضایت</h2>
<div className="space-y-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">امتیاز کلی</p>
<p className="text-xs text-[var(--text-2)] mb-1">امتیاز کلی</p>
<div dir="ltr"><StarRating rate={rateNum} /></div>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">درصد رضایت</p>
<p className="text-xs text-[var(--text-2)] mb-1">درصد رضایت</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-slate-200 dark:bg-gray-700">
<div className="flex-1 h-2 rounded-full bg-[var(--surface-3)]">
<div className="h-full rounded-full bg-amber-400 transition-all" style={{ width: `${doctor.satisfaction}%` }} />
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{doctor.satisfaction}%</span>
<span className="text-sm font-medium text-[var(--text)]">{doctor.satisfaction}%</span>
</div>
</div>
</div>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">UUID</h2>
<h2 className="text-sm font-semibold text-[var(--text)] mb-2">UUID</h2>
<div className="flex items-center gap-2">
<p className="text-[11px] font-mono text-slate-500 dark:text-slate-400 break-all flex-1" dir="ltr">{doctor.uuid}</p>
<p className="text-[11px] font-mono text-[var(--text-2)] break-all flex-1" dir="ltr">{doctor.uuid}</p>
<button onClick={() => { navigator.clipboard.writeText(doctor.uuid); toast.success('UUID کپی شد'); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors shrink-0">
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] hover:bg-[var(--surface-2)] transition-colors shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
</div>
@@ -1887,7 +1887,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</>
}
>
<label className="block text-sm text-slate-600 mb-2">انتخاب نماینده</label>
<label className="block text-sm text-[var(--text-2)] mb-2">انتخاب نماینده</label>
<GlobalSearchableSelect
options={repOptions}
value={selRepId}