feat(appointments): profile-aware doctor tabs + toolbar to match tauri turns

- Multi-doctor clinic profile (clinic/admin) shows the doctor tabs for
  multi-doctor management; clinic auto-selects the first doctor so the timeline
  loads immediately (as in the reference). Admin keeps the «همه» tab.
- Independent doctor profile (doctor role, incl. clinic-scoped guest) shows no
  tabs and only its own schedule — no clinic/doctor selection.
- Toolbar reordered to match the reference: date + service select
  («سرویس مورد نظر را انتخاب کنید...») + view toggle on the right; filter +
  «افزودن نوبت» on the left. The in-toolbar doctor picker is removed (doctor
  selection is via tabs); the personnel select is replaced by a service filter
  bound to the existing itemUuid filter (client-side, no new endpoint).

Tests: added clinic-profile tests (tabs shown, no «همه», first doctor
auto-selected) and independent-doctor test (no tabs, service filter shown).

No API endpoints changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 16:07:04 +03:30
co-authored by Claude Opus 4.8
parent ef6017e688
commit f41852b168
2 changed files with 94 additions and 46 deletions
@@ -38,4 +38,44 @@ describe('AppointmentsPage — طرح نوبت‌ها', () => {
// نمای پیش‌فرض زمانبندی است و پزشک (نقش doctor) از قبل انتخاب شده
expect(await screen.findByText('این روز تعطیل است')).toBeInTheDocument();
});
it('independent doctor profile does NOT show doctor tabs', async () => {
renderWithProviders(<AppointmentsPage />);
await screen.findByText('این روز تعطیل است');
expect(screen.queryByText('همه')).toBeNull();
// برچسب فیلتر سرویس دیده می‌شود (مطابق طرح)
expect(screen.getByText('سرویس مورد نظر را انتخاب کنید...')).toBeInTheDocument();
});
});
describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () => {
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 5, completed: 1, waiting: 3, cancelled: 1 } });
if (url.includes('/clinic/doctor-list/')) return Promise.resolve({ success: true, data: { data: [
{ uuid: 'd1', name: 'دکتر محمدی' }, { uuid: 'd2', name: 'دکتر رضایی' },
] } });
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [] } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
return Promise.resolve({ success: true, data: [] });
});
});
it('shows the doctor tabs (multi-doctor management) without the «همه» tab', async () => {
renderWithProviders(<AppointmentsPage />);
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
expect(screen.queryByText('همه')).toBeNull();
});
it('auto-selects the first doctor so the timeline loads its slots', async () => {
renderWithProviders(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
// اسلات‌ها برای اولین دکتر (d1) درخواست می‌شوند
await screen.findByText('این روز تعطیل است');
const calledSlotsForD1 = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=d1'));
expect(calledSlotsForD1).toBe(true);
});
});
+54 -46
View File
@@ -15,7 +15,6 @@ import Pagination from '../components/ui/Pagination';
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
import PersianCalendar from '../components/ui/PersianCalendar';
import SearchableSelect from '../components/ui/SearchableSelect';
// اجزای طرح نوبت‌های tauri
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
@@ -264,8 +263,27 @@ export default function AppointmentsPage() {
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
}, [appointments, clinicDoctorsList]);
const showDoctorTabs = (isClinic || isAdmin) && doctors.length >= 2;
const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid);
// سرویس‌های موجود در نوبت‌های امروز (برای فیلتر «سرویس مورد نظر...»).
const serviceOptions = React.useMemo(() => {
const map = new Map<string, string>();
appointments.forEach(a => {
if (a.service_item?.uuid && a.service_item?.name) map.set(a.service_item.uuid, a.service_item.name);
});
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
}, [appointments]);
// نوع پروفایل: کلینیک چندپزشکه (تب دکترها + مدیریت چند پزشک) در برابر پزشک مستقل.
// نقش clinic/admin = چندپزشکه؛ نقش doctor (حتی مهمانِ کلینیک) = مستقل، فقط برنامهٔ خودش.
const isMultiDoctorClinic = isClinic || isAdmin;
const showDoctorTabs = isMultiDoctorClinic && doctors.length >= 1;
const showDoctorCol = isAdmin && !selectedDoctorUuid;
// در کلینیک، اولین دکتر به‌صورت پیش‌فرض انتخاب می‌شود تا زمانبندی مثل طرح پر باشد.
useEffect(() => {
if (isClinic && !selectedDoctorUuid && doctors.length > 0) {
setSelectedDoctorUuid(doctors[0].uuid);
}
}, [isClinic, selectedDoctorUuid, doctors]);
// ── Slots query (timeline)
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
@@ -342,23 +360,20 @@ export default function AppointmentsPage() {
<div style={{
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
}}>
{/* افزودن نوبت → صفحهٔ کامل */}
{!isRepresentation && (
<button
className="btn primary sm"
onClick={() => {
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
navigate(`/admin/appointments/new${q}`);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن نوبت
</button>
)}
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
<StaffFilterSelect value={filters.staffUuid} onChange={(v) => setFilters(f => ({ ...f, staffUuid: v }))} />
<ServiceFilterSelect
value={filters.itemUuid}
options={serviceOptions}
onChange={(v) => setFilters(f => ({ ...f, itemUuid: v }))}
/>
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
<div style={{ flex: 1 }} />
{/* سمت چپ: فیلتر + افزودن نوبت */}
<button
aria-label="فیلترها"
className="btn sm"
@@ -372,25 +387,19 @@ export default function AppointmentsPage() {
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</button>
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
{/* انتخاب پزشک (admin / clinic) */}
{!isDoctor && (
<div style={{ minWidth: 200 }}>
<SearchableSelect
options={doctors.map(d => ({ value: d.uuid, label: d.name }))}
value={selectedDoctorUuid || null}
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')}
placeholder="انتخاب پزشک..."
isClearable
height={36}
/>
</div>
{!isRepresentation && (
<button
className="btn primary sm"
onClick={() => {
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
navigate(`/admin/appointments/new${q}`);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
<PlusIcon style={{ width: 15, height: 15 }} />
افزودن نوبت
</button>
)}
<div style={{ flex: 1 }} />
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
</div>
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
@@ -399,7 +408,7 @@ export default function AppointmentsPage() {
borderRadius: 'var(--r)', overflow: 'hidden',
}}>
{showDoctorTabs && (
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} />
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}
<div style={{ padding: 16 }}>
{viewMode === 'table' ? (
@@ -458,16 +467,15 @@ export default function AppointmentsPage() {
);
}
/** فیلتر پرسنل نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس پرسنل فیلتر می‌کند. */
function StaffFilterSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
});
/** فیلتر سرویس نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس سرویس فیلتر می‌کند. */
function ServiceFilterSelect({ value, options, onChange }: {
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
}) {
return (
<select aria-label="پرسنل" value={value} onChange={e => onChange(e.target.value)}
style={{ height: 36, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12.5, fontFamily: 'inherit', padding: '0 10px', minWidth: 170 }}>
<option value="">پرسنل را انتخاب کنید...</option>
{(staffQ.data?.data ?? []).map(s => <option key={s.uuid} value={s.uuid}>{s.full_name}</option>)}
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)}
style={{ height: 44, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 12px', minWidth: 280 }}>
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
</select>
);
}