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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user