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) از قبل انتخاب شده
|
// نمای پیشفرض زمانبندی است و پزشک (نقش doctor) از قبل انتخاب شده
|
||||||
expect(await screen.findByText('این روز تعطیل است')).toBeInTheDocument();
|
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 AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||||
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
||||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
||||||
// اجزای طرح نوبتهای tauri
|
// اجزای طرح نوبتهای tauri
|
||||||
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
||||||
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
||||||
@@ -264,8 +263,27 @@ export default function AppointmentsPage() {
|
|||||||
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
||||||
}, [appointments, clinicDoctorsList]);
|
}, [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)
|
// ── Slots query (timeline)
|
||||||
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
|
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
|
||||||
@@ -342,23 +360,20 @@ export default function AppointmentsPage() {
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16,
|
||||||
}}>
|
}}>
|
||||||
{/* افزودن نوبت → صفحهٔ کامل */}
|
{/* سمت راست: تاریخ + سرویس + سوییچ نما (مطابق طرح) */}
|
||||||
{!isRepresentation && (
|
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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
|
<button
|
||||||
aria-label="فیلترها"
|
aria-label="فیلترها"
|
||||||
className="btn sm"
|
className="btn sm"
|
||||||
@@ -372,25 +387,19 @@ export default function AppointmentsPage() {
|
|||||||
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<TurnsViewToggle viewMode={viewMode} onChange={setViewMode} />
|
{!isRepresentation && (
|
||||||
|
<button
|
||||||
{/* انتخاب پزشک (admin / clinic) */}
|
className="btn primary sm"
|
||||||
{!isDoctor && (
|
onClick={() => {
|
||||||
<div style={{ minWidth: 200 }}>
|
const q = selectedDoctorUuid ? `?doctor=${selectedDoctorUuid}&date=${selectedDate}` : `?date=${selectedDate}`;
|
||||||
<SearchableSelect
|
navigate(`/admin/appointments/new${q}`);
|
||||||
options={doctors.map(d => ({ value: d.uuid, label: d.name }))}
|
}}
|
||||||
value={selectedDoctorUuid || null}
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||||
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')}
|
>
|
||||||
placeholder="انتخاب پزشک..."
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||||
isClearable
|
افزودن نوبت
|
||||||
height={36}
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
{/* کارت اصلی — تب دکترها (هدر) + محتوا */}
|
||||||
@@ -399,7 +408,7 @@ export default function AppointmentsPage() {
|
|||||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{showDoctorTabs && (
|
{showDoctorTabs && (
|
||||||
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} />
|
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
|
||||||
)}
|
)}
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
{viewMode === 'table' ? (
|
{viewMode === 'table' ? (
|
||||||
@@ -458,16 +467,15 @@ export default function AppointmentsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** فیلتر پرسنل نوار ابزار — ردیفهای بارگذاریشدهٔ روز را بر اساس پرسنل فیلتر میکند. */
|
/** فیلتر سرویس نوار ابزار — ردیفهای بارگذاریشدهٔ روز را بر اساس سرویس فیلتر میکند. */
|
||||||
function StaffFilterSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function ServiceFilterSelect({ value, options, onChange }: {
|
||||||
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||||
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
|
}) {
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<select aria-label="پرسنل" value={value} onChange={e => onChange(e.target.value)}
|
<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 }}>
|
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>
|
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
|
||||||
{(staffQ.data?.data ?? []).map(s => <option key={s.uuid} value={s.uuid}>{s.full_name}</option>)}
|
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user