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