refactor: update UserDetailPage styles and introduce avatar gradient utility

- Refactored role metadata styles in UserDetailPage for consistency with design tokens.
- Replaced hardcoded avatar colors with a utility function to generate gradients based on user ID.
- Improved InfoCard component styles for better hover effects and accessibility.
- Removed deprecated color classes and adjusted background gradients for various components.
- Updated theme token tests to reflect the removal of deferred files and ensure compliance with design standards.
- Added new avatarColors utility file to manage avatar gradient definitions.
This commit is contained in:
hamed
2026-07-27 16:58:03 +03:30
parent 55ab2f5dfc
commit bdc00fe267
6 changed files with 153 additions and 147 deletions
@@ -169,7 +169,7 @@ function PersianDateInput({ value, onChange, minDate, placeholder }: {
<div>
<button ref={btnRef} type="button" onClick={openPicker}
className="cp-input h-11 flex items-center justify-between w-full cursor-pointer">
<span className={value ? 'text-[var(--text)] dark:text-[var(--text)]' : 'text-[var(--text-3)] dark:text-[var(--text-2)]'}>
<span className={value ? 'text-[var(--text)]' : 'text-[var(--text-3)]'}>
{value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span>
<CalendarIcon className="w-4 h-4 text-[var(--text-3)] shrink-0 mr-2" />
@@ -205,10 +205,10 @@ function PersianDateInput({ value, onChange, minDate, placeholder }: {
return (
<button key={jd} type="button" disabled={dis} onClick={() => selectDay(jd)}
className={`w-full aspect-square flex items-center justify-center rounded-lg text-sm transition-colors
${dis ? 'text-[var(--text-3)] dark:text-[var(--text-2)] cursor-not-allowed'
: sel ? 'bg-primary-500 text-[var(--on-primary)] font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium'
: 'text-[var(--text)] dark:text-[var(--text-3)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]'}`}>
${dis ? 'text-[var(--text-3)] cursor-not-allowed'
: sel ? 'bg-[var(--primary)] text-[var(--on-primary)] font-semibold shadow-sm'
: tod ? 'ring-1 ring-[var(--primary)] text-[var(--primary-700)] dark:text-[var(--primary)] font-medium'
: 'text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]'}`}>
{toPersianNums(jd)}
</button>
);
@@ -352,7 +352,7 @@ function SlotEditor({ slots, onChange }: {
</div>
))}
<button type="button" onClick={add}
className="flex items-center gap-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-1">
className="flex items-center gap-1.5 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline mt-1">
<PlusIcon className="w-3.5 h-3.5" />افزودن بازه زمانی
</button>
</div>
@@ -511,7 +511,7 @@ function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-[var(--warning-bg)] dark:bg-amber-500/10 border border-[var(--warning)] dark:border-amber-500/30 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-[var(--warning-bg)] border border-[var(--warning)] flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-[var(--warning)]" />
</div>
<div>
@@ -627,9 +627,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
return (
<div className="space-y-2">
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-[var(--success)] dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
@@ -638,7 +638,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active);
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
return (
<div key={day.key} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] dark:bg-[var(--surface)]/40">
<div key={day.key} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
<div className="flex-1 flex gap-2 items-center flex-wrap min-w-0">
{activeSessions.length > 0 ? activeSessions.map((s, i) => (
@@ -665,9 +665,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="space-y-2">
{/* ─ خلاصه کل هفته */}
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-[var(--success)] dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
@@ -675,7 +675,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
{/* ─ روش نوبت‌دهی */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
<div className="px-4 py-3 bg-[var(--surface-2)] dark:bg-[var(--surface)]/50">
<div className="px-4 py-3 bg-[var(--surface-2)]">
<span className="text-sm font-medium text-[var(--text)]">روش نوبتدهی</span>
</div>
<div className="px-4 py-3 space-y-3">
@@ -694,11 +694,11 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
className={`text-right p-3 rounded-lg border transition-colors ${
selected
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border)] dark:border-[var(--border)] bg-[var(--surface)] dark:bg-[var(--bg)]'
: 'border-[var(--border)] bg-[var(--surface)]'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)] dark:border-gray-600'}`} />
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} />
<span className="text-sm font-medium text-[var(--text)]">{lbl}</span>
</div>
<p className="text-xs text-[var(--text-2)] leading-relaxed">{desc}</p>
@@ -712,7 +712,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
</p>
) : (
<p className="text-xs text-[var(--warning)] dark:text-amber-400 leading-relaxed">
<p className="text-xs text-[var(--warning)] leading-relaxed">
توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p>
)}
@@ -745,7 +745,7 @@ 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)] dark:bg-[var(--surface)]/50">
<label className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer 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>
@@ -757,7 +757,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
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-slate-300 dark:bg-gray-600 transition-colors peer-checked:bg-emerald-500" />
<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>
@@ -864,7 +864,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="flex items-center justify-between pt-3">
{(hasAnyOverlap || missingLocation) && (
<p className="text-xs text-[var(--danger)] dark:text-red-400 flex items-center gap-1">
<p className="text-xs text-[var(--danger)] flex items-center gap-1">
<ExclamationTriangleIcon className="w-3.5 h-3.5" />
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p>
@@ -966,7 +966,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
<div className="grid grid-cols-2 gap-2">
<button type="button"
onClick={() => setOverrideType('closed')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'closed' ? 'border-red-400 bg-[var(--danger-bg)] dark:bg-[var(--danger)]/10 text-[var(--danger)] dark:text-red-300' : 'border-[var(--border)] dark:border-[var(--border)] text-[var(--text-2)] dark:text-[var(--text-3)] hover:border-[var(--border-2)]'}`}>
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'closed' ? 'border-[var(--danger)] bg-[var(--danger-bg)] text-[var(--danger)]' : 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)]'}`}>
<XCircleIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">تعطیل است</p>
@@ -975,7 +975,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
</button>
<button type="button"
onClick={() => setOverrideType('custom')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'custom' ? 'border-amber-400 bg-[var(--warning-bg)] dark:bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-[var(--border)] dark:border-[var(--border)] text-[var(--text-2)] dark:text-[var(--text-3)] hover:border-[var(--border-2)]'}`}>
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'custom' ? 'border-[var(--warning)] bg-[var(--warning-bg)] text-[var(--warning)]' : 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)]'}`}>
<CalendarIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">ساعات خاص</p>
@@ -1020,9 +1020,9 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
)}
{overrideType === 'closed' && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--danger-bg)] dark:bg-[var(--danger)]/10 border border-[var(--danger)] dark:border-red-500/30">
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--danger-bg)] border border-[var(--danger)]">
<ExclamationTriangleIcon className="w-4 h-4 text-[var(--danger)] shrink-0 mt-0.5" />
<p className="text-xs text-[var(--danger)] dark:text-red-400">
<p className="text-xs text-[var(--danger)]">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.
</p>
</div>
@@ -1079,16 +1079,16 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
) : (
<div className="space-y-2">
{overrides.map(ov => (
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface)] dark:bg-[var(--surface)]/60">
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(tsToDate(ov.date))}</span>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--warning-bg)] dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--warning-bg)] text-[var(--warning)]">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] dark:bg-[var(--danger)]/20 text-[var(--danger)] dark:text-red-400">
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)]">
تعطیل
</span>
)}
@@ -1098,7 +1098,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
className="w-8 h-8 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">
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(ov.uuid)}
@@ -1250,17 +1250,17 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid:
const isCurrent = h.start_date <= now && h.end_date >= now;
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
return (
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-[var(--danger)] dark:border-red-500/30 bg-[var(--danger-bg)]/40 dark:bg-[var(--danger)]/5' : isPast ? 'border-[var(--border)] dark:border-[var(--border)] bg-[var(--surface-2)]/40 dark:bg-[var(--surface)]/30 opacity-70' : 'border-orange-200 dark:border-orange-500/30 bg-orange-50/30 dark:bg-orange-500/5'}`}>
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-[var(--danger)] bg-[var(--danger-bg)]/40' : isPast ? 'border-[var(--border)] bg-[var(--surface-2)]/40 opacity-70' : 'border-[var(--accent)] bg-[var(--accent-bg)]/30'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${isPast ? 'text-[var(--text-2)] dark:text-[var(--text-3)]' : 'text-[var(--text)] dark:text-[var(--text)]'}`}>
<span className={`text-sm font-medium ${isPast ? 'text-[var(--text-2)]' : 'text-[var(--text)]'}`}>
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
</span>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] dark:bg-[var(--danger)]/20 text-[var(--danger)] dark:text-red-400 animate-pulse">در جریان</span>
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)] animate-pulse">در جریان</span>
)}
{!isCurrent && !isPast && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400">آینده</span>
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--accent-bg)] text-[var(--accent)]">آینده</span>
)}
{isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-3)]">گذشته</span>
@@ -1274,11 +1274,11 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid:
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-[var(--border)] dark:border-gray-600 text-[var(--text-2)] dark:text-[var(--text-3)] hover:border-[var(--border-2)] hover:text-[var(--text)] dark:hover:text-[var(--text)]' : 'border-emerald-200 dark:border-emerald-500/40 text-[var(--success)] dark:text-emerald-400 hover:bg-[var(--success-bg)] dark:hover:bg-emerald-500/10'}`}>
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)] hover:text-[var(--text)]' : 'border-[var(--success)] text-[var(--success)] hover:bg-[var(--success-bg)] dark:hover:bg-[var(--success)]/10'}`}>
{h.active ? 'غیرفعال' : 'فعال'}
</button>
<button onClick={() => { setEditing(h); setModalOpen(true); }}
className="w-8 h-8 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">
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(h.uuid)}
@@ -1329,8 +1329,8 @@ export function ScheduleSection({ doctorUuid, clinicUuid, readOnly = false }: {
<button key={t.id} type="button" onClick={() => setTab(t.id)}
className={`flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t.id
? 'bg-[var(--surface)] dark:bg-[var(--surface-2)] text-[var(--text)] dark:text-[var(--text)] shadow-sm'
: 'text-[var(--text-2)] dark:text-[var(--text-3)] hover:text-[var(--text)] dark:hover:text-[var(--text)]'
? 'bg-[var(--surface)] text-[var(--text)] shadow-sm'
: 'text-[var(--text-2)] hover:text-[var(--text)]'
}`}>
{t.label}
</button>
+15
View File
@@ -0,0 +1,15 @@
/**
* گرادیان آواتارهای بدون عکس — تزئینی و عمداً چندرنگ، در هر دو تم یکسان
* (مثل پالت برچسب‌ها). تنها جایی در پنل که پالت ثابت Tailwind مجاز است.
*/
export const AVATAR_GRADIENTS = [
'from-blue-500 to-cyan-500',
'from-violet-500 to-purple-600',
'from-emerald-500 to-teal-600',
'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
];
/** گرادیان پایدار برای یک آواتار بر اساس شناسه/اندیس. */
export const avatarGradient = (seed: number) =>
AVATAR_GRADIENTS[Math.abs(seed) % AVATAR_GRADIENTS.length];
+46 -50
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -84,18 +85,13 @@ const DEGREE_OPTIONS = [
{ value: 'subspecialistplus', label: 'فلوشیپ' },
];
const GENDER_OPTIONS = [{ value: 'man', label: 'مرد' }, { value: 'woman', label: 'زن' }];
const AVATAR_COLORS = [
'from-blue-500 to-cyan-500', 'from-violet-500 to-purple-600',
'from-emerald-500 to-teal-600', 'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
];
const SPECIALTY_COLORS = [
'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',
'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300',
'bg-[var(--danger-bg)] text-[var(--danger)]',
'bg-[var(--info-bg)] text-[var(--info)]',
'bg-[var(--success-bg)] text-[var(--success)]',
'bg-[var(--violet-bg)] text-[var(--violet)]',
'bg-[var(--accent-bg)] text-[var(--accent)]',
'bg-[var(--violet-bg)] text-[var(--violet)]',
];
// ── Image upload ───────────────────────────────────────────────────────────
@@ -135,8 +131,8 @@ function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
onClick={() => onUpload && ref.current?.click()}
>
{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-[var(--on-primary)] text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-[var(--surface)]" />
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${avatarGradient(idx)} flex items-center justify-center text-[var(--on-primary)] text-3xl font-bold shadow-xl ring-4 ring-[var(--surface)]`}>{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">
@@ -161,13 +157,13 @@ 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-[var(--surface-2)] dark:bg-[var(--surface)]/60 hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors group">
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] hover:bg-[var(--surface-2)] 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-[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}>
<p className={`text-sm font-medium text-[var(--text)] ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p>
</div>
@@ -186,7 +182,7 @@ function StarRating({ rate }: { rate: number }) {
<div className="flex items-center gap-1" dir="ltr">
{Array.from({ length: 5 }).map((_, i) =>
i < Math.round(rate)
? <StarSolid key={i} className="w-4 h-4 text-amber-400" />
? <StarSolid key={i} className="w-4 h-4 text-[var(--warning)]" />
: <StarIcon key={i} className="w-4 h-4 text-[var(--text-3)]" />
)}
<span className="text-sm font-medium text-[var(--text-2)] mr-1">{rate.toFixed(1)}</span>
@@ -306,7 +302,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-[var(--text-3)] dark:text-[var(--text-2)]' : ''}`}
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)]' : ''}`}
>
<span className="truncate flex-1">{selected?.label ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon className={`w-4 h-4 shrink-0 mr-2 transition-transform ${open ? 'rotate-180' : ''}`} />
@@ -328,7 +324,7 @@ function SearchableSelect({ options, value, onChange, placeholder, disabled = fa
{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-[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)]'}`}>
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-[var(--primary)] dark:text-[var(--primary)] font-medium bg-[var(--primary-soft)] dark:bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]' : 'text-[var(--text)]'}`}>
{o.label}
</button>
))}
@@ -380,16 +376,16 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
return (
<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">
<div className="p-2 border-b border-[var(--border)] bg-[var(--surface-2)]">
<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-[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">
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-[var(--primary-soft2)] dark:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)] text-[var(--primary-700)] dark:text-[var(--primary)]">
{name}
<button type="button" onClick={() => toggleSelect(id)} className="hover:text-primary-900 dark:hover:text-primary-100 leading-none">×</button>
<button type="button" onClick={() => toggleSelect(id)} className="hover:text-[var(--primary-700)] dark:hover:text-[var(--primary-soft2)] leading-none">×</button>
</span>
))}
</div>
@@ -400,7 +396,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
? 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)] dark:border-gray-600 text-primary-600 shrink-0" />
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<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]">
@@ -424,13 +420,13 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
? <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)] dark:border-gray-600 text-primary-600 pointer-events-none shrink-0" />
className="rounded border-[var(--border-2)] text-[var(--primary)] pointer-events-none shrink-0" />
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-primary-600 dark:text-primary-400' : 'text-[var(--text)] dark:text-[var(--text-3)]'}`}>
<span className={`text-sm font-medium ${numSel > 0 ? 'text-[var(--primary)] dark:text-[var(--primary)]' : 'text-[var(--text)]'}`}>
{parent.name}
</span>
{numSel > 0 && (
<span className="mr-auto text-[10px] bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 px-1.5 py-0.5 rounded-full">
<span className="mr-auto text-[10px] bg-[var(--primary-soft2)] dark:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)] text-[var(--primary-700)] dark:text-[var(--primary)] px-1.5 py-0.5 rounded-full">
{numSel} انتخاب
</span>
)}
@@ -438,7 +434,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
{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)] dark:border-gray-600 text-primary-600 shrink-0" />
className="rounded border-[var(--border-2)] text-[var(--primary)] shrink-0" />
<span className="text-sm text-[var(--text-2)]">{child.name}</span>
</label>
))}
@@ -487,7 +483,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (selectedSpecialtyIds.length === 0) {
return (
<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 className="rounded-xl border border-dashed border-[var(--border-2)] py-8 text-center text-[var(--text-3)] text-sm">
ابتدا تخصصهایی را انتخاب کنید تا خدمات نمایش داده شوند
</div>
);
@@ -495,7 +491,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (relevantServices.length === 0) {
return (
<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 className="rounded-xl border border-dashed border-[var(--border-2)] py-8 text-center text-[var(--text-3)] text-sm">
خدمتی برای تخصصهای انتخابشده یافت نشد
</div>
);
@@ -504,11 +500,11 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
return (
<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-[var(--border)] bg-[var(--surface-2)] dark:bg-[var(--surface)]/60 overflow-x-auto">
<div className="flex gap-1 px-2 py-2 border-b border-[var(--border)] bg-[var(--surface-2)] 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-[var(--on-primary)]' : 'text-[var(--text-2)] dark:text-[var(--text-3)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]'}`}>
className={`px-2.5 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${activeTab === t.id ? 'bg-[var(--primary)] text-[var(--on-primary)]' : 'text-[var(--text-2)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)]'}`}>
{t.name}
</button>
))}
@@ -521,9 +517,9 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
{selectedLabels.length > 0 && (
<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">
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
{name}
<button type="button" onClick={() => toggle(id)} className="hover:text-emerald-900 dark:hover:text-emerald-100 leading-none">×</button>
<button type="button" onClick={() => toggle(id)} className="hover:text-[var(--success)] dark:hover:text-[var(--success)] leading-none">×</button>
</span>
))}
</div>
@@ -532,7 +528,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
{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)] dark:border-gray-600 text-[var(--success)] shrink-0" />
className="rounded border-[var(--border-2)] text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--text)]">{svc.name}</span>
</label>
))}
@@ -741,7 +737,7 @@ 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-[var(--surface-2)] dark:bg-[var(--surface)]/60 border border-[var(--border)] group">
<div className="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] 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>
@@ -769,7 +765,7 @@ 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-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] 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-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={onDelete}
@@ -1362,7 +1358,7 @@ 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-[var(--danger-bg)] dark:bg-red-400/10 flex items-center justify-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div>
<p className="font-semibold text-[var(--text)]">پزشک یافت نشد</p>
@@ -1407,7 +1403,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{/* Profile Card */}
<div className="cp-card overflow-hidden">
<div className="h-28 bg-gradient-to-l from-blue-600/20 via-blue-500/10 to-transparent dark:from-blue-500/20" />
<div className="h-28 bg-gradient-to-l from-[color-mix(in_srgb,var(--info)_20%,transparent)] via-[color-mix(in_srgb,var(--info)_10%,transparent)] to-transparent dark:from-[color-mix(in_srgb,var(--info)_20%,transparent)]" />
<div className="px-6 pb-6 -mt-12 flex flex-col sm:flex-row sm:items-end gap-4">
<div className="flex flex-col items-center gap-1.5">
@@ -1427,11 +1423,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)] dark:text-slate-50">{displayDoctorName(doctor.name)}</h1>
<h1 className="text-xl font-bold text-[var(--text)]">{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">
? <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
<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-[var(--surface-2)] text-[var(--text-2)]">
@@ -1439,7 +1435,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</span>
}
{doctor.degree && (
<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>
<span className="inline-flex text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--info-bg)] text-[var(--info)]">{degreeLabel}</span>
)}
{doctor.gender && (
<span className="text-xs text-[var(--text-2)]">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span>
@@ -1455,7 +1451,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-[var(--surface-2)] text-[var(--text-2)] border border-[var(--border)] 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)]">{e.name}</span>
))}
</div>
)}
@@ -1476,8 +1472,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
disabled={toggleMut.isPending}
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-[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'
? 'border-[var(--accent)] text-[var(--accent)] bg-[var(--accent-bg)] hover:bg-[var(--accent-bg)]'
: 'border-[var(--success)] text-[var(--success)] bg-[var(--success-bg)] hover:bg-[var(--success-bg)]'
}`}
>
{toggleMut.isPending
@@ -1497,7 +1493,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{menuOpen && (
<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-[var(--danger)] dark:text-red-400 hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10">
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10">
<TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
</button>
</div>
@@ -1556,7 +1552,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</h2>
{!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
className="flex items-center gap-1.5 text-xs font-medium text-[var(--primary)] dark:text-[var(--primary)] hover:underline">
<PlusIcon className="w-4 h-4" />افزودن آدرس
</button>
)}
@@ -1576,7 +1572,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<p className="text-sm">هنوز آدرسی ثبت نشده است</p>
{!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
className="mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline">
اولین آدرس را اضافه کنید
</button>
)}
@@ -1595,7 +1591,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</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-[var(--surface-2)] dark:bg-[var(--surface)]/60">
<div key={c.uuid} className="flex items-start gap-3 p-3 rounded-xl bg-[var(--surface-2)]">
<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>
@@ -1625,7 +1621,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
: <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">
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right">
ویرایش تخصصها
</button>
)}
@@ -1644,7 +1640,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
: <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">
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right">
ویرایش خدمات
</button>
)}
@@ -1661,7 +1657,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<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-[var(--surface-3)]">
<div className="h-full rounded-full bg-amber-400 transition-all" style={{ width: `${doctor.satisfaction}%` }} />
<div className="h-full rounded-full bg-[var(--warning)] transition-all" style={{ width: `${doctor.satisfaction}%` }} />
</div>
<span className="text-sm font-medium text-[var(--text)]">{doctor.satisfaction}%</span>
</div>
+39 -45
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
@@ -71,11 +72,11 @@ const MEDICAL_SECTIONS: {
// ── Helpers ───────────────────────────────────────────────────────────────
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
admin: { label: 'ادمین', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', dot: '#8b5cf6' },
doctor: { label: 'پزشک', cls: 'bg-[var(--info-bg)] dark:bg-blue-400/10 text-[var(--info)] dark:text-blue-300', dot: '#3b82f6' },
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' },
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' },
patient: { label: 'بیمار', cls: 'bg-[var(--surface-2)] dark:bg-slate-400/10 text-[var(--text-2)] dark:text-[var(--text-3)]', dot: '#94a3b8' },
admin: { label: 'ادمین', cls: 'bg-[var(--violet-bg)] text-[var(--violet)]', dot: 'var(--violet)' },
doctor: { label: 'پزشک', cls: 'bg-[var(--info-bg)] text-[var(--info)]', dot: 'var(--info)' },
secretary: { label: 'منشی', cls: 'bg-[var(--accent-bg)] text-[var(--accent)]', dot: 'var(--accent)' },
clinic: { label: 'کلینیک', cls: 'bg-[var(--success-bg)] text-[var(--success)]', dot: 'var(--success)' },
patient: { label: 'بیمار', cls: 'bg-[var(--surface-2)] text-[var(--text-2)]', dot: 'var(--text-3)' },
};
const ROLE_MAP: Record<string, string[]> = {
@@ -94,20 +95,13 @@ function getPrimaryRole(roles: string[]): string {
return 'patient';
}
const AVATAR_COLORS = [
'from-violet-500 to-purple-600',
'from-blue-500 to-cyan-600',
'from-emerald-500 to-teal-600',
'from-pink-500 to-rose-600',
'from-amber-500 to-orange-600',
];
function BigAvatar({ name, id }: { name: string | null; id: number }) {
const initials = name
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
: '؟';
return (
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-[var(--on-primary)] text-2xl font-bold shadow-lg shrink-0`}>
<div className={`w-20 h-20 rounded-2xl bg-gradient-to-br ${avatarGradient(id)} flex items-center justify-center text-[var(--on-primary)] text-2xl font-bold shadow-lg shrink-0`}>
{initials}
</div>
);
@@ -124,13 +118,13 @@ function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }:
}
};
return (
<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="flex items-start gap-3 p-4 rounded-xl bg-[var(--surface-2)] hover:bg-[var(--surface-2)] 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.5 h-4.5 text-[var(--text-2)]" />
</div>
<div className="flex-1 min-w-0">
<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)] truncate ${mono ? 'font-mono' : ''}`}
<p className={`text-sm font-medium text-[var(--text)] truncate ${mono ? 'font-mono' : ''}`}
dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p>
@@ -138,7 +132,7 @@ function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }:
{copyable && typeof value === 'string' && (
<button
onClick={handleCopy}
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)] dark:hover:text-[var(--text-3)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)] 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)] hover:bg-[var(--surface)] dark:hover:bg-[var(--surface-2)] transition-all shrink-0"
>
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
@@ -180,15 +174,15 @@ function ChangeRoleModal({ current, loading, onSave, onClose }: {
key={key}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
selected === key
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-transparent hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] border border-[var(--border)] dark:border-[var(--border)]'
? 'border-[var(--primary)] bg-[var(--primary-soft)] dark:bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]'
: 'border-transparent hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] border border-[var(--border)]'
}`}
>
<input type="radio" name="role" value={key} checked={selected === key}
onChange={() => setSelected(key)} className="sr-only" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
<span className="text-sm font-medium text-[var(--text)] flex-1">{meta.label}</span>
{selected === key && <CheckCircleIcon className="w-4 h-4 text-primary-600 dark:text-primary-400" />}
{selected === key && <CheckCircleIcon className="w-4 h-4 text-[var(--primary)] dark:text-[var(--primary)]" />}
</label>
))}
</div>
@@ -320,7 +314,7 @@ export default function UserDetailPage() {
return (
<div className="animate-slide-up">
<div className="cp-card p-16 flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] dark:bg-red-400/10 flex items-center justify-center">
<div className="w-16 h-16 rounded-2xl bg-[var(--danger-bg)] flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div>
<p className="font-semibold text-[var(--text)]">کاربر یافت نشد</p>
@@ -356,14 +350,14 @@ export default function UserDetailPage() {
<div className="cp-card overflow-hidden">
{/* Cover strip */}
<div className="h-24 bg-gradient-to-l from-primary-600/20 via-primary-500/10 to-transparent dark:from-primary-500/20" />
<div className="h-24 bg-gradient-to-l from-[color-mix(in_srgb,var(--primary)_20%,transparent)] via-[color-mix(in_srgb,var(--primary)_10%,transparent)] to-transparent dark:from-[color-mix(in_srgb,var(--primary)_20%,transparent)]" />
{/* Profile header */}
<div className="px-6 pb-6 -mt-10 flex flex-col sm:flex-row sm:items-end gap-4">
<BigAvatar name={user.name} id={user.id} />
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-[var(--text)] dark:text-slate-50 leading-tight">
<h1 className="text-xl font-bold text-[var(--text)] leading-tight">
{user.name || <span className="text-[var(--text-3)] font-normal">نام ثبت نشده</span>}
</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
@@ -372,12 +366,12 @@ export default function UserDetailPage() {
{roleMeta.label}
</span>
{user.is_active ? (
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] dark:bg-green-400/10 text-[var(--success)] dark:text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />فعال
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--success-bg)] text-[var(--success)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--success)] animate-pulse" />فعال
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-[var(--surface-2)] dark:bg-slate-400/10 text-[var(--text-2)]">
<span className="w-1.5 h-1.5 rounded-full bg-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)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--text-3)]" />غیرفعال
</span>
)}
<span className="text-xs text-[var(--text-3)]">#{user.id}</span>
@@ -411,7 +405,7 @@ export default function UserDetailPage() {
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors disabled:opacity-50"
>
{user.is_active
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
? <XCircleIcon className="w-4 h-4 text-[var(--accent)] shrink-0" />
: <CheckCircleIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
}
{user.is_active ? 'غیرفعال کردن حساب' : 'فعال‌سازی حساب'}
@@ -420,13 +414,13 @@ export default function UserDetailPage() {
onClick={() => { setRoleOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] transition-colors"
>
<ShieldCheckIcon className="w-4 h-4 text-violet-400 shrink-0" />
<ShieldCheckIcon className="w-4 h-4 text-[var(--violet)] shrink-0" />
تغییر نقش کاربر
</button>
<div className="border-t border-[var(--border)] my-1" />
<button
onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
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 transition-colors"
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors"
>
<TrashIcon className="w-4 h-4 shrink-0" />
حذف این کاربر
@@ -469,7 +463,7 @@ export default function UserDetailPage() {
</div>
<button
onClick={() => setRoleOpen(true)}
className="w-full mt-1 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right"
className="w-full mt-1 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline text-right"
>
تغییر نقش
</button>
@@ -484,7 +478,7 @@ export default function UserDetailPage() {
</p>
<button
onClick={() => { navigator.clipboard.writeText(user.uuid); toast.success('UUID کپی شد'); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--text-2)] dark:hover:text-[var(--text-3)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface-2)] 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)] dark:hover:bg-[var(--surface-2)] transition-colors shrink-0"
>
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
@@ -496,20 +490,20 @@ export default function UserDetailPage() {
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[var(--text)]">وضعیت حساب</h2>
{user.is_active
? <span className="text-xs font-medium text-[var(--success)] dark:text-green-400">فعال</span>
? <span className="text-xs font-medium text-[var(--success)]">فعال</span>
: <span className="text-xs font-medium text-[var(--text-2)]">غیرفعال</span>
}
</div>
<div className={`w-full h-2 rounded-full ${user.is_active ? 'bg-green-200 dark:bg-green-400/20' : 'bg-[var(--surface-3)] dark:bg-[var(--surface-2)]'}`}>
<div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-green-500' : 'w-0'}`} />
<div className={`w-full h-2 rounded-full ${user.is_active ? 'bg-[var(--success-bg)]' : 'bg-[var(--surface-3)]'}`}>
<div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-[var(--success)]' : 'w-0'}`} />
</div>
<button
onClick={() => toggleStatusMut.mutate()}
disabled={toggleStatusMut.isPending}
className={`w-full mt-4 text-sm font-medium py-2 rounded-xl transition-colors disabled:opacity-50 ${
user.is_active
? 'bg-orange-50 dark:bg-orange-400/10 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-400/20'
: 'bg-[var(--success-bg)] dark:bg-green-400/10 text-[var(--success)] dark:text-green-400 hover:bg-[var(--success-bg)] dark:hover:bg-green-400/20'
? 'bg-[var(--accent-bg)] text-[var(--accent)] hover:bg-[var(--accent-bg)]'
: 'bg-[var(--success-bg)] text-[var(--success)] hover:bg-[var(--success-bg)]'
}`}
>
{toggleStatusMut.isPending
@@ -561,14 +555,14 @@ export default function UserDetailPage() {
.map(format)
.filter((s) => s && s.trim());
return (
<div key={key} className="p-4 rounded-xl bg-[var(--surface-2)] dark:bg-[var(--surface)]/60">
<div key={key} className="p-4 rounded-xl bg-[var(--surface-2)]">
<p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-1.5">{label}</p>
{items.length === 0 ? (
<p className="text-sm text-[var(--text-3)]">موردی ثبت نشده</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{items.map((text, i) => (
<li key={i} className="text-xs px-2 py-0.5 rounded-lg bg-[var(--surface)] text-[var(--text-2)] border border-[var(--border)] dark:border-gray-600">
<li key={i} className="text-xs px-2 py-0.5 rounded-lg bg-[var(--surface)] text-[var(--text-2)] border border-[var(--border)]">
{text}
</li>
))}
@@ -624,10 +618,10 @@ export default function UserDetailPage() {
<input
type="text"
placeholder="مثال: علی احمدی"
className={`cp-input ${errors.name ? 'border-red-400 dark:border-red-500' : ''}`}
className={`cp-input ${errors.name ? 'border-[var(--danger)]' : ''}`}
{...register('name')}
/>
{errors.name && <p className="text-xs text-[var(--danger)] dark:text-red-400 mt-1">{errors.name.message}</p>}
{errors.name && <p className="text-xs text-[var(--danger)] mt-1">{errors.name.message}</p>}
</div>
{/* Email */}
@@ -639,10 +633,10 @@ export default function UserDetailPage() {
type="email"
placeholder="example@email.com"
dir="ltr"
className={`cp-input text-left ${errors.email ? 'border-red-400 dark:border-red-500' : ''}`}
className={`cp-input text-left ${errors.email ? 'border-[var(--danger)]' : ''}`}
{...register('email')}
/>
{errors.email && <p className="text-xs text-[var(--danger)] dark:text-red-400 mt-1">{errors.email.message}</p>}
{errors.email && <p className="text-xs text-[var(--danger)] mt-1">{errors.email.message}</p>}
</div>
{/* Password */}
@@ -657,11 +651,11 @@ export default function UserDetailPage() {
type="password"
placeholder="حداقل ۶ کاراکتر"
dir="ltr"
className={`cp-input pr-9 text-left ${errors.password ? 'border-red-400 dark:border-red-500' : ''}`}
className={`cp-input pr-9 text-left ${errors.password ? 'border-[var(--danger)]' : ''}`}
{...register('password')}
/>
</div>
{errors.password && <p className="text-xs text-[var(--danger)] dark:text-red-400 mt-1">{errors.password.message}</p>}
{errors.password && <p className="text-xs text-[var(--danger)] mt-1">{errors.password.message}</p>}
</div>
{/* Mobile (read-only) */}
@@ -675,7 +669,7 @@ export default function UserDetailPage() {
value={user.mobile_number}
readOnly
dir="ltr"
className="cp-input text-left bg-[var(--surface-2)] dark:bg-[var(--surface)]/60 text-[var(--text-3)] cursor-not-allowed"
className="cp-input text-left bg-[var(--surface-2)] text-[var(--text-3)] cursor-not-allowed"
/>
</div>
</form>
+9 -12
View File
@@ -9,18 +9,11 @@ import { describe, expect, it } from 'vitest';
const ROOT = join(__dirname, '..');
/** فاز ۳ — هنوز جفت slate/gray + dark: دارند؛ کار می‌کنند ولی از design-system منحرف‌اند. */
const DEFERRED = [
'pages/DoctorDetailPage.tsx',
'pages/UserDetailPage.tsx',
'components/schedule/ScheduleSection.tsx',
];
/** رنگ‌های عمداً ثابت: دادهٔ کاربر یا پوششی که در هر دو تم یکسان است. */
const INTENTIONAL = [
'pages/TagsSettingsPage.tsx', // TAG_COLORS — پالت انتخابی کاربر برای برچسب
'components/ImageCropModal.tsx', // bg-black/50 — اسکریم مودال، مثل .overlay
'stores/uiStore.ts', // BrandFixed — هیوی نارنجی عمداً hex ثابت است
'lib/avatarColors.ts', // گرادیان تزئینی آواتار، در هر دو تم یکسان
];
const HEX = /#[0-9a-fA-F]{3,8}\b/;
@@ -42,12 +35,16 @@ function sourceFiles(): string[] {
return out;
}
/** کامنت‌ها و fallbackهای `var(--x, #hex)` رنگِ رندرشده نیستند. */
/**
* کامنت‌ها و fallbackهای `var(--x, #hex)` رنگِ رندرشده نیستند؛ `bg-black/NN` هم
* اسکریم است و عمداً در هر دو تم یکسان می‌ماند (مثل `.overlay` در styles.css).
*/
function strip(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1')
.replace(/var\(\s*--[a-z0-9-]+\s*,[^)]*\)/gi, 'var(--token)');
.replace(/var\(\s*--[a-z0-9-]+\s*,[^)]*\)/gi, 'var(--token)')
.replace(/\bbg-black\/\d+/g, '');
}
describe('theme tokens', () => {
@@ -69,14 +66,14 @@ describe('theme tokens', () => {
it('فایل‌های پاک‌شده hex ثابت ندارند', () => {
const offenders = files
.filter((f) => ![...DEFERRED, ...INTENTIONAL].includes(f))
.filter((f) => !INTENTIONAL.includes(f))
.filter((f) => HEX.test(strip(readFileSync(join(ROOT, f), 'utf8'))));
expect(offenders).toEqual([]);
});
it('فایل‌های پاک‌شده کلاس پالت Tailwind ندارند', () => {
const offenders = files
.filter((f) => ![...DEFERRED, ...INTENTIONAL].includes(f))
.filter((f) => !INTENTIONAL.includes(f))
.filter((f) => PALETTE_UTIL.test(strip(readFileSync(join(ROOT, f), 'utf8'))));
expect(offenders).toEqual([]);
});
+7 -3
View File
@@ -478,9 +478,13 @@ Row 3: [Bar Chart — آمار ماهانه نمایندگان (60%)] | [لیس
رنگ را از والد بگیر، یا `style={{ stroke: 'var(--…)' }}`.
- الفای هگزی (`${color}30`) روی توکن بی‌معنی است — `color-mix(in srgb, var(--x) 19%, transparent)`.
فاز باقی‌مانده: `pages/DoctorDetailPage.tsx`، `pages/UserDetailPage.tsx` و
`components/schedule/ScheduleSection.tsx` هنوز جفت `slate/gray` + `dark:` دارند
(کار می‌کنند ولی با brand hue هماهنگ نمی‌شوند) — در تست گارد به‌عنوان `DEFERRED` علامت خورده‌اند.
کل `assets/admin` مهاجرت کرده است؛ تست گارد `DEFERRED` ندارد و فقط سه استثنای عمدی
دارد: پالت برچسب‌ها، `BrandFixed` در `uiStore`، و گرادیان تزئینی آواتار
(`lib/avatarColors.ts`). اسکریم `bg-black/NN` هم مجاز است.
> نکتهٔ تاریخی: کلاس‌هایی مثل `text-primary-600` / `bg-primary-100` هیچ‌وقت رنگی تولید
> نمی‌کردند — بلوک `@theme` فقط `--font-sans` را تعریف می‌کند و مقیاس `primary-*` در
> Tailwind وجود ندارد. ۴۳ نمونه از این کلاس‌های مرده در آدیت به توکن تبدیل شدند.
---