feat: add ROLE_REPRESENTATION access to admin panel for managing doctors and clinics

- Updated authStore to include 'representation' role.
- Modified DoctorFormPage and DoctorsPage to handle different endpoints based on user role.
- Created new RepresentationActionController for handling doctor and clinic creation by representatives.
- Added new API endpoints for representatives to manage doctors, clinics, and view appointments.
- Updated documentation to reflect new role and API changes.
This commit is contained in:
hamed
2026-06-19 13:20:40 +03:30
parent a8d36d7455
commit fe73fa1a05
14 changed files with 778 additions and 19 deletions
+4 -4
View File
@@ -43,7 +43,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
// ── Guards ──────────────────────────────────────────────────────────────────
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary'] as const;
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
@@ -146,7 +146,7 @@ export default function App() {
<Route path="blogs/new" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
<Route path="blogs/:uuid/edit" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
<Route path="secretaries" element={<RoleRoute roles={['admin']}><SecretariesPage /></RoleRoute>} />
<Route path="clinics" element={<RoleRoute roles={['admin']}><ClinicsPage /></RoleRoute>} />
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
@@ -156,8 +156,8 @@ export default function App() {
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه می‌کند */}
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
<Route path="profile" element={<RoleRoute roles={['doctor']}><DoctorProfilePage /></RoleRoute>} />
@@ -310,6 +310,25 @@ function buildSections(
];
}
if (primaryRole === "representation") {
return [
{
label: "عمومی",
items: [
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
],
},
{
label: "مدیریت",
items: [
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{ to: "/admin/clinics", icon: BuildingOffice2Icon, label: "کلینیک‌ها" },
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبت‌ها" },
],
},
];
}
return [
{
label: "عمومی",
@@ -329,6 +348,7 @@ const ROLE_LABELS: Record<string, string> = {
clinic: "مالک کلینیک",
doctor: "پزشک",
secretary: "منشی",
representation: "نماینده",
user: "کاربر",
};
+6 -1
View File
@@ -530,6 +530,7 @@ export default function AppointmentsPage() {
const isAdmin = primaryRole === 'admin';
const isClinic = primaryRole === 'clinic';
const isDoctor = primaryRole === 'doctor';
const isRepresentation = primaryRole === 'representation';
const today = new Date().toISOString().slice(0, 10);
const [selectedDate, setSelectedDate] = useState(today);
@@ -540,7 +541,11 @@ export default function AppointmentsPage() {
const qc = useQueryClient();
// ── Appointments query
const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
const apptEndpoint = isAdmin
? '/api/v1/admin/appointments'
: isRepresentation
? '/api/v1/representation/appointments'
: '/api/v1/my/appointments';
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
+13 -5
View File
@@ -15,6 +15,7 @@ import { z } from 'zod';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Clinic } from '../types';
import { useAuthStore } from '../stores/authStore';
import { formatDate, formatNumber } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
@@ -31,6 +32,8 @@ type AddForm = z.infer<typeof addSchema>;
export default function ClinicsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const isRepresentation = primaryRole === 'representation';
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -38,13 +41,14 @@ export default function ClinicsPage() {
const [addOpen, setAddOpen] = useState(false);
const limit = 15;
const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics';
const { data, isLoading } = useQuery({
queryKey: ['admin-clinics', page, search, statusFilter],
queryKey: ['admin-clinics', page, search, statusFilter, isRepresentation],
queryFn: () =>
api.get<PaginatedResponse<Clinic>>(
`/api/v1/admin/clinics?page=${page}&limit=${limit}` +
`${listBase}?page=${page}&limit=${limit}` +
(search ? `&search=${encodeURIComponent(search)}` : '') +
(statusFilter !== '' ? `&status=${statusFilter}` : ''),
(!isRepresentation && statusFilter !== '' ? `&status=${statusFilter}` : ''),
),
});
@@ -70,13 +74,17 @@ export default function ClinicsPage() {
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
const addMutation = useMutation({
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/clinic', d),
mutationFn: (d: AddForm) =>
api.post<ApiResponse<{ uuid: string }>>(
isRepresentation ? '/api/v1/representation/clinic' : '/api/v1/admin/clinic',
d,
),
onSuccess: (res) => {
toast.success('کلینیک اضافه شد');
setAddOpen(false);
addForm.reset();
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
if (res?.data?.uuid) navigate(`/admin/clinics/${res.data.uuid}`);
if (!isRepresentation && res?.data?.uuid) navigate(`/admin/clinics/${res.data.uuid}`);
},
onError: (err: Error) => toast.error(err.message),
});
+90 -4
View File
@@ -991,16 +991,102 @@ function SecretaryDashboard() {
);
}
function RepresentationDashboard() {
const now = new Date();
const jYear = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).format(now));
const jMonth = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { month: 'numeric' }).format(now));
const meQ = useQuery({
queryKey: ['representation-me'],
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string; commission_percent: string } }>>('/api/v1/representation/me'),
staleTime: 300_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rep = useMemo<any>(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]);
const repUuid: string | undefined = rep?.uuid;
const monthlyQ = useQuery({
queryKey: ['representation-monthly', repUuid, jYear, jMonth],
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
`/api/v1/representation/${repUuid}/dashboard/monthly?year=${jYear}&month=${jMonth}`,
),
enabled: !!repUuid,
staleTime: 120_000,
});
const yearlyQ = useQuery({
queryKey: ['representation-yearly', repUuid, jYear],
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
`/api/v1/representation/${repUuid}/dashboard/yearly?year=${jYear}`,
),
enabled: !!repUuid,
staleTime: 120_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const monthly = useMemo<any>(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const yearly = useMemo<any>(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]);
if (meQ.isLoading) return <LoadingSkeleton />;
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
const cards = [
{ label: 'نوبت‌های این ماه', value: formatNumber(monthly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'کمیسیون این ماه', value: formatRial(monthly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
{ label: 'نوبت‌های امسال', value: formatNumber(yearly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'کمیسیون امسال', value: formatRial(yearly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
];
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">داشبورد نماینده</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {rep?.full_name ?? ''}</div>
</div>
<button className="btn ghost sm" onClick={() => { monthlyQ.refetch(); yearlyQ.refetch(); }}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
{cards.map(c => (
<div key={c.label} className="stat">
<div className="ico" style={{ background: c.bg, color: c.color }}>
<c.icon style={{ width: 21, height: 21 }} />
</div>
<div className="lbl">{c.label}</div>
<div className="val">{c.value}</div>
</div>
))}
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>دسترسی سریع</h3>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/admin/doctors" className="btn sm">پزشکان من</Link>
<Link to="/admin/clinics" className="btn sm">کلینیکها</Link>
<Link to="/admin/appointments" className="btn sm">نوبتها</Link>
</div>
</div>
</div>
);
}
// ── Main Dispatcher ───────────────────────────────────────────────────────
export default function DashboardPage() {
const primaryRole = useAuthStore(s => s.primaryRole);
if (!primaryRole) return <LoadingSkeleton />;
if (primaryRole === 'admin') return <AdminDashboard />;
if (primaryRole === 'clinic') return <ClinicDashboard />;
if (primaryRole === 'doctor') return <DoctorDashboard />;
if (primaryRole === 'secretary') return <SecretaryDashboard />;
if (primaryRole === 'admin') return <AdminDashboard />;
if (primaryRole === 'clinic') return <ClinicDashboard />;
if (primaryRole === 'doctor') return <DoctorDashboard />;
if (primaryRole === 'secretary') return <SecretaryDashboard />;
if (primaryRole === 'representation') return <RepresentationDashboard />;
return <AdminDashboard />;
}
+5 -1
View File
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -253,6 +254,9 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
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' | ''>('');
@@ -273,7 +277,7 @@ export default function DoctorFormPage() {
const createMut = useMutation({
mutationFn: (values: FormValues) =>
api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/doctors', {
api.post<ApiResponse<{ uuid: string }>>(createDoctorEndpoint, {
mobile: values.mobile,
name: values.name,
gender: gender || undefined,
+6 -1
View File
@@ -13,6 +13,7 @@ import { formatDate, formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useAuthStore } from '../stores/authStore';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -91,6 +92,8 @@ function DoctorAvatar({ name, id, image, size = 'sm' }: {
export default function DoctorsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const primaryRole = useAuthStore(s => s.primaryRole);
const isRepresentation = primaryRole === 'representation';
const [page, setPage] = useState(1);
const [limit] = useState(25);
@@ -114,6 +117,7 @@ export default function DoctorsPage() {
queryKey: ['doctors-stats'],
queryFn: () => api.get<ApiResponse<DoctorStats>>('/api/v1/admin/doctors/stats'),
staleTime: 30_000,
enabled: !isRepresentation,
});
const specialtiesQ = useQuery({
@@ -129,7 +133,8 @@ export default function DoctorsPage() {
if (search) p.set('search', search);
if (status) p.set('status', status);
if (specialtyId) p.set('specialty_id', specialtyId);
return api.get<PaginatedResponse<AdminDoctor>>(`/api/v1/admin/doctors?${p}`);
const base = isRepresentation ? '/api/v1/doctors' : '/api/v1/admin/doctors';
return api.get<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
},
});
+2 -2
View File
@@ -5,7 +5,7 @@ export interface ContextItem {
type: 'doctor' | 'clinic';
db_uuid: string;
name: string;
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user';
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user';
doctor_uuid?: string;
permissions?: Record<string, any>;
}
@@ -16,7 +16,7 @@ interface AuthState {
isAuthenticated: boolean;
userUuid: string | null;
userName: string | null;
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user' | null;
dbUuid: string | null;
dbKey: string | null;
doctorUuid: string | null;