- Refactor color palette in `ui-design-spec.md` to utilize CSS variables exclusively, eliminating fixed hex values and Tailwind utility classes. - Complete dark mode implementation in `uiStore.ts`, ensuring proper theme application via `applyTheme()` and `applyBrand()`. - Create `admin-theme-dark-light-audit.md` to document the transition process, outlining issues with inline styles and fixed colors. - Introduce `theme-tokens.test.ts` to enforce rules against fixed hex colors and ensure compliance with the design system. - Update various components and styles to replace inline styles and fixed colors with CSS variables, ensuring consistent theming across light and dark modes. - Ensure all changes maintain visual integrity in both light and dark modes, with a focus on accessibility and contrast standards.
467 lines
24 KiB
TypeScript
467 lines
24 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import { useForm, Controller } from 'react-hook-form';
|
||
import { zodResolver } from '@hookform/resolvers/zod';
|
||
import { z } from 'zod';
|
||
import {
|
||
ArrowRightIcon, UserPlusIcon, PhoneIcon,
|
||
IdentificationIcon, AcademicCapIcon, DocumentTextIcon,
|
||
HeartIcon, ChevronLeftIcon, CheckIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import { toast } from 'sonner';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||
import MobileInput from '../components/ui/MobileInput';
|
||
import PersianDatePicker from '../components/ui/PersianDatePicker';
|
||
import { iranMobileSchema } from '../lib/utils';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
|
||
// ── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null }
|
||
|
||
// ── Schema ───────────────────────────────────────────────────────────────────
|
||
|
||
const schema = z.object({
|
||
mobile: iranMobileSchema,
|
||
name: z.string().min(2, 'نام حداقل ۲ کاراکتر'),
|
||
gender: z.enum(['man', 'woman']).optional().or(z.literal('')),
|
||
degree: z.string().optional().or(z.literal('')),
|
||
medical_system_code: z.string().max(30).optional().or(z.literal('')),
|
||
info: z.string().max(2000).optional().or(z.literal('')),
|
||
});
|
||
type FormValues = z.infer<typeof schema>;
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const DEGREE_OPTIONS = [
|
||
{ value: 'general', label: 'پزشک عمومی' },
|
||
{ value: 'specialist', label: 'متخصص' },
|
||
{ value: 'expert', label: 'فوق تخصص' },
|
||
{ value: 'subspecialistplus', label: 'فلوشیپ' },
|
||
];
|
||
|
||
const GENDER_OPTIONS = [
|
||
{ value: 'man', label: 'مرد', icon: '♂' },
|
||
{ value: 'woman', label: 'زن', icon: '♀' },
|
||
];
|
||
|
||
// ── Field ─────────────────────────────────────────────────────────────────────
|
||
|
||
function Field({ label, required, error, hint, children }: {
|
||
label: string; required?: boolean; error?: string; hint?: string; children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label className="cp-label">
|
||
{label}{required && <span style={{ color: 'var(--danger)', marginRight: 2 }}>*</span>}
|
||
</label>
|
||
{children}
|
||
{hint && !error && <p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 5 }}>{hint}</p>}
|
||
{error && <p style={{ fontSize: 12, color: 'var(--danger)', marginTop: 5 }}>{error}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Specialty Picker ──────────────────────────────────────────────────────────
|
||
//
|
||
// منطق انتخاب:
|
||
// • تخصص با فرزند → کلیک روی فرزند: parent_id + child_id هر دو ذخیره میشوند
|
||
// در هر گروه فقط یک فرزند قابل انتخاب است (جایگزین میشود)
|
||
// • تخصص بدون فرزند → فقط خودش انتخاب میشود
|
||
// • حذف chip: parent + child هر دو پاک میشوند
|
||
|
||
interface SelectedEntry { parentId: number | null; childId: number }
|
||
|
||
function SpecialtyPicker({ selected, onChange, specialties }: {
|
||
selected: number[]; onChange: (ids: number[]) => void;
|
||
specialties: SpecialtyOption[];
|
||
}) {
|
||
const [activeParentId, setActiveParentId] = useState<number | null>(null);
|
||
|
||
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
|
||
const childMap = useMemo(() => {
|
||
const m: Record<number, SpecialtyOption[]> = {};
|
||
specialties.forEach(s => {
|
||
if (s.parent_id !== null) {
|
||
if (!m[s.parent_id]) m[s.parent_id] = [];
|
||
m[s.parent_id].push(s);
|
||
}
|
||
});
|
||
return m;
|
||
}, [specialties]);
|
||
|
||
const activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
|
||
|
||
// برای هر گروه، child انتخابشده را پیدا میکند
|
||
const selectedChildOfParent = (parentId: number): number | null => {
|
||
const kids = childMap[parentId] ?? [];
|
||
return kids.find(k => selected.includes(k.id))?.id ?? null;
|
||
};
|
||
|
||
// آیا یک والد بدون فرزند انتخاب شده (مستقیم)
|
||
const isRootSelected = (parentId: number): boolean =>
|
||
!(childMap[parentId]?.length) && selected.includes(parentId);
|
||
|
||
const selectChild = (child: SpecialtyOption) => {
|
||
const parentId = child.parent_id!;
|
||
if (selected.includes(child.id)) {
|
||
onChange([]);
|
||
} else {
|
||
onChange([parentId, child.id]);
|
||
}
|
||
};
|
||
|
||
const selectRoot = (root: SpecialtyOption) => {
|
||
if (selected.includes(root.id)) {
|
||
onChange([]);
|
||
} else {
|
||
setActiveParentId(null);
|
||
onChange([root.id]);
|
||
}
|
||
};
|
||
|
||
const removeEntry = () => { onChange([]); setActiveParentId(null); };
|
||
|
||
// ساخت chip ها: از selected فقط childها + rootهای مستقیم را نمایش بده
|
||
const chips: SelectedEntry[] = useMemo(() => {
|
||
return selected
|
||
.map(id => {
|
||
const s = specialties.find(x => x.id === id);
|
||
if (!s) return null;
|
||
if (s.parent_id !== null) return { parentId: s.parent_id, childId: s.id };
|
||
// اگر parent است و childای از آن انتخاب نشده → root مستقیم
|
||
const hasSelectedChild = (childMap[s.id] ?? []).some(k => selected.includes(k.id));
|
||
if (!hasSelectedChild && !(childMap[s.id]?.length)) return { parentId: null, childId: s.id };
|
||
return null; // parentهایی که فرزند دارند و فرزندشان انتخاب شده: در chip فرزند نمایش داده میشوند
|
||
})
|
||
.filter(Boolean) as SelectedEntry[];
|
||
}, [selected, specialties, childMap]);
|
||
|
||
return (
|
||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
|
||
|
||
{/* Selected chips */}
|
||
{chips.length > 0 && (
|
||
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||
{chips.map(({ parentId, childId }) => {
|
||
const child = specialties.find(x => x.id === childId);
|
||
const parent = parentId !== null ? specialties.find(x => x.id === parentId) : null;
|
||
if (!child) return null;
|
||
const label = parent ? `${parent.name} — ${child.name}` : child.name;
|
||
return (
|
||
<span key={childId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, padding: '3px 10px 3px 6px', borderRadius: 999, background: 'var(--primary-soft2)', color: 'var(--primary-700)', fontWeight: 600 }}>
|
||
{label}
|
||
<button type="button" onClick={() => removeEntry()}
|
||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, borderRadius: '50%', background: 'var(--primary-soft)', border: 'none', cursor: 'pointer', color: 'var(--primary-600)', padding: 0, fontSize: 12, fontWeight: 700 }}>×</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', minHeight: 240 }}>
|
||
{/* Parents column */}
|
||
<div style={{ width: '45%', borderLeft: '1px solid var(--border)', overflowY: 'auto', maxHeight: 300 }}>
|
||
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
|
||
گروه تخصصی
|
||
</div>
|
||
{parents.map(p => {
|
||
const hasChildren = (childMap[p.id]?.length ?? 0) > 0;
|
||
const childSel = hasChildren ? selectedChildOfParent(p.id) : null;
|
||
const rootSel = !hasChildren && isRootSelected(p.id);
|
||
const isMarked = childSel !== null || rootSel;
|
||
const isActive = activeParentId === p.id;
|
||
const hasSelection = selected.length > 0;
|
||
const isDisabled = hasSelection && !isMarked;
|
||
return (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
disabled={isDisabled}
|
||
onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
|
||
style={{
|
||
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '10px 14px', fontSize: 13, border: 'none',
|
||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||
opacity: isDisabled ? 0.35 : 1,
|
||
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
|
||
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
|
||
fontWeight: (isActive || rootSel) ? 700 : 400,
|
||
borderRight: (isActive || rootSel) ? '3px solid var(--primary)' : '3px solid transparent',
|
||
transition: 'all .14s',
|
||
}}
|
||
>
|
||
<span style={{ flex: 1, lineHeight: 1.4 }}>{p.name}</span>
|
||
{isMarked && !rootSel && (
|
||
<span style={{ fontSize: 11, fontWeight: 700, background: 'var(--primary)', color: 'var(--on-primary)', borderRadius: 999, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
|
||
✓
|
||
</span>
|
||
)}
|
||
{rootSel && <CheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />}
|
||
{hasChildren && <ChevronLeftIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Children column */}
|
||
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
|
||
{activeParentId === null ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13, gap: 8, padding: 24 }}>
|
||
<HeartIcon style={{ width: 32, height: 32, opacity: .3 }} />
|
||
<span>یک گروه تخصصی انتخاب کنید</span>
|
||
</div>
|
||
) : activeChildren.length === 0 ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
|
||
زیرمجموعهای یافت نشد
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
|
||
انتخاب تخصص — یک مورد
|
||
</div>
|
||
{activeChildren.map(s => {
|
||
const checked = selected.includes(s.id);
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
onClick={() => selectChild(s)}
|
||
style={{
|
||
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '10px 14px', border: 'none', cursor: 'pointer',
|
||
background: checked ? 'var(--primary-soft)' : 'transparent',
|
||
borderBottom: '1px solid var(--border)',
|
||
transition: 'background .12s',
|
||
}}
|
||
>
|
||
<div style={{ width: 18, height: 18, borderRadius: '50%', border: `2px solid ${checked ? 'var(--primary)' : 'var(--border-2)'}`, background: checked ? 'var(--primary)' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all .12s' }}>
|
||
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--on-primary)' }} />}
|
||
</div>
|
||
<span style={{ fontSize: 13, color: checked ? 'var(--primary-700)' : 'var(--text)', fontWeight: checked ? 600 : 400 }}>{s.name}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function DoctorFormPage() {
|
||
const navigate = useNavigate();
|
||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||
const createDoctorEndpoint =
|
||
primaryRole === 'representation' ? '/api/v1/representation/doctor' : '/api/v1/admin/doctors';
|
||
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
|
||
const [gender, setGender] = useState<'man' | 'woman' | ''>('');
|
||
const [activityDate, setActivityDate] = useState('');
|
||
|
||
const specialtiesQ = useQuery({
|
||
queryKey: ['specialties-list'],
|
||
queryFn: () => api.get<ApiResponse<SpecialtyOption[]>>('/api/v1/specialties'),
|
||
staleTime: 300_000,
|
||
});
|
||
|
||
const specialties: SpecialtyOption[] = useMemo(
|
||
() => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [],
|
||
[specialtiesQ.data],
|
||
);
|
||
|
||
const { register, handleSubmit, control, formState: { errors } } = useForm<FormValues>({
|
||
resolver: zodResolver(schema),
|
||
});
|
||
|
||
const createMut = useMutation({
|
||
mutationFn: (values: FormValues) =>
|
||
api.post<ApiResponse<{ uuid: string }>>(createDoctorEndpoint, {
|
||
mobile: values.mobile,
|
||
name: values.name,
|
||
gender: gender || undefined,
|
||
degree: values.degree || undefined,
|
||
medical_system_code: values.medical_system_code || undefined,
|
||
info: values.info || undefined,
|
||
...(activityDate
|
||
? { activity_time: Math.floor(new Date(`${activityDate}T12:00:00`).getTime() / 1000) }
|
||
: {}),
|
||
specialties: selectedSpecialties,
|
||
}),
|
||
onSuccess: (res) => {
|
||
const uuid = (res?.data as any)?.uuid ?? res?.data?.uuid;
|
||
toast.success('پزشک با موفقیت اضافه شد');
|
||
if (uuid) navigate(`/admin/doctors/${uuid}`);
|
||
else navigate('/admin/doctors');
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const onSubmit = (values: FormValues) => createMut.mutate(values);
|
||
|
||
return (
|
||
<div style={{ maxWidth: 680, margin: '0 auto' }} className="animate-slide-up">
|
||
|
||
{/* Breadcrumb */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-3)', marginBottom: 20 }}>
|
||
<button onClick={() => navigate('/admin/doctors')} style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', fontSize: 13, padding: 0 }}>
|
||
<ArrowRightIcon style={{ width: 14, height: 14 }} />
|
||
پزشکان
|
||
</button>
|
||
<span>/</span>
|
||
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit(onSubmit)}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
|
||
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
|
||
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
|
||
<SectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل میشود">
|
||
<MobileInput hasError={!!errors.mobile} {...register('mobile')} />
|
||
</Field>
|
||
<Field label="نام کامل" required error={errors.name?.message} hint="عنوان «دکتر» خودکار اضافه میشود؛ لازم نیست تایپ کنید">
|
||
<div className="cp-input" style={{
|
||
display: 'flex', alignItems: 'center', gap: 6, padding: 0, overflow: 'hidden',
|
||
...(errors.name ? { borderColor: 'var(--danger)' } : {}),
|
||
}}>
|
||
<span style={{
|
||
flexShrink: 0, alignSelf: 'stretch', display: 'flex', alignItems: 'center',
|
||
padding: '0 12px', background: 'var(--surface-2)', color: 'var(--text-2)',
|
||
fontWeight: 600, fontSize: 14, borderInlineEnd: '1px solid var(--border)',
|
||
}}>دکتر</span>
|
||
<input type="text" placeholder="مثلاً: حامد حسینی" {...register('name')}
|
||
style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', padding: '0 12px', height: '100%', color: 'inherit', font: 'inherit' }} />
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Section: اطلاعات حرفهای ────────────────────────────── */}
|
||
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
|
||
<SectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفهای" />
|
||
|
||
{/* Gender toggle */}
|
||
<div style={{ marginBottom: 14 }}>
|
||
<label className="cp-label">جنسیت</label>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
{GENDER_OPTIONS.map(o => (
|
||
<button key={o.value} type="button" onClick={() => setGender(gender === o.value ? '' : o.value as 'man' | 'woman')}
|
||
style={{
|
||
flex: 1, height: 42, borderRadius: 'var(--r-sm)', border: `1.5px solid ${gender === o.value ? 'var(--primary)' : 'var(--border)'}`,
|
||
background: gender === o.value ? 'var(--primary-soft)' : 'var(--surface)',
|
||
color: gender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
|
||
fontWeight: gender === o.value ? 700 : 400, fontSize: 14, cursor: 'pointer', transition: 'all .14s',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||
}}>
|
||
<span style={{ fontSize: 16 }}>{o.icon}</span>{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<Field label="درجه تحصیلی">
|
||
<Controller name="degree" control={control} render={({ field }) => (
|
||
<SearchableSelect
|
||
options={DEGREE_OPTIONS}
|
||
value={field.value ?? null}
|
||
onChange={(v) => field.onChange(v ?? '')}
|
||
placeholder="انتخاب کنید"
|
||
isClearable
|
||
/>
|
||
)} />
|
||
</Field>
|
||
<Field label="کد نظام پزشکی">
|
||
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
|
||
</Field>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 14 }}>
|
||
<Field label="تاریخ شروع فعالیت">
|
||
<PersianDatePicker
|
||
value={activityDate}
|
||
onChange={setActivityDate}
|
||
placeholder="انتخاب تاریخ"
|
||
enableYearPicker
|
||
minWidth={200}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 14 }}>
|
||
<Field label="بیوگرافی">
|
||
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Section: تخصصها ────────────────────────────────────── */}
|
||
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
|
||
<SectionHeader
|
||
icon={<AcademicCapIcon style={{ width: 16, height: 16 }} />}
|
||
title="تخصصها"
|
||
badge={selectedSpecialties.length > 0 ? `${selectedSpecialties.length} انتخاب شده` : undefined}
|
||
/>
|
||
{specialtiesQ.isLoading ? (
|
||
<div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
|
||
) : (
|
||
<SpecialtyPicker
|
||
selected={selectedSpecialties}
|
||
onChange={setSelectedSpecialties}
|
||
specialties={specialties}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Actions ─────────────────────────────────────────────── */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, paddingBottom: 8 }}>
|
||
<button type="button" onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary">
|
||
انصراف
|
||
</button>
|
||
<button type="submit" disabled={createMut.isPending} className="cp-btn-primary" style={{ minWidth: 140 }}>
|
||
{createMut.isPending ? (
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,.3)', borderTopColor: 'var(--surface)', borderRadius: '50%', animation: 'spin .7s linear infinite', flexShrink: 0 }} />
|
||
در حال ذخیره...
|
||
</span>
|
||
) : (
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<UserPlusIcon style={{ width: 16, height: 16 }} />
|
||
افزودن پزشک
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Section Header ────────────────────────────────────────────────────────────
|
||
|
||
function SectionHeader({ icon, title, badge }: { icon: React.ReactNode; title: string; badge?: string }) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, paddingBottom: 14, borderBottom: '1px solid var(--border)' }}>
|
||
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||
{icon}
|
||
</div>
|
||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{title}</span>
|
||
{badge && (
|
||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
|
||
{badge}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|