feat(representation): add KPI tiles and enhance doctor representation details in admin view
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, UserGroupIcon, CalendarDaysIcon, ClockIcon, ReceiptPercentIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
@@ -52,6 +52,23 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ icon: Icon, label, value, tone }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string; value: React.ReactNode; tone: 'violet' | 'green' | 'amber' | 'pink';
|
||||
}) {
|
||||
return (
|
||||
<div className="cp-stat">
|
||||
<div className="cp-stat-icon" style={{ background: `var(--stat-${tone}-bg)`, color: `var(--stat-${tone}-fg)` }}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-[var(--text-3)] mb-1">{label}</div>
|
||||
<div className="text-xl font-bold text-[var(--text)] leading-none">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RepresentationDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -85,6 +102,8 @@ export default function RepresentationDetailPage() {
|
||||
});
|
||||
const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? [];
|
||||
const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length;
|
||||
const upcomingTotal = repDoctors.reduce((s, d) => s + (d.upcoming_count ?? 0), 0);
|
||||
const pastTotal = repDoctors.reduce((s, d) => s + (d.past_count ?? 0), 0);
|
||||
|
||||
const appointmentsQuery = useQuery({
|
||||
queryKey: ['representation-appointments', uuid, apptScope],
|
||||
@@ -244,6 +263,14 @@ export default function RepresentationDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI tiles */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatTile icon={UserGroupIcon} tone="violet" label="تعداد پزشکان" value={formatNumber(repDoctorsTotal)} />
|
||||
<StatTile icon={CalendarDaysIcon} tone="green" label="نوبت آینده" value={formatNumber(upcomingTotal)} />
|
||||
<StatTile icon={ClockIcon} tone="amber" label="نوبت گذشته" value={formatNumber(pastTotal)} />
|
||||
<StatTile icon={ReceiptPercentIcon} tone="pink" label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="cp-card p-6">
|
||||
@@ -284,24 +311,33 @@ export default function RepresentationDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Doctors of this representation */}
|
||||
<div className="cp-card p-6 mt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="cp-card p-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="w-5 h-5 text-[var(--text-3)]" />
|
||||
<h2 className="text-sm font-semibold text-gray-700">پزشکان این نماینده</h2>
|
||||
<span className="badge blue">{formatNumber(repDoctorsTotal)} پزشک</span>
|
||||
<span className="badge violet">{formatNumber(repDoctorsTotal)}</span>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setAttachOpen(true)}>افزودن پزشک</button>
|
||||
<button className="btn primary sm inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
|
||||
<PlusIcon className="w-4 h-4" /> افزودن پزشک
|
||||
</button>
|
||||
</div>
|
||||
{doctorsQuery.isLoading ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
|
||||
<p className="text-sm text-gray-400 text-center py-8">در حال بارگذاری…</p>
|
||||
) : repDoctors.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">این نماینده پزشکی ندارد</p>
|
||||
<div className="text-center py-10">
|
||||
<UserGroupIcon className="w-10 h-10 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-sm text-gray-400">این نماینده هنوز پزشکی ندارد</p>
|
||||
<button className="btn primary sm mt-3 inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
|
||||
<PlusIcon className="w-4 h-4" /> افزودن اولین پزشک
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<div className="table-wrap">
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>نام پزشک</th>
|
||||
<th>کد نظام</th>
|
||||
<th>نوبت گذشته</th>
|
||||
<th>نوبت آینده</th>
|
||||
@@ -312,9 +348,9 @@ export default function RepresentationDetailPage() {
|
||||
{repDoctors.map((d) => (
|
||||
<tr key={d.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${d.uuid}`)}>
|
||||
<td><b>{d.name}</b></td>
|
||||
<td dir="ltr" style={{ fontFamily: 'monospace' }}>{d.medical_code ?? '—'}</td>
|
||||
<td dir="ltr" style={{ fontFamily: 'monospace' }} className="text-[var(--text-3)]">{d.medical_code ?? '—'}</td>
|
||||
<td>{formatNumber(d.past_count)}</td>
|
||||
<td>{formatNumber(d.upcoming_count)}</td>
|
||||
<td>{d.upcoming_count > 0 ? <span className="badge green">{formatNumber(d.upcoming_count)}</span> : <span className="text-[var(--text-3)]">۰</span>}</td>
|
||||
<td><ActiveBadge active={d.is_active} /></td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -325,46 +361,50 @@ export default function RepresentationDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Appointments of this representation */}
|
||||
<div className="cp-card p-6 mt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-700">نوبتهای این نماینده</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className={`btn sm ${apptScope === 'upcoming' ? 'primary' : 'ghost'}`} onClick={() => setApptScope('upcoming')}>آینده</button>
|
||||
<button className={`btn sm ${apptScope === 'past' ? 'primary' : 'ghost'}`} onClick={() => setApptScope('past')}>گذشته</button>
|
||||
<div className="cp-card p-6 mt-6">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDaysIcon className="w-5 h-5 text-[var(--text-3)]" />
|
||||
<h2 className="text-sm font-semibold text-gray-700">نوبتهای این نماینده</h2>
|
||||
{!appointmentsQuery.isLoading && <span className="badge blue">{formatNumber(repAppointmentsTotal)}</span>}
|
||||
</div>
|
||||
<div className="seg">
|
||||
<button className={apptScope === 'upcoming' ? 'on' : ''} onClick={() => setApptScope('upcoming')}>آینده</button>
|
||||
<button className={apptScope === 'past' ? 'on' : ''} onClick={() => setApptScope('past')}>گذشته</button>
|
||||
</div>
|
||||
</div>
|
||||
{appointmentsQuery.isLoading ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
|
||||
<p className="text-sm text-gray-400 text-center py-8">در حال بارگذاری…</p>
|
||||
) : repAppointments.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">
|
||||
{apptScope === 'upcoming' ? 'نوبت آیندهای ثبت نشده' : 'نوبت گذشتهای وجود ندارد'}
|
||||
</p>
|
||||
<div className="text-center py-10">
|
||||
<CalendarDaysIcon className="w-10 h-10 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{apptScope === 'upcoming' ? 'نوبت آیندهای ثبت نشده است' : 'نوبت گذشتهای وجود ندارد'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2"><span className="badge blue">{formatNumber(repAppointmentsTotal)} نوبت</span></div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>پزشک</th>
|
||||
<th>بیمار</th>
|
||||
<th>زمان</th>
|
||||
<th>وضعیت</th>
|
||||
<div className="table-wrap">
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>پزشک</th>
|
||||
<th>بیمار</th>
|
||||
<th>زمان</th>
|
||||
<th>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repAppointments.map((a) => (
|
||||
<tr key={a.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${a.doctor_uuid}`)}>
|
||||
<td><b>{a.doctor_name}</b></td>
|
||||
<td>{a.patient_name ?? '—'}</td>
|
||||
<td dir="ltr" className="text-right">{formatDateTime(a.slot_start)}</td>
|
||||
<td><span className="badge gray">{a.status}</span></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repAppointments.map((a) => (
|
||||
<tr key={a.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${a.doctor_uuid}`)}>
|
||||
<td><b>{a.doctor_name}</b></td>
|
||||
<td>{a.patient_name ?? '—'}</td>
|
||||
<td>{formatDateTime(a.slot_start)}</td>
|
||||
<td><span className="badge gray">{a.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Payment\Entity\Payment;
|
||||
use App\Rating\Entity\Comment;
|
||||
use App\Rating\Entity\Rate;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Settlement\Entity\Settlement;
|
||||
|
||||
@@ -122,6 +122,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION') // نماینده — لاگین با نامکاربری/رمز مجاز است
|
||||
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user