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:
@@ -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: "کاربر",
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user