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> <div>
<button ref={btnRef} type="button" onClick={openPicker} <button ref={btnRef} type="button" onClick={openPicker}
className="cp-input h-11 flex items-center justify-between w-full cursor-pointer"> 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 ?? 'انتخاب تاریخ')} {value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span> </span>
<CalendarIcon className="w-4 h-4 text-[var(--text-3)] shrink-0 mr-2" /> <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 ( return (
<button key={jd} type="button" disabled={dis} onClick={() => selectDay(jd)} <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 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' ${dis ? 'text-[var(--text-3)] cursor-not-allowed'
: sel ? 'bg-primary-500 text-[var(--on-primary)] font-semibold shadow-sm' : sel ? 'bg-[var(--primary)] text-[var(--on-primary)] font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium' : tod ? 'ring-1 ring-[var(--primary)] text-[var(--primary-700)] dark:text-[var(--primary)] font-medium'
: 'text-[var(--text)] dark:text-[var(--text-3)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]'}`}> : 'text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]'}`}>
{toPersianNums(jd)} {toPersianNums(jd)}
</button> </button>
); );
@@ -352,7 +352,7 @@ function SlotEditor({ slots, onChange }: {
</div> </div>
))} ))}
<button type="button" onClick={add} <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" />افزودن بازه زمانی <PlusIcon className="w-3.5 h-3.5" />افزودن بازه زمانی
</button> </button>
</div> </div>
@@ -511,7 +511,7 @@ function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد']; : ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center"> <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)]" /> <MapPinIcon className="w-6 h-6 text-[var(--warning)]" />
</div> </div>
<div> <div>
@@ -627,9 +627,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{totalSlots > 0 && ( {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"> <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)] dark:text-emerald-400 shrink-0" /> <CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300"> <span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span> </span>
</div> </div>
@@ -638,7 +638,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active); const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active);
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0); const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
return ( 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> <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"> <div className="flex-1 flex gap-2 items-center flex-wrap min-w-0">
{activeSessions.length > 0 ? activeSessions.map((s, i) => ( {activeSessions.length > 0 ? activeSessions.map((s, i) => (
@@ -665,9 +665,9 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="space-y-2"> <div className="space-y-2">
{/* ─ خلاصه کل هفته */} {/* ─ خلاصه کل هفته */}
{totalSlots > 0 && ( {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"> <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)] dark:text-emerald-400 shrink-0" /> <CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300"> <span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span> </span>
</div> </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="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> <span className="text-sm font-medium text-[var(--text)]">روش نوبتدهی</span>
</div> </div>
<div className="px-4 py-3 space-y-3"> <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 ${ className={`text-right p-3 rounded-lg border transition-colors ${
selected selected
? 'border-[var(--primary)] bg-[var(--primary)]/5' ? '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)]'}`} } ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
> >
<div className="flex items-center gap-2 mb-1"> <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> <span className="text-sm font-medium text-[var(--text)]">{lbl}</span>
</div> </div>
<p className="text-xs text-[var(--text-2)] leading-relaxed">{desc}</p> <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>
) : ( ) : (
<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>. پیش از ذخیره با دقت انتخاب کنید. توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p> </p>
)} )}
@@ -745,7 +745,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
{/* ─ نوبت‌دهی آنلاین */} {/* ─ نوبت‌دهی آنلاین */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden"> <div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
{/* header + toggle */} {/* 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"> <div className="flex items-center gap-2">
<GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" /> <GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" />
<span className="text-sm font-medium text-[var(--text)]">نوبتدهی آنلاین</span> <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} checked={meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, online_booking_enabled: e.target.checked }))} 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 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> </span>
</label> </label>
@@ -864,7 +864,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
<div className="flex items-center justify-between pt-3"> <div className="flex items-center justify-between pt-3">
{(hasAnyOverlap || missingLocation) && ( {(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" /> <ExclamationTriangleIcon className="w-3.5 h-3.5" />
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'} {hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p> </p>
@@ -966,7 +966,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button type="button" <button type="button"
onClick={() => setOverrideType('closed')} 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" /> <XCircleIcon className="w-5 h-5 shrink-0" />
<div> <div>
<p className="font-medium leading-tight">تعطیل است</p> <p className="font-medium leading-tight">تعطیل است</p>
@@ -975,7 +975,7 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
</button> </button>
<button type="button" <button type="button"
onClick={() => setOverrideType('custom')} 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" /> <CalendarIcon className="w-5 h-5 shrink-0" />
<div> <div>
<p className="font-medium leading-tight">ساعات خاص</p> <p className="font-medium leading-tight">ساعات خاص</p>
@@ -1020,9 +1020,9 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, on
)} )}
{overrideType === 'closed' && ( {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" /> <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> </p>
</div> </div>
@@ -1079,16 +1079,16 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{overrides.map(ov => ( {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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(tsToDate(ov.date))}</span> <span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(tsToDate(ov.date))}</span>
{ov.active ? ( {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} بازه سفارشی` : 'ساعات خاص'} {ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span> </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> </span>
)} )}
@@ -1098,7 +1098,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
{!readOnly && ( {!readOnly && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button onClick={() => { setEditing(ov); setModalOpen(true); }} <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" /> <PencilIcon className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => setDeletingUuid(ov.uuid)} <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 isCurrent = h.start_date <= now && h.end_date >= now;
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date); const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
return ( 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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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))}`} {sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
</span> </span>
{isCurrent && h.active && ( {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 && ( {!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 && ( {isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-3)]">گذشته</span> <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 && ( {!readOnly && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending} <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 ? 'غیرفعال' : 'فعال'} {h.active ? 'غیرفعال' : 'فعال'}
</button> </button>
<button onClick={() => { setEditing(h); setModalOpen(true); }} <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" /> <PencilIcon className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => setDeletingUuid(h.uuid)} <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)} <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 ${ className={`flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t.id tab === t.id
? 'bg-[var(--surface)] dark:bg-[var(--surface-2)] text-[var(--text)] dark:text-[var(--text)] shadow-sm' ? 'bg-[var(--surface)] text-[var(--text)] shadow-sm'
: 'text-[var(--text-2)] dark:text-[var(--text-3)] hover:text-[var(--text)] dark:hover:text-[var(--text)]' : 'text-[var(--text-2)] hover:text-[var(--text)]'
}`}> }`}>
{t.label} {t.label}
</button> </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 React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -84,18 +85,13 @@ const DEGREE_OPTIONS = [
{ value: 'subspecialistplus', label: 'فلوشیپ' }, { value: 'subspecialistplus', label: 'فلوشیپ' },
]; ];
const GENDER_OPTIONS = [{ value: 'man', label: 'مرد' }, { value: 'woman', 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 = [ const SPECIALTY_COLORS = [
'bg-[var(--danger-bg)] dark:bg-red-400/10 text-[var(--danger)] dark:text-red-300', 'bg-[var(--danger-bg)] text-[var(--danger)]',
'bg-[var(--info-bg)] dark:bg-blue-400/10 text-[var(--info)] dark:text-blue-300', 'bg-[var(--info-bg)] text-[var(--info)]',
'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', 'bg-[var(--success-bg)] text-[var(--success)]',
'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', 'bg-[var(--violet-bg)] text-[var(--violet)]',
'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', 'bg-[var(--accent-bg)] text-[var(--accent)]',
'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300', 'bg-[var(--violet-bg)] text-[var(--violet)]',
]; ];
// ── Image upload ─────────────────────────────────────────────────────────── // ── Image upload ───────────────────────────────────────────────────────────
@@ -135,8 +131,8 @@ function DoctorAvatar({ name, img, idx, onUpload, uploading }: {
onClick={() => onUpload && ref.current?.click()} onClick={() => onUpload && ref.current?.click()}
> >
{img {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" /> ? <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 ${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> : <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 && ( {onUpload && (
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <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; mono?: boolean; copyable?: boolean;
}) { }) {
return ( 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"> <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)]" /> <Icon className="w-4 h-4 text-[var(--text-2)]" />
</div> </div>
<div className="flex-1 min-w-0"> <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-[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>} {value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p> </p>
</div> </div>
@@ -186,7 +182,7 @@ function StarRating({ rate }: { rate: number }) {
<div className="flex items-center gap-1" dir="ltr"> <div className="flex items-center gap-1" dir="ltr">
{Array.from({ length: 5 }).map((_, i) => {Array.from({ length: 5 }).map((_, i) =>
i < Math.round(rate) 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)]" /> : <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> <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" type="button"
disabled={disabled} disabled={disabled}
onClick={openDropdown} 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> <span className="truncate flex-1">{selected?.label ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon className={`w-4 h-4 shrink-0 mr-2 transition-transform ${open ? 'rotate-180' : ''}`} /> <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 => ( {filtered.map(o => (
<button key={o.value} type="button" <button key={o.value} type="button"
onClick={() => { onChange(o.value, o.label); setOpen(false); setQ(''); }} 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} {o.label}
</button> </button>
))} ))}
@@ -380,16 +376,16 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
return ( return (
<div className="border border-[var(--border)] rounded-xl overflow-hidden"> <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="جستجوی تخصص..." <input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی تخصص..."
className="cp-input text-sm h-8" /> className="cp-input text-sm h-8" />
</div> </div>
{selectedLabels.length > 0 && ( {selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-[var(--border)] 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 }) => ( {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} {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> </span>
))} ))}
</div> </div>
@@ -400,7 +396,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
? filteredFlat.map(s => ( ? 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"> <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)} <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> <span className="text-sm text-[var(--text)]">{s.name}</span>
{s.parent_id !== null && ( {s.parent_id !== null && (
<span className="text-[10px] text-[var(--text-3)] mr-auto truncate max-w-[100px]"> <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" /> ? <ChevronDownIcon className="w-4 h-4 text-[var(--text-3)] shrink-0" />
: <ChevronRightIcon 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 : <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} {parent.name}
</span> </span>
{numSel > 0 && ( {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} انتخاب {numSel} انتخاب
</span> </span>
)} )}
@@ -438,7 +434,7 @@ function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
{isOpen && children.map(child => ( {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"> <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)} <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> <span className="text-sm text-[var(--text-2)]">{child.name}</span>
</label> </label>
))} ))}
@@ -487,7 +483,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (selectedSpecialtyIds.length === 0) { if (selectedSpecialtyIds.length === 0) {
return ( 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> </div>
); );
@@ -495,7 +491,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
if (relevantServices.length === 0) { if (relevantServices.length === 0) {
return ( 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> </div>
); );
@@ -504,11 +500,11 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
return ( return (
<div className="border border-[var(--border)] rounded-xl overflow-hidden"> <div className="border border-[var(--border)] rounded-xl overflow-hidden">
{tabs.length > 1 && ( {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 => ( {[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => (
<button key={String(t.id)} type="button" <button key={String(t.id)} type="button"
onClick={() => setActiveTab(t.id as number | 'all')} 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} {t.name}
</button> </button>
))} ))}
@@ -521,9 +517,9 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
{selectedLabels.length > 0 && ( {selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-[var(--border)] 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 }) => ( {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} {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> </span>
))} ))}
</div> </div>
@@ -532,7 +528,7 @@ function ServicesPicker({ selected, onChange, services, specialties, selectedSpe
{visibleServices.map(svc => ( {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"> <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)} <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> <span className="text-sm text-[var(--text)]">{svc.name}</span>
</label> </label>
))} ))}
@@ -741,7 +737,7 @@ function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean; addr: AddressData; onEdit: () => void; onDelete: () => void; readOnly?: boolean;
}) { }) {
return ( 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"> <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)]" /> <MapPinIcon className="w-4 h-4 text-[var(--text-3)]" />
</div> </div>
@@ -769,7 +765,7 @@ function AddressCard({ addr, onEdit, onDelete, readOnly = false }: {
{!readOnly && ( {!readOnly && (
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0"> <div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
<button onClick={onEdit} <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" /> <PencilIcon className="w-3.5 h-3.5" />
</button> </button>
<button onClick={onDelete} <button onClick={onDelete}
@@ -1362,7 +1358,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
if (isError || !doctor) { if (isError || !doctor) {
return ( return (
<div className="animate-slide-up cp-card p-16 flex flex-col items-center gap-4 text-center"> <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)]" /> <XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div> </div>
<p className="font-semibold text-[var(--text)]">پزشک یافت نشد</p> <p className="font-semibold text-[var(--text)]">پزشک یافت نشد</p>
@@ -1407,7 +1403,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{/* Profile Card */} {/* Profile Card */}
<div className="cp-card overflow-hidden"> <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="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"> <div className="flex flex-col items-center gap-1.5">
@@ -1427,11 +1423,11 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div> </div>
<div className="flex-1 min-w-0 sm:mb-1"> <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"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */} {/* Active status badge */}
{doctor.active {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" />فعال <CheckCircleIcon className="w-3 h-3" />فعال
</span> </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)]"> : <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> </span>
} }
{doctor.degree && ( {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 && ( {doctor.gender && (
<span className="text-xs text-[var(--text-2)]">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span> <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 && ( {doctor.expertise.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{doctor.expertise.map(e => ( {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> </div>
)} )}
@@ -1476,8 +1472,8 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
disabled={toggleMut.isPending} 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 ${ 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 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-[var(--accent)] text-[var(--accent)] bg-[var(--accent-bg)] hover:bg-[var(--accent-bg)]'
: '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(--success)] text-[var(--success)] bg-[var(--success-bg)] hover:bg-[var(--success-bg)]'
}`} }`}
> >
{toggleMut.isPending {toggleMut.isPending
@@ -1497,7 +1493,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
{menuOpen && ( {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> <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); }} <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" />حذف این پزشک <TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
</button> </button>
</div> </div>
@@ -1556,7 +1552,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</h2> </h2>
{!isReadOnly && ( {!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }} <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" />افزودن آدرس <PlusIcon className="w-4 h-4" />افزودن آدرس
</button> </button>
)} )}
@@ -1576,7 +1572,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<p className="text-sm">هنوز آدرسی ثبت نشده است</p> <p className="text-sm">هنوز آدرسی ثبت نشده است</p>
{!isReadOnly && ( {!isReadOnly && (
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }} <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> </button>
)} )}
@@ -1595,7 +1591,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
{doctor.clinics.map(c => ( {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"> <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)]" /> <BuildingOfficeIcon className="w-4 h-4 text-[var(--text-3)]" />
</div> </div>
@@ -1625,7 +1621,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
: <p className="text-sm text-[var(--text-3)]">تخصصی ثبت نشده</p> : <p className="text-sm text-[var(--text-3)]">تخصصی ثبت نشده</p>
} }
{!isReadOnly && ( {!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> </button>
)} )}
@@ -1644,7 +1640,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
: <p className="text-sm text-[var(--text-3)]">خدمتی ثبت نشده</p> : <p className="text-sm text-[var(--text-3)]">خدمتی ثبت نشده</p>
} }
{!isReadOnly && ( {!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> </button>
)} )}
@@ -1661,7 +1657,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<p className="text-xs text-[var(--text-2)] mb-1">درصد رضایت</p> <p className="text-xs text-[var(--text-2)] mb-1">درصد رضایت</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-[var(--surface-3)]"> <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> </div>
<span className="text-sm font-medium text-[var(--text)]">{doctor.satisfaction}%</span> <span className="text-sm font-medium text-[var(--text)]">{doctor.satisfaction}%</span>
</div> </div>
+39 -45
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { avatarGradient } from '../lib/avatarColors';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -71,11 +72,11 @@ const MEDICAL_SECTIONS: {
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = { 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' }, admin: { label: 'ادمین', cls: 'bg-[var(--violet-bg)] text-[var(--violet)]', dot: 'var(--violet)' },
doctor: { label: 'پزشک', cls: 'bg-[var(--info-bg)] dark:bg-blue-400/10 text-[var(--info)] dark:text-blue-300', dot: '#3b82f6' }, doctor: { label: 'پزشک', cls: 'bg-[var(--info-bg)] text-[var(--info)]', dot: 'var(--info)' },
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' }, secretary: { label: 'منشی', cls: 'bg-[var(--accent-bg)] text-[var(--accent)]', dot: 'var(--accent)' },
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' }, clinic: { label: 'کلینیک', cls: 'bg-[var(--success-bg)] text-[var(--success)]', dot: 'var(--success)' },
patient: { label: 'بیمار', cls: 'bg-[var(--surface-2)] dark:bg-slate-400/10 text-[var(--text-2)] dark:text-[var(--text-3)]', dot: '#94a3b8' }, patient: { label: 'بیمار', cls: 'bg-[var(--surface-2)] text-[var(--text-2)]', dot: 'var(--text-3)' },
}; };
const ROLE_MAP: Record<string, string[]> = { const ROLE_MAP: Record<string, string[]> = {
@@ -94,20 +95,13 @@ function getPrimaryRole(roles: string[]): string {
return 'patient'; 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 }) { function BigAvatar({ name, id }: { name: string | null; id: number }) {
const initials = name const initials = name
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) ? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
: '؟'; : '؟';
return ( 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} {initials}
</div> </div>
); );
@@ -124,13 +118,13 @@ function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }:
} }
}; };
return ( 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"> <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)]" /> <Icon className="w-4.5 h-4.5 text-[var(--text-2)]" />
</div> </div>
<div className="flex-1 min-w-0"> <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-[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}> dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>} {value ?? <span className="text-[var(--text-3)] font-normal">ثبت نشده</span>}
</p> </p>
@@ -138,7 +132,7 @@ function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }:
{copyable && typeof value === 'string' && ( {copyable && typeof value === 'string' && (
<button <button
onClick={handleCopy} 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" /> <ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button> </button>
@@ -180,15 +174,15 @@ function ChangeRoleModal({ current, loading, onSave, onClose }: {
key={key} key={key}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${ className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
selected === key selected === key
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10' ? '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)] dark:border-[var(--border)]' : '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} <input type="radio" name="role" value={key} checked={selected === key}
onChange={() => setSelected(key)} className="sr-only" /> onChange={() => setSelected(key)} className="sr-only" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} /> <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> <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> </label>
))} ))}
</div> </div>
@@ -320,7 +314,7 @@ export default function UserDetailPage() {
return ( return (
<div className="animate-slide-up"> <div className="animate-slide-up">
<div className="cp-card p-16 flex flex-col items-center gap-4 text-center"> <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)]" /> <XCircleIcon className="w-8 h-8 text-[var(--danger)]" />
</div> </div>
<p className="font-semibold text-[var(--text)]">کاربر یافت نشد</p> <p className="font-semibold text-[var(--text)]">کاربر یافت نشد</p>
@@ -356,14 +350,14 @@ export default function UserDetailPage() {
<div className="cp-card overflow-hidden"> <div className="cp-card overflow-hidden">
{/* Cover strip */} {/* 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 */} {/* Profile header */}
<div className="px-6 pb-6 -mt-10 flex flex-col sm:flex-row sm:items-end gap-4"> <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} /> <BigAvatar name={user.name} id={user.id} />
<div className="flex-1 min-w-0 sm:mb-1"> <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>} {user.name || <span className="text-[var(--text-3)] font-normal">نام ثبت نشده</span>}
</h1> </h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
@@ -372,12 +366,12 @@ export default function UserDetailPage() {
{roleMeta.label} {roleMeta.label}
</span> </span>
{user.is_active ? ( {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="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-green-500 animate-pulse" />فعال <span className="w-1.5 h-1.5 rounded-full bg-[var(--success)] animate-pulse" />فعال
</span> </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="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-slate-400" />غیرفعال <span className="w-1.5 h-1.5 rounded-full bg-[var(--text-3)]" />غیرفعال
</span> </span>
)} )}
<span className="text-xs text-[var(--text-3)]">#{user.id}</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" 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 {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" /> : <CheckCircleIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
} }
{user.is_active ? 'غیرفعال کردن حساب' : 'فعال‌سازی حساب'} {user.is_active ? 'غیرفعال کردن حساب' : 'فعال‌سازی حساب'}
@@ -420,13 +414,13 @@ export default function UserDetailPage() {
onClick={() => { setRoleOpen(true); setMenuOpen(false); }} 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" 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> </button>
<div className="border-t border-[var(--border)] my-1" /> <div className="border-t border-[var(--border)] my-1" />
<button <button
onClick={() => { setDeleteOpen(true); setMenuOpen(false); }} 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" /> <TrashIcon className="w-4 h-4 shrink-0" />
حذف این کاربر حذف این کاربر
@@ -469,7 +463,7 @@ export default function UserDetailPage() {
</div> </div>
<button <button
onClick={() => setRoleOpen(true)} 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> </button>
@@ -484,7 +478,7 @@ export default function UserDetailPage() {
</p> </p>
<button <button
onClick={() => { navigator.clipboard.writeText(user.uuid); toast.success('UUID کپی شد'); }} 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" /> <ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button> </button>
@@ -496,20 +490,20 @@ export default function UserDetailPage() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[var(--text)]">وضعیت حساب</h2> <h2 className="text-sm font-semibold text-[var(--text)]">وضعیت حساب</h2>
{user.is_active {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> : <span className="text-xs font-medium text-[var(--text-2)]">غیرفعال</span>
} }
</div> </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={`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-green-500' : 'w-0'}`} /> <div className={`h-full rounded-full transition-all ${user.is_active ? 'w-full bg-[var(--success)]' : 'w-0'}`} />
</div> </div>
<button <button
onClick={() => toggleStatusMut.mutate()} onClick={() => toggleStatusMut.mutate()}
disabled={toggleStatusMut.isPending} disabled={toggleStatusMut.isPending}
className={`w-full mt-4 text-sm font-medium py-2 rounded-xl transition-colors disabled:opacity-50 ${ className={`w-full mt-4 text-sm font-medium py-2 rounded-xl transition-colors disabled:opacity-50 ${
user.is_active 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(--accent-bg)] text-[var(--accent)] hover:bg-[var(--accent-bg)]'
: '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(--success-bg)] text-[var(--success)] hover:bg-[var(--success-bg)]'
}`} }`}
> >
{toggleStatusMut.isPending {toggleStatusMut.isPending
@@ -561,14 +555,14 @@ export default function UserDetailPage() {
.map(format) .map(format)
.filter((s) => s && s.trim()); .filter((s) => s && s.trim());
return ( 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> <p className="text-[11px] font-medium text-[var(--text-3)] uppercase tracking-wide mb-1.5">{label}</p>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-[var(--text-3)]">موردی ثبت نشده</p> <p className="text-sm text-[var(--text-3)]">موردی ثبت نشده</p>
) : ( ) : (
<ul className="flex flex-wrap gap-1.5"> <ul className="flex flex-wrap gap-1.5">
{items.map((text, i) => ( {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} {text}
</li> </li>
))} ))}
@@ -624,10 +618,10 @@ export default function UserDetailPage() {
<input <input
type="text" type="text"
placeholder="مثال: علی احمدی" placeholder="مثال: علی احمدی"
className={`cp-input ${errors.name ? 'border-red-400 dark:border-red-500' : ''}`} className={`cp-input ${errors.name ? 'border-[var(--danger)]' : ''}`}
{...register('name')} {...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> </div>
{/* Email */} {/* Email */}
@@ -639,10 +633,10 @@ export default function UserDetailPage() {
type="email" type="email"
placeholder="example@email.com" placeholder="example@email.com"
dir="ltr" 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')} {...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> </div>
{/* Password */} {/* Password */}
@@ -657,11 +651,11 @@ export default function UserDetailPage() {
type="password" type="password"
placeholder="حداقل ۶ کاراکتر" placeholder="حداقل ۶ کاراکتر"
dir="ltr" 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')} {...register('password')}
/> />
</div> </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> </div>
{/* Mobile (read-only) */} {/* Mobile (read-only) */}
@@ -675,7 +669,7 @@ export default function UserDetailPage() {
value={user.mobile_number} value={user.mobile_number}
readOnly readOnly
dir="ltr" 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> </div>
</form> </form>
+9 -12
View File
@@ -9,18 +9,11 @@ import { describe, expect, it } from 'vitest';
const ROOT = join(__dirname, '..'); const ROOT = join(__dirname, '..');
/** فاز ۳ — هنوز جفت slate/gray + dark: دارند؛ کار می‌کنند ولی از design-system منحرف‌اند. */
const DEFERRED = [
'pages/DoctorDetailPage.tsx',
'pages/UserDetailPage.tsx',
'components/schedule/ScheduleSection.tsx',
];
/** رنگ‌های عمداً ثابت: دادهٔ کاربر یا پوششی که در هر دو تم یکسان است. */ /** رنگ‌های عمداً ثابت: دادهٔ کاربر یا پوششی که در هر دو تم یکسان است. */
const INTENTIONAL = [ const INTENTIONAL = [
'pages/TagsSettingsPage.tsx', // TAG_COLORS — پالت انتخابی کاربر برای برچسب 'pages/TagsSettingsPage.tsx', // TAG_COLORS — پالت انتخابی کاربر برای برچسب
'components/ImageCropModal.tsx', // bg-black/50 — اسکریم مودال، مثل .overlay
'stores/uiStore.ts', // BrandFixed — هیوی نارنجی عمداً hex ثابت است 'stores/uiStore.ts', // BrandFixed — هیوی نارنجی عمداً hex ثابت است
'lib/avatarColors.ts', // گرادیان تزئینی آواتار، در هر دو تم یکسان
]; ];
const HEX = /#[0-9a-fA-F]{3,8}\b/; const HEX = /#[0-9a-fA-F]{3,8}\b/;
@@ -42,12 +35,16 @@ function sourceFiles(): string[] {
return out; return out;
} }
/** کامنت‌ها و fallbackهای `var(--x, #hex)` رنگِ رندرشده نیستند. */ /**
* کامنت‌ها و fallbackهای `var(--x, #hex)` رنگِ رندرشده نیستند؛ `bg-black/NN` هم
* اسکریم است و عمداً در هر دو تم یکسان می‌ماند (مثل `.overlay` در styles.css).
*/
function strip(src: string): string { function strip(src: string): string {
return src return src
.replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1') .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', () => { describe('theme tokens', () => {
@@ -69,14 +66,14 @@ describe('theme tokens', () => {
it('فایل‌های پاک‌شده hex ثابت ندارند', () => { it('فایل‌های پاک‌شده hex ثابت ندارند', () => {
const offenders = files const offenders = files
.filter((f) => ![...DEFERRED, ...INTENTIONAL].includes(f)) .filter((f) => !INTENTIONAL.includes(f))
.filter((f) => HEX.test(strip(readFileSync(join(ROOT, f), 'utf8')))); .filter((f) => HEX.test(strip(readFileSync(join(ROOT, f), 'utf8'))));
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it('فایل‌های پاک‌شده کلاس پالت Tailwind ندارند', () => { it('فایل‌های پاک‌شده کلاس پالت Tailwind ندارند', () => {
const offenders = files 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')))); .filter((f) => PALETTE_UTIL.test(strip(readFileSync(join(ROOT, f), 'utf8'))));
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
+7 -3
View File
@@ -478,9 +478,13 @@ Row 3: [Bar Chart — آمار ماهانه نمایندگان (60%)] | [لیس
رنگ را از والد بگیر، یا `style={{ stroke: 'var(--…)' }}`. رنگ را از والد بگیر، یا `style={{ stroke: 'var(--…)' }}`.
- الفای هگزی (`${color}30`) روی توکن بی‌معنی است — `color-mix(in srgb, var(--x) 19%, transparent)`. - الفای هگزی (`${color}30`) روی توکن بی‌معنی است — `color-mix(in srgb, var(--x) 19%, transparent)`.
فاز باقی‌مانده: `pages/DoctorDetailPage.tsx`، `pages/UserDetailPage.tsx` و کل `assets/admin` مهاجرت کرده است؛ تست گارد `DEFERRED` ندارد و فقط سه استثنای عمدی
`components/schedule/ScheduleSection.tsx` هنوز جفت `slate/gray` + `dark:` دارند دارد: پالت برچسب‌ها، `BrandFixed` در `uiStore`، و گرادیان تزئینی آواتار
(کار می‌کنند ولی با brand hue هماهنگ نمی‌شوند) — در تست گارد به‌عنوان `DEFERRED` علامت خورده‌اند. (`lib/avatarColors.ts`). اسکریم `bg-black/NN` هم مجاز است.
> نکتهٔ تاریخی: کلاس‌هایی مثل `text-primary-600` / `bg-primary-100` هیچ‌وقت رنگی تولید
> نمی‌کردند — بلوک `@theme` فقط `--font-sans` را تعریف می‌کند و مقیاس `primary-*` در
> Tailwind وجود ندارد. ۴۳ نمونه از این کلاس‌های مرده در آدیت به توکن تبدیل شدند.
--- ---