feat(api): add dashboard endpoints for clinic, doctor, and secretary roles
- Implemented GET /api/v1/dashboard/clinic to return clinic stats and today's schedule for clinic owners. - Implemented GET /api/v1/dashboard/doctor to return doctor's stats and today's schedule for doctors. - Implemented GET /api/v1/dashboard/secretary to return stats and conditional appointments for secretaries. feat(migrations): create user_active_context and mobile_verification_otp tables - Added migration to create user_active_context table for tracking active user sessions. - Added migration to create mobile_verification_otp table for handling mobile number verification. feat(migrations): create site_config table for application settings - Added migration to create site_config table to store various site configuration settings. feat(appointments): create MyAppointmentsController for user-specific appointments - Added MyAppointmentsController to handle fetching user-specific appointments with pagination and filtering. feat(auth): implement NotificationMobileController for mobile number verification - Added NotificationMobileController to handle OTP requests and verification for mobile number changes. feat(auth): create MobileVerificationOtp entity for OTP management - Created MobileVerificationOtp entity to manage OTP records for mobile verification. feat(auth): create UserActiveContext entity for user session management - Created UserActiveContext entity to manage user active sessions. feat(config): implement SiteConfigController for managing site settings - Added SiteConfigController to handle fetching and updating site configuration settings. feat(config): create SiteConfig entity and repository for configuration management - Created SiteConfig entity and repository to manage site configuration data.
This commit is contained in:
+77
-50
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SelectContextPage from './pages/SelectContextPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
import DoctorsPage from './pages/DoctorsPage';
|
||||
@@ -25,10 +26,33 @@ import CategoriesPage from './pages/CategoriesPage';
|
||||
import BlogsPage from './pages/BlogsPage';
|
||||
import BlogFormPage from './pages/BlogFormPage';
|
||||
import SecretariesPage from './pages/SecretariesPage';
|
||||
import MyClinicPage from './pages/MyClinicPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/admin/login" replace />;
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !primaryRole) {
|
||||
fetchMe();
|
||||
}
|
||||
}, [isAuthenticated, primaryRole, fetchMe]);
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
|
||||
|
||||
// اگر context هنوز لود نشده — صبر کن
|
||||
if (isAuthenticated && !primaryRole) {
|
||||
return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
// اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو
|
||||
if (availableContexts.length > 1 && !dbUuid) {
|
||||
return <Navigate to="/admin/select-context" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -36,74 +60,77 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// ── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route
|
||||
path="/admin/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
element={<PublicRoute><LoginPage /></PublicRoute>}
|
||||
/>
|
||||
|
||||
{/* انتخاب محیط کاری — نیاز به auth دارد اما خارج از AdminLayout */}
|
||||
<Route
|
||||
path="/admin/*"
|
||||
path="/admin/select-context"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminLayout />
|
||||
<SelectContextPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected */}
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={<PrivateRoute><AdminLayout /></PrivateRoute>}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
|
||||
{/* داشبورد — همه نقشها */}
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* Users */}
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:uuid" element={<UserDetailPage />} />
|
||||
|
||||
{/* Doctors */}
|
||||
<Route path="doctors" element={<DoctorsPage />} />
|
||||
<Route path="doctors/new" element={<DoctorFormPage />} />
|
||||
<Route path="doctors/:uuid" element={<DoctorDetailPage />} />
|
||||
|
||||
{/* Clinics */}
|
||||
<Route path="clinics" element={<ClinicsPage />} />
|
||||
<Route path="clinics/:uuid" element={<ClinicDetailPage />} />
|
||||
|
||||
{/* Appointments */}
|
||||
{/* نوبتها — همه نقشها */}
|
||||
<Route path="appointments" element={<AppointmentsPage />} />
|
||||
<Route path="appointments/:uuid" element={<AppointmentDetailPage />} />
|
||||
|
||||
{/* Payments */}
|
||||
<Route path="payments" element={<PaymentsPage />} />
|
||||
<Route path="payments/:uuid" element={<PaymentDetailPage />} />
|
||||
{/* فقط ادمین */}
|
||||
<Route path="users" element={<RoleRoute roles={['admin']}><UsersPage /></RoleRoute>} />
|
||||
<Route path="users/:uuid" element={<RoleRoute roles={['admin']}><UserDetailPage /></RoleRoute>} />
|
||||
<Route path="payments" element={<RoleRoute roles={['admin']}><PaymentsPage /></RoleRoute>} />
|
||||
<Route path="payments/:uuid" element={<RoleRoute roles={['admin']}><PaymentDetailPage /></RoleRoute>} />
|
||||
<Route path="settlements" element={<RoleRoute roles={['admin']}><SettlementsPage /></RoleRoute>} />
|
||||
<Route path="representations" element={<RoleRoute roles={['admin']}><RepresentationsPage /></RoleRoute>} />
|
||||
<Route path="representations/:uuid" element={<RoleRoute roles={['admin']}><RepresentationDetailPage /></RoleRoute>} />
|
||||
<Route path="comments" element={<RoleRoute roles={['admin']}><CommentsPage /></RoleRoute>} />
|
||||
<Route path="ratings" element={<RoleRoute roles={['admin']}><RatingsPage /></RoleRoute>} />
|
||||
<Route path="sms" element={<RoleRoute roles={['admin']}><SmsPage /></RoleRoute>} />
|
||||
<Route path="categories" element={<RoleRoute roles={['admin']}><CategoriesPage /></RoleRoute>} />
|
||||
<Route path="blogs" element={<RoleRoute roles={['admin']}><BlogsPage /></RoleRoute>} />
|
||||
<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="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
|
||||
|
||||
{/* Settlements */}
|
||||
<Route path="settlements" element={<SettlementsPage />} />
|
||||
{/* کلینیک من — fallback اگر dbUuid هنوز لود نشده */}
|
||||
<Route path="my-clinic" element={<RoleRoute roles={['clinic']}><MyClinicPage /></RoleRoute>} />
|
||||
|
||||
{/* Representations */}
|
||||
<Route path="representations" element={<RepresentationsPage />} />
|
||||
<Route path="representations/:uuid" element={<RepresentationDetailPage />} />
|
||||
|
||||
{/* Comments & Ratings */}
|
||||
<Route path="comments" element={<CommentsPage />} />
|
||||
<Route path="ratings" element={<RatingsPage />} />
|
||||
|
||||
{/* SMS */}
|
||||
<Route path="sms" element={<SmsPage />} />
|
||||
|
||||
{/* Categories */}
|
||||
<Route path="categories" element={<CategoriesPage />} />
|
||||
|
||||
{/* Blogs */}
|
||||
<Route path="blogs" element={<BlogsPage />} />
|
||||
<Route path="blogs/new" element={<BlogFormPage />} />
|
||||
<Route path="blogs/:uuid/edit" element={<BlogFormPage />} />
|
||||
|
||||
{/* Secretaries */}
|
||||
<Route path="secretaries" element={<SecretariesPage />} />
|
||||
{/* ادمین + کلینیک */}
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'clinic']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'clinic']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
BanknotesIcon,
|
||||
BuildingOffice2Icon,
|
||||
Cog6ToothIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
@@ -19,76 +21,140 @@ import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
const sections = [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
{ to: "/admin/users", icon: UserGroupIcon, label: "کاربران" },
|
||||
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
||||
type SectionItem = { to: string; icon: React.ElementType; label: string };
|
||||
type Section = { label: string; items: SectionItem[] };
|
||||
|
||||
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] {
|
||||
if (primaryRole === 'admin') {
|
||||
return [
|
||||
{
|
||||
to: "/admin/clinics",
|
||||
icon: BuildingOffice2Icon,
|
||||
label: "کلینیکها",
|
||||
label: 'عمومی',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
|
||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیکها' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
label: 'مدیریت',
|
||||
items: [
|
||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
||||
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداختها' },
|
||||
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویهحساب' },
|
||||
],
|
||||
},
|
||||
{ to: "/admin/payments", icon: CreditCardIcon, label: "پرداختها" },
|
||||
{
|
||||
to: "/admin/settlements",
|
||||
icon: BanknotesIcon,
|
||||
label: "تسویهحساب",
|
||||
label: 'محتوا',
|
||||
items: [
|
||||
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
|
||||
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
|
||||
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
|
||||
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "محتوا",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/comments",
|
||||
icon: ChatBubbleLeftEllipsisIcon,
|
||||
label: "نظرات",
|
||||
label: 'سیستم',
|
||||
items: [
|
||||
{ to: '/admin/categories', icon: TagIcon, label: 'دستهبندیها' },
|
||||
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' },
|
||||
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشیها' },
|
||||
{ to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' },
|
||||
],
|
||||
},
|
||||
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
|
||||
{ to: "/admin/blogs", icon: DocumentTextIcon, label: "بلاگ" },
|
||||
{ to: "/admin/sms", icon: DevicePhoneMobileIcon, label: "پیامک" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "سیستم",
|
||||
items: [
|
||||
{ to: "/admin/categories", icon: TagIcon, label: "دستهبندیها" },
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === 'clinic') {
|
||||
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic';
|
||||
return [
|
||||
{
|
||||
to: "/admin/representations",
|
||||
icon: UsersIcon,
|
||||
label: "نمایندگان",
|
||||
label: 'عمومی',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' },
|
||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||
],
|
||||
},
|
||||
{ to: "/admin/secretaries", icon: KeyIcon, label: "منشیها" },
|
||||
],
|
||||
},
|
||||
];
|
||||
{
|
||||
label: 'مدیریت',
|
||||
items: [
|
||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === 'doctor') {
|
||||
return [
|
||||
{
|
||||
label: 'عمومی',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'مدیریت',
|
||||
items: [
|
||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتهای من' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === 'secretary') {
|
||||
return [
|
||||
{
|
||||
label: 'عمومی',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'مدیریت',
|
||||
items: [
|
||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'عمومی',
|
||||
items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'مدیر کل',
|
||||
clinic: 'مالک کلینیک',
|
||||
doctor: 'پزشک',
|
||||
secretary: 'منشی',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const HUES = [256, 205, 162, 295, 272];
|
||||
|
||||
function avatarBg(name: string): string {
|
||||
const hue = HUES[(name.charCodeAt(0) ?? 0) % HUES.length];
|
||||
return `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mobileOpen?: boolean;
|
||||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
mobileOpen: _mobileOpen,
|
||||
onMobileClose: _onMobileClose,
|
||||
}: Props) {
|
||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sections = buildSections(primaryRole, dbUuid);
|
||||
const initials = (userName ?? 'U').charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
{/* Brand */}
|
||||
@@ -112,56 +178,49 @@ export default function Sidebar({
|
||||
key={to}
|
||||
to={to}
|
||||
title={!sidebarOpen ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${isActive ? " active" : ""}`
|
||||
}
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<Icon
|
||||
style={{
|
||||
width: 19,
|
||||
height: 19,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* تغییر محیط کاری — فقط اگر چند context دارد */}
|
||||
{availableContexts.length > 1 && (
|
||||
<div className="nav-group">
|
||||
<span className="nav-label">محیط کاری</span>
|
||||
<NavLink
|
||||
to="/admin/select-context"
|
||||
title={!sidebarOpen ? 'تغییر محیط' : undefined}
|
||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
||||
>
|
||||
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
||||
<span>تغییر محیط</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="sidebar-foot">
|
||||
<div
|
||||
className="user-chip"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/admin/login");
|
||||
}}
|
||||
onClick={() => { logout(); navigate('/admin/login'); }}
|
||||
title="خروج از سیستم"
|
||||
>
|
||||
<div
|
||||
className="avatar sm"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }}
|
||||
>
|
||||
A
|
||||
{initials}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<b>Admin</b>
|
||||
<span>مدیر سیستم</span>
|
||||
<b>{userName ?? 'کاربر'}</b>
|
||||
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span>
|
||||
</div>
|
||||
<ArrowLeftOnRectangleIcon
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
color: "var(--text-3)",
|
||||
}}
|
||||
/>
|
||||
<ArrowLeftOnRectangleIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DevicePhoneMobileIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
|
||||
interface NotificationMobileData {
|
||||
notification_mobile: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
target: 'doctor' | 'clinic';
|
||||
}
|
||||
|
||||
export default function NotificationMobileCard({ target }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [step, setStep] = useState<'idle' | 'enter_mobile' | 'enter_otp'>('idle');
|
||||
const [newMobile, setNewMobile] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['notification-mobile', target],
|
||||
queryFn: () => api.get<ApiResponse<NotificationMobileData>>(`/api/v1/notification-mobile/${target}`),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const current: string | null = ((data?.data as any)?.data ?? data?.data)?.notification_mobile ?? null;
|
||||
|
||||
const requestOtp = useMutation({
|
||||
mutationFn: (mobile: string) =>
|
||||
api.post<ApiResponse<{ message: string }>>('/api/v1/notification-mobile/request-otp', {
|
||||
target, new_mobile: mobile,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setStep('enter_otp');
|
||||
},
|
||||
onError: (e: unknown) => setError(String(e)),
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: (code: string) =>
|
||||
api.post<ApiResponse<{ notification_mobile: string }>>('/api/v1/notification-mobile/verify', {
|
||||
target, otp_code: code,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notification-mobile', target] });
|
||||
setStep('idle');
|
||||
setNewMobile('');
|
||||
setOtpCode('');
|
||||
setError(null);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onError: (e: any) => setError(e?.message ?? 'خطا در تأیید کد'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api.delete<ApiResponse<unknown>>(`/api/v1/notification-mobile/${target}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-mobile', target] }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="skeleton" style={{ height: 100, borderRadius: 'var(--r)' }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="ico" style={{ background: 'var(--info-bg)', color: 'var(--info)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<DevicePhoneMobileIcon style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 14.5, fontWeight: 600 }}>شماره اعلان نوبت</h3>
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
پیامک نوبت جدید به این شماره ارسال میشود
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نمایش شماره فعلی */}
|
||||
{current && step === 'idle' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
|
||||
background: 'var(--success-bg)', borderRadius: 'var(--r-sm)',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
<CheckCircleIcon style={{ width: 18, height: 18, color: 'var(--success)', flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14, direction: 'ltr', flex: 1 }}>{current}</span>
|
||||
<button
|
||||
className="mini-btn"
|
||||
onClick={() => remove.mutate()}
|
||||
disabled={remove.isPending}
|
||||
title="حذف"
|
||||
>
|
||||
<XMarkIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!current && step === 'idle' && (
|
||||
<p className="muted" style={{ fontSize: 13, marginBottom: '1rem' }}>
|
||||
شمارهای تنظیم نشده است.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* مرحله ۱: ورود شماره جدید */}
|
||||
{step === 'idle' && (
|
||||
<button
|
||||
className="btn ghost sm"
|
||||
onClick={() => { setStep('enter_mobile'); setError(null); }}
|
||||
>
|
||||
{current ? 'تغییر شماره' : 'تنظیم شماره اعلان'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 'enter_mobile' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 5 }}>
|
||||
شماره موبایل جدید
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={newMobile}
|
||||
onChange={e => setNewMobile(e.target.value)}
|
||||
placeholder="09XXXXXXXXX"
|
||||
dir="ltr"
|
||||
style={{
|
||||
width: '100%', maxWidth: 220, height: 38, padding: '0 12px',
|
||||
borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 12.5 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => requestOtp.mutate(newMobile)}
|
||||
disabled={requestOtp.isPending || !/^09\d{9}$/.test(newMobile)}
|
||||
>
|
||||
{requestOtp.isPending ? 'در حال ارسال...' : 'ارسال کد تأیید'}
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={() => { setStep('idle'); setError(null); }}>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* مرحله ۲: ورود کد OTP */}
|
||||
{step === 'enter_otp' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
||||
کد ۶ رقمی ارسالشده به <b style={{ direction: 'ltr', display: 'inline-block' }}>{newMobile}</b> را وارد کنید.
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={otpCode}
|
||||
onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="_ _ _ _ _ _"
|
||||
dir="ltr"
|
||||
maxLength={6}
|
||||
style={{
|
||||
width: 140, height: 44, padding: '0 12px', textAlign: 'center',
|
||||
letterSpacing: 8, fontSize: 22, fontWeight: 700,
|
||||
borderRadius: 'var(--r-sm)', border: '1.5px solid var(--border)',
|
||||
background: 'var(--surface)', color: 'var(--text)', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 12.5 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
onClick={() => verify.mutate(otpCode)}
|
||||
disabled={verify.isPending || otpCode.length !== 6}
|
||||
>
|
||||
{verify.isPending ? 'در حال تأیید...' : 'تأیید'}
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost sm"
|
||||
onClick={() => requestOtp.mutate(newMobile)}
|
||||
disabled={requestOtp.isPending}
|
||||
>
|
||||
ارسال مجدد
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={() => { setStep('idle'); setError(null); }}>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
MagnifyingGlassIcon, EyeIcon,
|
||||
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon,
|
||||
MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon,
|
||||
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, FunnelIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
@@ -12,29 +12,153 @@ import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||
{ value: 'waiting', label: 'صف انتظار' },
|
||||
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||
{ value: 'visited', label: 'ویزیت شده' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'cancelled_by_doctor', label: 'لغو پزشک' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو بیمار' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
];
|
||||
|
||||
const APPT_CLS: Record<string, string> = {
|
||||
waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet',
|
||||
waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green',
|
||||
cancelled_by_doctor: 'red', cancelled_by_user: 'red', auto_cancel_unpaid: 'gray', no_show: 'gray',
|
||||
};
|
||||
const APPT_LABEL: Record<string, string> = {
|
||||
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
||||
waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده',
|
||||
completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار',
|
||||
auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت',
|
||||
};
|
||||
|
||||
// ── Timeline View ─────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineProps {
|
||||
items: Appointment[];
|
||||
loading: boolean;
|
||||
onView: (uuid: string) => void;
|
||||
}
|
||||
|
||||
function TimelineView({ items, loading, onView }: TimelineProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '1rem' }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div className="skeleton" style={{ width: 48, height: 48, borderRadius: 10, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="skeleton" style={{ height: 14, borderRadius: 5, width: '55%', marginBottom: 6 }} />
|
||||
<div className="skeleton" style={{ height: 12, borderRadius: 5, width: '35%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
||||
}
|
||||
|
||||
// گروهبندی بر اساس تاریخ
|
||||
const grouped = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
const key = a.appointment_date;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(a);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 1rem 1rem' }}>
|
||||
{Object.entries(grouped).map(([date, appts]) => (
|
||||
<div key={date} style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
{new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{appts.map((a) => (
|
||||
<div
|
||||
key={a.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '0.75rem 1rem',
|
||||
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
|
||||
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
|
||||
}}
|
||||
onClick={() => onView(a.uuid)}
|
||||
onMouseEnter={e => (e.currentTarget.style.boxShadow = 'var(--shadow-sm, 0 2px 8px rgba(0,0,0,.08))')}
|
||||
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
{/* ساعت */}
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 10, flexShrink: 0,
|
||||
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: 15, lineHeight: 1.2,
|
||||
}}>
|
||||
{a.appointment_time}
|
||||
</div>
|
||||
|
||||
{/* اطلاعات */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{a.patient_name || maskMobile(a.patient_mobile)}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* وضعیت */}
|
||||
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
||||
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
|
||||
<EyeIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
|
||||
const limit = 15;
|
||||
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['appointments', page, search, statusFilter],
|
||||
queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
return api.get<PaginatedResponse<Appointment>>(`/api/v1/admin/appointments?${params}`);
|
||||
if (dateFilter) params.set('date', dateFilter);
|
||||
return api.get<PaginatedResponse<Appointment>>(`${endpoint}?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,11 +208,13 @@ export default function AppointmentsPage() {
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
const pageTitle = isAdmin ? 'نوبتها' : (primaryRole === 'doctor' ? 'نوبتهای من' : primaryRole === 'secretary' ? 'نوبتهای پزشک' : 'نوبتهای کلینیک');
|
||||
|
||||
const statCards = [
|
||||
{ label: 'کل نوبتها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
|
||||
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
||||
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
||||
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
||||
{ label: 'کل نوبتها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
|
||||
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
||||
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
||||
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -96,7 +222,7 @@ export default function AppointmentsPage() {
|
||||
{/* Header */}
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">نوبتها</h1>
|
||||
<h1 className="section-title">{pageTitle}</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
|
||||
</div>
|
||||
<button className="btn primary sm">
|
||||
@@ -125,45 +251,93 @@ export default function AppointmentsPage() {
|
||||
{/* Main card */}
|
||||
<div className="card">
|
||||
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="field" style={{ minWidth: 240 }}>
|
||||
<div className="toolbar" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||
{/* جستجو */}
|
||||
<div className="field" style={{ minWidth: 220 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||
<input
|
||||
placeholder="جستجو بر اساس موبایل یا نام..."
|
||||
placeholder="جستجو (موبایل / نام)..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="seg">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={statusFilter === f.value ? 'on' : ''}
|
||||
onClick={() => { setStatusFilter(f.value); setPage(1); }}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
|
||||
{/* فیلتر تاریخ */}
|
||||
<div className="field" style={{ minWidth: 160 }}>
|
||||
<FunnelIcon style={{ width: 15, height: 15 }} />
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => { setDateFilter(e.target.value); setPage(1); }}
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* فیلتر وضعیت */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
style={{
|
||||
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{STATUS_FILTERS.map(f => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={{ marginRight: 'auto' }} />
|
||||
|
||||
{/* تغییر نما */}
|
||||
<div className="seg">
|
||||
<button
|
||||
className={viewMode === 'table' ? 'on' : ''}
|
||||
onClick={() => setViewMode('table')}
|
||||
title="نمای جدول"
|
||||
>
|
||||
<TableCellsIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'timeline' ? 'on' : ''}
|
||||
onClick={() => setViewMode('timeline')}
|
||||
title="نمای زمانی"
|
||||
>
|
||||
<CalendarViewIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ نوبتی یافت نشد"
|
||||
actions={(appt) => (
|
||||
<button
|
||||
className="mini-btn"
|
||||
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ نوبتی یافت نشد"
|
||||
actions={(appt) => (
|
||||
<button
|
||||
className="mini-btn"
|
||||
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TimelineView
|
||||
items={items}
|
||||
loading={isLoading}
|
||||
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { ClinicDetail } from '../types';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// Fix leaflet icons
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@@ -568,6 +570,7 @@ export default function ClinicDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -1031,6 +1034,10 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primaryRole === 'clinic' && (
|
||||
<NotificationMobileCard target="clinic" />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,54 +4,14 @@ import { Link } from 'react-router-dom';
|
||||
import {
|
||||
UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon,
|
||||
CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
||||
ClockIcon, StarIcon, UserIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DashboardStats {
|
||||
total_users: number;
|
||||
active_doctors: number;
|
||||
total_doctors: number;
|
||||
total_clinics: number;
|
||||
today_appointments: number;
|
||||
total_appointments: number;
|
||||
today_payments_count: number;
|
||||
today_payments_amount: number;
|
||||
total_payments_amount: number;
|
||||
pending_comments: number;
|
||||
pending_settlements: number;
|
||||
this_month_revenue: number;
|
||||
this_month_appointments: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
appointments_30d: { date: string; count: number }[];
|
||||
revenue_30d: { date: string; amount: number }[];
|
||||
appointment_status: { status: string; count: number }[];
|
||||
top_specialties: { name: string; count: number }[];
|
||||
}
|
||||
|
||||
interface RecentAppointment {
|
||||
uuid: string; slot_start: string; status: string;
|
||||
doctor_name: string; user_mobile: string; user_name: string | null; created_at: string;
|
||||
}
|
||||
interface RecentPayment {
|
||||
uuid: string; amount: number; status: string; gateway: string;
|
||||
user_mobile: string; user_name: string | null; created_at: string;
|
||||
}
|
||||
interface RecentUser {
|
||||
uuid: string; mobile: string; name: string | null; email: string | null; created_at: string;
|
||||
}
|
||||
interface RecentData {
|
||||
appointments: RecentAppointment[];
|
||||
payments: RecentPayment[];
|
||||
users: RecentUser[];
|
||||
}
|
||||
|
||||
// ── Status maps ───────────────────────────────────────────────────────────
|
||||
// ── Shared Status Maps ────────────────────────────────────────────────────
|
||||
|
||||
const APPT_LABEL: Record<string, string> = {
|
||||
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
||||
@@ -80,7 +40,7 @@ const PAY_CLS: Record<string, string> = {
|
||||
pending: 'amber', received: 'green', canceled: 'red', refund: 'blue',
|
||||
};
|
||||
|
||||
// ── SVG Chart Components ──────────────────────────────────────────────────
|
||||
// ── Shared SVG Charts ─────────────────────────────────────────────────────
|
||||
|
||||
function SvgLineChart({ data, color, h = 220 }: { data: number[]; color: string; h?: number }) {
|
||||
if (data.length < 2) return null;
|
||||
@@ -165,7 +125,7 @@ function SvgHBars({ data }: { data: { label: string; value: number }[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Colored Avatar ────────────────────────────────────────────────────────
|
||||
// ── Shared UI Pieces ──────────────────────────────────────────────────────
|
||||
|
||||
function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) {
|
||||
const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' lg' : '');
|
||||
@@ -176,16 +136,9 @@ function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: numbe
|
||||
);
|
||||
}
|
||||
|
||||
// ── MiniList ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MiniRow {
|
||||
title: string;
|
||||
sub: string;
|
||||
meta: string;
|
||||
badgeLabel: string;
|
||||
badgeCls: string;
|
||||
initials: string;
|
||||
hue: number;
|
||||
title: string; sub: string; meta: string;
|
||||
badgeLabel: string; badgeCls: string; initials: string; hue: number;
|
||||
}
|
||||
|
||||
function MiniList({ title, to, rows, loading }: { title: string; to: string; rows: MiniRow[]; loading?: boolean }) {
|
||||
@@ -230,8 +183,6 @@ function MiniList({ title, to, rows, loading }: { title: string; to: string; row
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skeleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiSkeleton() {
|
||||
return (
|
||||
<div className="stat">
|
||||
@@ -243,62 +194,116 @@ function KpiSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="stat-grid">
|
||||
{Array.from({ length: 4 }).map((_, i) => <KpiSkeleton key={i} />)}
|
||||
</div>
|
||||
<div className="skeleton" style={{ height: 300, borderRadius: 'var(--r)', marginTop: 'var(--gap)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
// ── Appointments Table (shared) ────────────────────────────────────────────
|
||||
|
||||
interface ApptRow {
|
||||
uuid: string;
|
||||
patient_name: string | null;
|
||||
patient_mobile?: string;
|
||||
slot_start: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function TodayAppointmentsTable({ appts, loading }: { appts: ApptRow[]; loading: boolean }) {
|
||||
if (loading) return <div className="skeleton" style={{ height: 180, borderRadius: 'var(--r)' }} />;
|
||||
if (!appts.length) return <p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>نوبتی برای امروز ثبت نشده</p>;
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>بیمار</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>ساعت</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{appts.map((a, i) => (
|
||||
<tr key={a.uuid} style={{ borderBottom: i < appts.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 12px' }}>{a.patient_name || a.patient_mobile || '—'}</td>
|
||||
<td style={{ padding: '10px 12px', direction: 'ltr', textAlign: 'left' }}>
|
||||
{new Date(a.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
||||
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Admin Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
interface AdminStats {
|
||||
total_users: number; active_doctors: number; total_doctors: number; total_clinics: number;
|
||||
today_appointments: number; total_appointments: number; today_payments_count: number;
|
||||
today_payments_amount: number; total_payments_amount: number; pending_comments: number;
|
||||
pending_settlements: number; this_month_revenue: number; this_month_appointments: number;
|
||||
}
|
||||
interface AdminCharts {
|
||||
appointments_30d: { date: string; count: number }[];
|
||||
revenue_30d: { date: string; amount: number }[];
|
||||
appointment_status: { status: string; count: number }[];
|
||||
top_specialties: { name: string; count: number }[];
|
||||
}
|
||||
interface AdminRecent {
|
||||
appointments: { uuid: string; slot_start: string; status: string; doctor_name: string; user_mobile: string; user_name: string | null; created_at: string }[];
|
||||
payments: { uuid: string; amount: number; status: string; gateway: string; user_mobile: string; user_name: string | null; created_at: string }[];
|
||||
users: { uuid: string; mobile: string; name: string | null; email: string | null; created_at: string }[];
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const [chartMode, setChartMode] = useState<'appts' | 'rev'>('appts');
|
||||
|
||||
const statsQ = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: () => api.get<ApiResponse<DashboardStats>>('/api/v1/admin/dashboard/stats'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const chartsQ = useQuery({
|
||||
queryKey: ['dashboard-charts'],
|
||||
queryFn: () => api.get<ApiResponse<ChartData>>('/api/v1/admin/dashboard/charts'),
|
||||
staleTime: 120_000,
|
||||
});
|
||||
const recentQ = useQuery({
|
||||
queryKey: ['dashboard-recent'],
|
||||
queryFn: () => api.get<ApiResponse<RecentData>>('/api/v1/admin/dashboard/recent'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get<ApiResponse<AdminStats>>('/api/v1/admin/dashboard/stats'), staleTime: 60_000 });
|
||||
const chartsQ = useQuery({ queryKey: ['dashboard-charts'], queryFn: () => api.get<ApiResponse<AdminCharts>>('/api/v1/admin/dashboard/charts'), staleTime: 120_000 });
|
||||
const recentQ = useQuery({ queryKey: ['dashboard-recent'], queryFn: () => api.get<ApiResponse<AdminRecent>>('/api/v1/admin/dashboard/recent'), staleTime: 30_000 });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stats = useMemo<DashboardStats | undefined>(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]);
|
||||
const stats = useMemo<AdminStats | undefined>(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const charts = useMemo<ChartData | undefined>( () => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]);
|
||||
const charts = useMemo<AdminCharts | undefined>(() => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const recent = useMemo<RecentData | undefined>( () => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]);
|
||||
const recent = useMemo<AdminRecent | undefined>(() => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]);
|
||||
|
||||
const fn = (n?: number) => n !== undefined ? formatNumber(n) : '—';
|
||||
const fr = (n?: number) => n !== undefined ? formatRial(n) : '—';
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching;
|
||||
|
||||
// Chart data transformations
|
||||
const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]);
|
||||
const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [charts]);
|
||||
const donutData = useMemo(() =>
|
||||
(charts?.appointment_status ?? []).slice(0, 7).map(s => ({
|
||||
label: APPT_LABEL[s.status] ?? s.status,
|
||||
value: s.count,
|
||||
color: APPT_COLOR[s.status] ?? '#94a3b8',
|
||||
label: APPT_LABEL[s.status] ?? s.status, value: s.count, color: APPT_COLOR[s.status] ?? '#94a3b8',
|
||||
})), [charts]);
|
||||
const hbarsData = useMemo(() =>
|
||||
(charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]);
|
||||
const hbarsData = useMemo(() => (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]);
|
||||
|
||||
// KPI cards
|
||||
const kpiCards = [
|
||||
{ label: 'کل کاربران', value: fn(stats?.total_users), hint: 'رشد نسبت به ماه قبل', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
{ label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'کلینیکها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'نوبتهای امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||
{ label: 'کل کاربران', value: fn(stats?.total_users), hint: '', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||||
{ label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'کلینیکها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'نوبتهای امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||
{ label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
||||
];
|
||||
|
||||
// Quick actions
|
||||
const quickActions = [
|
||||
{ label: 'پزشکان', to: '/admin/doctors', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'کلینیکها', to: '/admin/clinics', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
@@ -308,7 +313,6 @@ export default function DashboardPage() {
|
||||
{ label: 'کاربران', to: '/admin/users', icon: UserGroupIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||
];
|
||||
|
||||
// Timeline events
|
||||
const timelineEvents = useMemo(() => {
|
||||
if (!recent) return [];
|
||||
const evs: { title: string; sub: string; time: string; color: string }[] = [];
|
||||
@@ -324,47 +328,32 @@ export default function DashboardPage() {
|
||||
return evs.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()).slice(0, 8);
|
||||
}, [recent]);
|
||||
|
||||
// MiniList rows
|
||||
const apptRows = useMemo<MiniRow[]>(() =>
|
||||
(recent?.appointments ?? []).slice(0, 5).map(a => ({
|
||||
title: a.user_name || a.user_mobile,
|
||||
sub: `دکتر ${a.doctor_name}`,
|
||||
meta: formatDateTime(a.slot_start),
|
||||
badgeLabel: APPT_LABEL[a.status] ?? a.status,
|
||||
badgeCls: APPT_CLS[a.status] ?? 'gray',
|
||||
initials: (a.user_name || a.user_mobile).slice(0, 2),
|
||||
hue: 222,
|
||||
title: a.user_name || a.user_mobile, sub: `دکتر ${a.doctor_name}`, meta: formatDateTime(a.slot_start),
|
||||
badgeLabel: APPT_LABEL[a.status] ?? a.status, badgeCls: APPT_CLS[a.status] ?? 'gray',
|
||||
initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222,
|
||||
})), [recent]);
|
||||
|
||||
const payRows = useMemo<MiniRow[]>(() =>
|
||||
(recent?.payments ?? []).slice(0, 5).map(p => ({
|
||||
title: p.user_name || p.user_mobile,
|
||||
sub: formatRial(p.amount),
|
||||
meta: formatDateTime(p.created_at),
|
||||
badgeLabel: PAY_LABEL[p.status] ?? p.status,
|
||||
badgeCls: PAY_CLS[p.status] ?? 'gray',
|
||||
initials: (p.user_name || p.user_mobile).slice(0, 2),
|
||||
hue: 162,
|
||||
title: p.user_name || p.user_mobile, sub: formatRial(p.amount), meta: formatDateTime(p.created_at),
|
||||
badgeLabel: PAY_LABEL[p.status] ?? p.status, badgeCls: PAY_CLS[p.status] ?? 'gray',
|
||||
initials: (p.user_name || p.user_mobile).slice(0, 2), hue: 162,
|
||||
})), [recent]);
|
||||
|
||||
const userRows = useMemo<MiniRow[]>(() =>
|
||||
(recent?.users ?? []).slice(0, 5).map(u => ({
|
||||
title: u.name || u.mobile,
|
||||
sub: u.name ? u.mobile : '',
|
||||
meta: formatDateTime(u.created_at),
|
||||
badgeLabel: 'فعال',
|
||||
badgeCls: 'green',
|
||||
initials: (u.name || u.mobile).slice(0, 2),
|
||||
hue: 256,
|
||||
title: u.name || u.mobile, sub: u.name ? u.mobile : '', meta: formatDateTime(u.created_at),
|
||||
badgeLabel: 'فعال', badgeCls: 'green',
|
||||
initials: (u.name || u.mobile).slice(0, 2), hue: 256,
|
||||
})), [recent]);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد</h1>
|
||||
<h1 className="section-title">داشبورد مدیریت</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
@@ -377,7 +366,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards (6-col grid) */}
|
||||
<div className="stat-grid">
|
||||
{statsQ.isLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => <KpiSkeleton key={i} />)
|
||||
@@ -396,7 +384,6 @@ export default function DashboardPage() {
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* dash-main: Donut (360px) + LineChart (1fr) */}
|
||||
<div className="dash-main">
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>وضعیت نوبتها</h3></div>
|
||||
@@ -428,7 +415,6 @@ export default function DashboardPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>{chartMode === 'appts' ? 'نوبتها' : 'درآمد'} — ۳۰ روز اخیر</h3>
|
||||
@@ -448,7 +434,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top specialties (HBars) */}
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>پرتکرارترین تخصصها</h3>
|
||||
@@ -463,7 +448,6 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* grid-2: Quick access + Timeline */}
|
||||
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>دسترسی سریع</h3></div>
|
||||
@@ -478,11 +462,8 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>آخرین رویدادها</h3>
|
||||
</div>
|
||||
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>آخرین رویدادها</h3></div>
|
||||
{recentQ.isLoading ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -514,13 +495,299 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* dash-3: Three MiniLists */}
|
||||
<div className="dash-3">
|
||||
<MiniList title="آخرین نوبتها" to="/admin/appointments" rows={apptRows} loading={recentQ.isLoading} />
|
||||
<MiniList title="آخرین پرداختها" to="/admin/payments" rows={payRows} loading={recentQ.isLoading} />
|
||||
<MiniList title="کاربران جدید" to="/admin/users" rows={userRows} loading={recentQ.isLoading} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Clinic Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
interface ClinicDashboardData {
|
||||
clinic: { uuid: string; name: string; is_active: boolean; logo: string | null };
|
||||
stats: { total_doctors: number; today_appointments: number; this_month_appointments: number; pending_invitations: number };
|
||||
today_appointments: ApptRow[];
|
||||
doctors: { uuid: string; name: string; today_count: number }[];
|
||||
}
|
||||
|
||||
function ClinicDashboard() {
|
||||
const { context } = useAuthStore();
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-clinic'],
|
||||
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>('/api/v1/dashboard/clinic'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<ClinicDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const kpiCards = [
|
||||
{ label: 'پزشکان', value: formatNumber(d?.stats.total_doctors ?? 0), icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'نوبتهای این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'دعوتنامه در انتظار', value: formatNumber(d?.stats.pending_invitations ?? 0), icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-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} · {d?.clinic.name ?? context?.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{kpiCards.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="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
|
||||
<Link to="/admin/doctors" className="link">همه</Link>
|
||||
</div>
|
||||
{!d?.doctors.length ? (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>پزشکی ثبت نشده</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{d.doctors.map((doc, i) => (
|
||||
<Link
|
||||
key={doc.uuid}
|
||||
to={`/admin/doctors/${doc.uuid}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none', textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<AvatarEl initials={doc.name.slice(0, 1)} hue={162} size="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 13.5 }}>دکتر {doc.name}</b>
|
||||
</div>
|
||||
<span className="badge blue"><span className="bdot" />{formatNumber(doc.today_count)} امروز</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Doctor Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
interface DoctorDashboardData {
|
||||
doctor: { uuid: string; name: string; degree: string | null };
|
||||
stats: { today_appointments: number; tomorrow_appointments: number; this_month_appointments: number; avg_rating: number | null; total_ratings: number };
|
||||
today_appointments: ApptRow[];
|
||||
clinics: { uuid: string; name: string; logo: string | null }[];
|
||||
}
|
||||
|
||||
function DoctorDashboard() {
|
||||
const { context } = useAuthStore();
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-doctor'],
|
||||
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>('/api/v1/dashboard/doctor'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<DoctorDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const kpiCards = [
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'نوبتهای فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
{ label: 'نوبتهای این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
|
||||
{ label: 'میانگین امتیاز', value: d?.stats.avg_rating != null ? String(d.stats.avg_rating) : '—', icon: StarIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||||
];
|
||||
|
||||
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} · دکتر {d?.doctor.name ?? context?.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{kpiCards.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>
|
||||
{c.label === 'میانگین امتیاز' && d?.stats.total_ratings ? (
|
||||
<div className="hint">{formatNumber(d.stats.total_ratings)} نظر</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>کلینیکهای من</h3>
|
||||
</div>
|
||||
{!d?.clinics.length ? (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>عضو کلینیکی نیستید</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{d.clinics.map((c, i) => (
|
||||
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.clinics.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<AvatarEl initials={c.name.slice(0, 1)} hue={205} size="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 13.5 }}>{c.name}</b>
|
||||
</div>
|
||||
<span className="badge green"><span className="bdot" />فعال</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Secretary Dashboard ───────────────────────────────────────────────────
|
||||
|
||||
interface SecretaryDashboardData {
|
||||
doctor: { uuid: string; name: string; degree: string | null };
|
||||
permissions: Record<string, unknown>;
|
||||
stats: { today_appointments: number; tomorrow_appointments: number };
|
||||
today_appointments: ApptRow[];
|
||||
}
|
||||
|
||||
function SecretaryDashboard() {
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-secretary'],
|
||||
queryFn: () => api.get<ApiResponse<SecretaryDashboardData>>('/api/v1/dashboard/secretary'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<SecretaryDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const kpiCards = [
|
||||
{ label: 'نوبتهای امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'نوبتهای فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false;
|
||||
|
||||
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} · منشی دکتر {d?.doctor.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>دکتر {d?.doctor.name ?? '—'}</div>
|
||||
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
||||
</div>
|
||||
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
|
||||
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />{canViewAppts ? 'دسترسی نوبتها: فعال' : 'دسترسی نوبتها: غیرفعال'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
{kpiCards.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>
|
||||
|
||||
{canViewAppts && (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
|
||||
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canViewAppts && (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
||||
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
||||
<p className="muted" style={{ fontSize: 13.5 }}>دسترسی مشاهده نوبتها برای این منشی فعال نیست.</p>
|
||||
</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 />;
|
||||
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||||
|
||||
// Fix leaflet default marker icons
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@@ -1780,6 +1781,7 @@ export default function DoctorDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -2204,6 +2206,10 @@ export default function DoctorDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primaryRole === 'doctor' && (
|
||||
<NotificationMobileCard target="doctor" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export default function MyClinicPage() {
|
||||
const { dbUuid, fetchMe } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (dbUuid) {
|
||||
navigate(`/admin/clinics/${dbUuid}`, { replace: true });
|
||||
} else {
|
||||
fetchMe().then(() => {
|
||||
const uuid = useAuthStore.getState().dbUuid;
|
||||
if (uuid) navigate(`/admin/clinics/${uuid}`, { replace: true });
|
||||
});
|
||||
}
|
||||
}, [dbUuid, fetchMe, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری اطلاعات کلینیک...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore, ContextItem } from '../stores/authStore';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'مدیر کل',
|
||||
clinic: 'مالک کلینیک',
|
||||
doctor: 'پزشک',
|
||||
secretary: 'منشی',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
doctor: '🏥',
|
||||
clinic: '🏢',
|
||||
};
|
||||
|
||||
export default function SelectContextPage() {
|
||||
const { availableContexts, switchContext } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSelect = async (ctx: ContextItem) => {
|
||||
setLoading(ctx.db_uuid);
|
||||
await switchContext(ctx.db_uuid);
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--surface-alt, #f4f6f9)',
|
||||
padding: '2rem',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: 480 }}>
|
||||
<div className="card card-pad" style={{ textAlign: 'center' }}>
|
||||
<h2 style={{ marginBottom: '.25rem', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||
انتخاب محیط کاری
|
||||
</h2>
|
||||
<p className="muted" style={{ marginBottom: '1.5rem', fontSize: '.875rem' }}>
|
||||
لطفاً محیط کاری مورد نظر خود را انتخاب کنید
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '.75rem' }}>
|
||||
{availableContexts.map((ctx) => (
|
||||
<button
|
||||
key={ctx.db_uuid}
|
||||
onClick={() => handleSelect(ctx)}
|
||||
disabled={loading !== null}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '.875rem 1rem',
|
||||
border: '1.5px solid var(--border, #e2e8f0)',
|
||||
borderRadius: '0.625rem',
|
||||
background: loading === ctx.db_uuid ? 'var(--surface-alt, #f4f6f9)' : '#fff',
|
||||
cursor: loading !== null ? 'wait' : 'pointer',
|
||||
textAlign: 'right',
|
||||
transition: 'border-color .15s, box-shadow .15s',
|
||||
opacity: loading !== null && loading !== ctx.db_uuid ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--primary, #6366f1)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border, #e2e8f0)')}
|
||||
>
|
||||
<span style={{ fontSize: '1.5rem', lineHeight: 1 }}>
|
||||
{TYPE_ICONS[ctx.type] ?? '👤'}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '.9375rem', marginBottom: '.125rem' }}>
|
||||
{ctx.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '.8125rem' }}>
|
||||
<span className="badge blue" style={{ fontSize: '.75rem' }}>
|
||||
{ROLE_LABELS[ctx.role] ?? ctx.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{loading === ctx.db_uuid && (
|
||||
<span className="skeleton" style={{ width: 20, height: 20, borderRadius: '50%' }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { Cog6ToothIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
site_name: z.string().min(1, 'نام سایت الزامی است'),
|
||||
support_phone: z.string(),
|
||||
commission_enabled: z.string(),
|
||||
commission_percent: z.string().refine(v => {
|
||||
const n = Number(v);
|
||||
return !isNaN(n) && n >= 0 && n <= 100;
|
||||
}, 'درصد باید بین ۰ تا ۱۰۰ باشد'),
|
||||
max_cancel_hours_before: z.string(),
|
||||
appointment_reminder_hours: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Settings {
|
||||
site_name: string;
|
||||
support_phone: string;
|
||||
commission_enabled: string;
|
||||
commission_percent: string;
|
||||
max_cancel_hours_before: string;
|
||||
appointment_reminder_hours: string;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => api.get<ApiResponse<Settings>>('/api/v1/admin/settings'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const settings: Settings | undefined = (data?.data as any)?.data ?? data?.data;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
reset({
|
||||
site_name: settings.site_name ?? 'ClinicPro',
|
||||
support_phone: settings.support_phone ?? '',
|
||||
commission_enabled: settings.commission_enabled ?? '0',
|
||||
commission_percent: settings.commission_percent ?? '0',
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before ?? '24',
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours ?? '2',
|
||||
});
|
||||
}
|
||||
}, [settings, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<ApiResponse<Settings>>('/api/v1/admin/settings', values),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
const commissionEnabled = watch('commission_enabled') === '1';
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
mutation.mutate(values);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="card card-pad">
|
||||
<div className="skeleton" style={{ height: 200, borderRadius: 'var(--r)' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: 3 }}>پیکربندی کلی پلتفرم</div>
|
||||
</div>
|
||||
{mutation.isSuccess && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--success)', fontSize: 13.5 }}>
|
||||
<CheckCircleIcon style={{ width: 18, height: 18 }} />
|
||||
تنظیمات ذخیره شد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||
|
||||
{/* اطلاعات پایه */}
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="ico" style={{ background: 'var(--primary-soft)', color: 'var(--primary)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<Cog6ToothIcon style={{ width: 18, height: 18 }} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>اطلاعات پایه</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>نام سایت</label>
|
||||
<input
|
||||
{...register('site_name')}
|
||||
style={{
|
||||
width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: `1px solid ${errors.site_name ? 'var(--danger)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{errors.site_name && <p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.site_name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>شماره پشتیبانی</label>
|
||||
<input
|
||||
{...register('support_phone')}
|
||||
dir="ltr"
|
||||
placeholder="021-12345678"
|
||||
style={{
|
||||
width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تنظیمات کمیسیون */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--success-bg)', color: 'var(--success)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>٪</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>کمیسیون سایت</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* toggle فعال/غیرفعال */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
|
||||
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commissionEnabled}
|
||||
onChange={(e) => {
|
||||
const target = e.target;
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="commission_enabled"]');
|
||||
if (input) {
|
||||
input.value = target.checked ? '1' : '0';
|
||||
// Trigger react-hook-form change
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}}
|
||||
style={{ opacity: 0, width: 0, height: 0, position: 'absolute' }}
|
||||
/>
|
||||
<input type="hidden" {...register('commission_enabled')} />
|
||||
<div style={{
|
||||
width: 44, height: 24, borderRadius: 12,
|
||||
background: commissionEnabled ? 'var(--primary)' : 'var(--border)',
|
||||
transition: 'background .2s', position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: '#fff',
|
||||
transition: 'right .2s', right: commissionEnabled ? 2 : 22,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,.2)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{commissionEnabled ? 'کمیسیون فعال است' : 'کمیسیون غیرفعال است'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{commissionEnabled && (
|
||||
<div style={{ maxWidth: 280 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||
درصد کمیسیون از کاربر (۰–۱۰۰)
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
{...register('commission_percent')}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
style={{
|
||||
width: 120, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: `1px solid ${errors.commission_percent ? 'var(--danger)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 13 }}>درصد</span>
|
||||
</div>
|
||||
{errors.commission_percent && (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 12, marginTop: 4 }}>{errors.commission_percent.message}</p>
|
||||
)}
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||
کمیسیون فقط از کاربر دریافت میشود. منشیها کمیسیون ندارند.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تنظیمات نوبتدهی */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
|
||||
<div className="ico" style={{ background: 'var(--warning-bg)', color: 'var(--warning)', width: 36, height: 36, borderRadius: 10 }}>
|
||||
<span style={{ fontSize: 16 }}>⏱</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15 }}>تنظیمات نوبتدهی</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||
حداکثر ساعت مجاز برای لغو نوبت
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
{...register('max_cancel_hours_before')}
|
||||
type="number"
|
||||
min={0}
|
||||
style={{
|
||||
width: 100, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 500, display: 'block', marginBottom: 6 }}>
|
||||
ارسال یادآور قبل از نوبت
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
{...register('appointment_reminder_hours')}
|
||||
type="number"
|
||||
min={0}
|
||||
style={{
|
||||
width: 100, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 13 }}>ساعت قبل از نوبت</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه ذخیره */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => settings && reset({
|
||||
site_name: settings.site_name,
|
||||
support_phone: settings.support_phone,
|
||||
commission_enabled: settings.commission_enabled,
|
||||
commission_percent: settings.commission_percent,
|
||||
max_cancel_hours_before: settings.max_cancel_hours_before,
|
||||
appointment_reminder_hours: settings.appointment_reminder_hours,
|
||||
})}
|
||||
disabled={!isDirty || mutation.isPending}
|
||||
>
|
||||
بازگشت
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
>
|
||||
{mutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,135 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface ContextItem {
|
||||
type: 'doctor' | 'clinic';
|
||||
db_uuid: string;
|
||||
name: string;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user';
|
||||
permissions?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
userUuid: string | null;
|
||||
userName: string | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
|
||||
dbUuid: string | null;
|
||||
dbKey: string | null;
|
||||
context: ContextItem | null;
|
||||
availableContexts: ContextItem[];
|
||||
|
||||
login: (token: string, refreshToken: string) => void;
|
||||
logout: () => void;
|
||||
fetchMe: () => Promise<void>;
|
||||
switchContext: (dbUuid: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('clinicpro-auth');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw)?.state?.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options: RequestInit = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
login: (token, refreshToken) =>
|
||||
set({ token, refreshToken, isAuthenticated: true }),
|
||||
userUuid: null,
|
||||
userName: null,
|
||||
primaryRole: null,
|
||||
dbUuid: null,
|
||||
dbKey: null,
|
||||
context: null,
|
||||
availableContexts: [],
|
||||
|
||||
login: (token, refreshToken) => {
|
||||
set({ token, refreshToken, isAuthenticated: true });
|
||||
get().fetchMe();
|
||||
},
|
||||
|
||||
logout: () =>
|
||||
set({ token: null, refreshToken: null, isAuthenticated: false }),
|
||||
set({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
userUuid: null,
|
||||
userName: null,
|
||||
primaryRole: null,
|
||||
dbUuid: null,
|
||||
dbKey: null,
|
||||
context: null,
|
||||
availableContexts: [],
|
||||
}),
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const res = await apiFetch('/oauth/userinfo');
|
||||
if (!res.success) return;
|
||||
const d = res.data;
|
||||
set({
|
||||
userUuid: d.uuid,
|
||||
userName: d.realName,
|
||||
primaryRole: d.primary_role ?? null,
|
||||
dbUuid: d.db_uuid ?? null,
|
||||
dbKey: d.db_key ?? null,
|
||||
context: d.context ?? null,
|
||||
availableContexts: d.available_contexts ?? [],
|
||||
});
|
||||
} catch {
|
||||
// شبکه در دسترس نیست — state دستنخورده بماند
|
||||
}
|
||||
},
|
||||
|
||||
switchContext: async (dbUuid: string) => {
|
||||
const res = await apiFetch('/api/v1/auth/switch-context', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ db_uuid: dbUuid }),
|
||||
});
|
||||
if (res.success) {
|
||||
set({
|
||||
dbUuid: res.data.db_uuid,
|
||||
dbKey: res.data.db_key,
|
||||
context: res.data.context,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'clinicpro-auth' }
|
||||
{
|
||||
name: 'clinicpro-auth',
|
||||
partialize: (s) => ({
|
||||
token: s.token,
|
||||
refreshToken: s.refreshToken,
|
||||
isAuthenticated: s.isAuthenticated,
|
||||
userUuid: s.userUuid,
|
||||
userName: s.userName,
|
||||
primaryRole: s.primaryRole,
|
||||
dbUuid: s.dbUuid,
|
||||
dbKey: s.dbKey,
|
||||
context: s.context,
|
||||
availableContexts: s.availableContexts,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user