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