feat: redesign StaffPage with KPI bar, improved table layout, and enhanced form fields

This commit is contained in:
hamed
2026-06-14 23:36:37 +03:30
parent 5be63a23b6
commit 55f646e2d4
6 changed files with 1118 additions and 258 deletions
+204 -60
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
MagnifyingGlassIcon, PlusIcon, ChevronRightIcon, PencilIcon, PhoneIcon,
FolderOpenIcon, UsersIcon,
} from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -30,6 +31,10 @@ const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
};
const PAYMENT_BADGE: Record<string, string> = {
cash: 'green', card: 'blue', insurance: 'purple', online: 'blue', pending: 'amber',
};
const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = [];
@@ -139,13 +144,20 @@ export default function MyPatientsPage() {
key: 'user',
header: 'بیمار',
render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="avatar sm" style={{ background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
className="avatar sm"
style={{
background: 'linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))',
flexShrink: 0,
fontWeight: 700,
}}
>
{(r.user.fullName ?? '?').charAt(0)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.user.fullName ?? '—'}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 2 }}>
<PhoneIcon style={{ width: 11 }} />
<span dir="ltr">{r.user.phone ?? '—'}</span>
</div>
@@ -153,13 +165,24 @@ export default function MyPatientsPage() {
</div>
),
},
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
{
key: 'created_at',
header: 'تاریخ ثبت',
render: (r) => (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(r.created_at)}</span>
),
},
{
key: 'uuid',
header: 'عملیات',
header: '',
render: (r) => (
<button className="btn primary sm" onClick={() => { setSelectedRecord(r); setSessionPage(1); }}>
پرونده
<button
className="btn primary sm"
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
onClick={() => { setSelectedRecord(r); setSessionPage(1); }}
>
<FolderOpenIcon style={{ width: 14 }} />
مشاهده پرونده
</button>
),
},
@@ -168,23 +191,47 @@ export default function MyPatientsPage() {
if (!selectedRecord) {
return (
<>
<PageHeader title="پرونده بیماران" description="لیست بیماران و سوابق مراجعات" />
<PageHeader title="پرونده بیماران" description="مراجعه‌کنندگان ثبت‌شده شما" />
<div style={{ marginBottom: 16 }}>
<div style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<MagnifyingGlassIcon style={{ width: 18, color: 'var(--text-3)', flexShrink: 0 }} />
<input
style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontSize: 14 }}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..."
/>
</div>
</div>
<div className="card">
<div style={{ padding: '12px 16px 0', display: 'flex', gap: 8 }}>
<div style={{ position: 'relative', flex: 1 }}>
<MagnifyingGlassIcon style={{ width: 16, position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
<input
style={{ paddingRight: 32, width: '100%' }}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..."
/>
{records.length === 0 && !isLoading ? (
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
<UsersIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
{search ? 'بیماری یافت نشد' : 'هنوز بیماری ثبت نشده است'}
</div>
<div style={{ fontSize: 13 }}>
{search ? 'عبارت جستجو را تغییر دهید' : 'پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود'}
</div>
</div>
</div>
<DataTable columns={recordColumns} data={records} loading={isLoading} emptyMessage="بیماری ثبت نشده است" />
<div style={{ padding: '0 16px 12px' }}>
<Pagination page={page} total={totalRec} limit={limit} onPageChange={setPage} />
</div>
) : (
<>
<DataTable columns={recordColumns} data={records} loading={isLoading} emptyMessage="بیماری ثبت نشده است" />
<div style={{ padding: '0 16px 12px' }}>
<Pagination page={page} total={totalRec} limit={limit} onPageChange={setPage} />
</div>
</>
)}
</div>
</>
);
@@ -200,19 +247,66 @@ export default function MyPatientsPage() {
<button className="btn sm" onClick={() => setSelectedRecord(null)}>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<button className="btn primary sm" onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}>
<button
className="btn primary sm"
onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
</div>
}
/>
{/* بنر اطلاعات بیمار */}
<div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
borderRadius: 'var(--r)',
padding: '16px 20px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
color: '#fff',
}}>
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, fontWeight: 700, flexShrink: 0,
}}>
{(selectedRecord.user.fullName ?? '?').charAt(0)}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 17 }}>{selectedRecord.user.fullName ?? '—'}</div>
<div style={{ fontSize: 13, opacity: 0.85, marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
<PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{selectedRecord.user.phone ?? '—'}</span>
</div>
</div>
<div style={{ textAlign: 'center', background: 'rgba(255,255,255,0.15)', borderRadius: 10, padding: '8px 16px' }}>
<div style={{ fontSize: 22, fontWeight: 800 }}>{totalSes}</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>مراجعه</div>
</div>
</div>
<div className="card">
<div style={{ fontWeight: 600, padding: '12px 16px 8px' }}>مراجعات</div>
<div style={{ fontWeight: 600, padding: '14px 16px 10px', borderBottom: '1px solid var(--border)', fontSize: 14 }}>
تاریخچه مراجعات
</div>
{sessionsLoading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : sessions.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>مراجعهای ثبت نشده است</div>
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<FolderOpenIcon style={{ width: 40, margin: '0 auto 12px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-2)' }}>مراجعهای ثبت نشده است</div>
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => { form.reset(); setSelectedServices([]); setSessionModal(true); }}
>
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
</button>
</div>
) : (
sessions.map((s) => <SessionRow key={s.uuid} session={s} onEdit={setEditSession} />)
)}
@@ -226,7 +320,7 @@ export default function MyPatientsPage() {
{/* Modal مراجعه جدید */}
<Modal open={sessionModal} onClose={() => setSessionModal(false)} title="ثبت مراجعه جدید">
<form onSubmit={handleSubmitSession}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div className="field">
<label>قیمت ویزیت (ریال)</label>
@@ -255,8 +349,8 @@ export default function MyPatientsPage() {
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>افزودن سرویس</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>افزودن خدمات</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<div style={{ flex: 1 }}>
<SearchableSelect
options={sectionOptions}
@@ -274,27 +368,38 @@ export default function MyPatientsPage() {
isDisabled={!sectionUuid}
/>
</div>
<button type="button" className="btn sm" onClick={handleAddService} disabled={!itemUuid}>
<button type="button" className="btn primary sm" onClick={handleAddService} disabled={!itemUuid}>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.map((svc) => (
<div key={svc.service_item_uuid} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '4px 8px', background: 'var(--surface-2)', borderRadius: 4, marginBottom: 4 }}>
<span>{svc.name}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span>{formatRial(svc.price_rials)}</span>
<button type="button" style={{ fontSize: 11, color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer' }}
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}>
حذف
</button>
</div>
{selectedServices.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{selectedServices.map((svc) => (
<div
key={svc.service_item_uuid}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
background: 'var(--primary-subtle)', border: '1px solid oklch(0.85 0.06 256)',
borderRadius: 20, padding: '4px 10px', fontSize: 12.5,
}}
>
<span style={{ fontWeight: 500 }}>{svc.name}</span>
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>{formatRial(svc.price_rials)}</span>
<button
type="button"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, fontSize: 14, lineHeight: 1 }}
onClick={() => setSelectedServices((p) => p.filter((s) => s.service_item_uuid !== svc.service_item_uuid))}
>×</button>
</div>
))}
</div>
))}
)}
</div>
<div style={{ background: 'var(--surface-2)', borderRadius: 8, padding: 12, fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: 'var(--text-3)' }}>جمع سرویسها</span>
<div style={{ background: 'var(--primary-subtle)', border: '1px solid oklch(0.88 0.05 256)', borderRadius: 10, padding: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6, fontSize: 13, color: 'var(--text-3)' }}>
<span>جمع خدمات</span>
<span>{formatRial(servicesTotal)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, fontSize: 15 }}>
@@ -325,34 +430,69 @@ export default function MyPatientsPage() {
function SessionRow({ session, onEdit }: { session: PatientSession; onEdit: (s: PatientSession) => void }) {
const [open, setOpen] = useState(false);
const badgeColor = PAYMENT_BADGE[session.payment_method] ?? 'gray';
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div style={{
borderBottom: '1px solid var(--border)',
transition: 'background 0.1s',
}}>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', cursor: 'pointer' }}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 16px', cursor: 'pointer',
}}
onClick={() => setOpen(!open)}
>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: open ? 'var(--primary)' : 'var(--border)',
transition: 'background 0.15s', flexShrink: 0,
}} />
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDateTime(session.created_at)}</span>
<span className={`badge ${session.payment_method === 'pending' ? 'amber' : 'green'}`}>
<span className={`badge ${badgeColor}`}>
{PAYMENT_LABELS[session.payment_method] ?? session.payment_method}
</span>
<b style={{ fontSize: 14 }}>{formatRial(session.final_price_rials)}</b>
<b style={{ fontSize: 14, color: 'var(--primary)' }}>{formatRial(session.final_price_rials)}</b>
</div>
<button className="btn sm" onClick={(e) => { e.stopPropagation(); onEdit(session); }}>
<button
className="btn sm"
onClick={(e) => { e.stopPropagation(); onEdit(session); }}
title="ویرایش مراجعه"
>
<PencilIcon style={{ width: 14 }} />
</button>
</div>
{open && (
<div style={{ padding: '0 16px 12px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13 }}>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>قیمت ویزیت</div><div>{formatRial(session.visit_price_rials)}</div></div>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>تخفیف بیمه پایه</div><div>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div></div>
<div><div style={{ color: 'var(--text-3)', marginBottom: 2 }}>جمع سرویسها</div><div>{formatRial(session.services_total_rials)}</div></div>
{session.notes && (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ color: 'var(--text-3)', marginBottom: 2 }}>یادداشت</div>
<div>{session.notes}</div>
<div style={{ padding: '0 16px 14px' }}>
<div style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 10,
padding: 14,
display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, fontSize: 13,
}}>
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>قیمت ویزیت</div>
<div style={{ fontWeight: 600 }}>{formatRial(session.visit_price_rials)}</div>
</div>
)}
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>تخفیف بیمه پایه</div>
<div style={{ fontWeight: 600 }}>{formatNumber(parseFloat(session.base_insurance_discount_percent))}٪</div>
</div>
<div>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>جمع خدمات</div>
<div style={{ fontWeight: 600 }}>{formatRial(session.services_total_rials)}</div>
</div>
{session.notes && (
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 2 }}>
<div style={{ color: 'var(--text-3)', marginBottom: 4, fontSize: 12 }}>یادداشت</div>
<div style={{ lineHeight: 1.6 }}>{session.notes}</div>
</div>
)}
</div>
</div>
)}
</div>
@@ -390,7 +530,11 @@ function EditSessionModal({
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={loading} onClick={() => session && onSave(session.uuid, { notes, payment_method: method })}>
<button
className="btn primary"
disabled={loading}
onClick={() => session && onSave(session.uuid, { notes, payment_method: method })}
>
{loading ? 'در حال ذخیره...' : 'ذخیره'}
</button>
<button className="btn" onClick={onClose}>انصراف</button>