feat(appointments): per-resource tabs backed by a resource_uuid list filter
Resources now get their own tabs on the appointments page, alongside doctors. An appointment on "Laser CO2" belongs to the device, not to whichever doctor happens to stand behind it, so selecting a resource tab replaces the doctor filter instead of stacking on top of it. GET /api/v1/my/appointments gains an optional resource_uuid filter and returns a `resource` object per row. The join is a leftJoin on purpose: appointments created before the resource-first model have no resource and must not drop out of the list. The resource tab lives in the URL so Back and refresh restore the same view, per the list-state rule in CLAUDE.md. The doctor tab is still useState; moving it is a separate refactor and was left untouched. Verified against the running app: filtering by a resource returns only its appointments, a resource from another tenant returns an empty list (TenantFilter, 200 not 403), and legacy rows still list with resource: null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,10 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
||||
] } });
|
||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان' },
|
||||
{ uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر' },
|
||||
] });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
@@ -77,6 +81,29 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
||||
expect(screen.queryByText('همه')).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به
|
||||
* پزشکی که پشتش ایستاده.
|
||||
*/
|
||||
it('برای هر منبع فعال یک تب نشان میدهد', async () => {
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
expect(await screen.findByText('لیزر CO2')).toBeInTheDocument();
|
||||
expect(screen.getByText('اتاق ۱')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** تب فعال از URL خوانده میشود و فهرست با resource_uuid فیلتر میشود، نه doctor_uuid. */
|
||||
it('تب منبع از URL خوانده میشود و فهرست را با resource_uuid میگیرد', async () => {
|
||||
renderWithProviders(<AppointmentsPage />, { route: '/admin/appointments?resource=r-2' });
|
||||
await screen.findByText('لیزر CO2');
|
||||
|
||||
const calls = get.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.filter((u: any) => typeof u === 'string' && u.includes('/my/appointments'));
|
||||
|
||||
expect(calls.some((u: string) => u.includes('resource_uuid=r-2'))).toBe(true);
|
||||
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-selects the first doctor so the timeline loads its slots', async () => {
|
||||
renderWithProviders(<AppointmentsPage />);
|
||||
await screen.findByText('دکتر محمدی');
|
||||
|
||||
@@ -26,6 +26,10 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
|
||||
import ResourceTimeline from '../components/appointments/ResourceTimeline';
|
||||
import { useResourceTimeline } from '../hooks/useResourceTimeline';
|
||||
import DoctorTabs from '../components/appointments/DoctorTabs';
|
||||
/** هیچ تبی فعال نیست — وقتی تب منبع انتخاب شده، نوار پزشکان نباید هایلایت داشته باشد. */
|
||||
const NO_ACTIVE_TAB = '\u0000';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import TurnsTimeline from '../components/appointments/TurnsTimeline';
|
||||
import TurnsTable from '../components/appointments/TurnsTable';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
@@ -514,6 +518,24 @@ export default function AppointmentsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState(params.get('date') || today);
|
||||
const [viewMode, setViewMode] = useState<TurnsViewMode>('timeline');
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor ? (doctorUuid ?? '') : '');
|
||||
|
||||
/**
|
||||
* تب منبع در URL مینشیند تا «بازگشت» و رفرش همان تب را برگردانند — همان قاعدهای که
|
||||
* `CLAUDE.md` برای وضعیت لیستها میگذارد. (تب پزشک هنوز `useState` است؛ رفعش
|
||||
* refactor جداست و اینجا دست نمیخورد.)
|
||||
*/
|
||||
const [urlState, setUrlState] = useUrlState({ resource: '' });
|
||||
const selectedResourceUuid = urlState.resource;
|
||||
const { resources: bookableResources } = useResources({ active: '1' });
|
||||
|
||||
const selectResource = (uuid: string) => {
|
||||
setUrlState({ resource: uuid });
|
||||
if (uuid) setSelectedDoctorUuid('');
|
||||
};
|
||||
const selectDoctor = (uuid: string) => {
|
||||
setSelectedDoctorUuid(uuid);
|
||||
if (selectedResourceUuid) setUrlState({ resource: '' });
|
||||
};
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
|
||||
@@ -536,9 +558,12 @@ export default function AppointmentsPage() {
|
||||
: isRepresentation
|
||||
? '/api/v1/representation/appointments'
|
||||
: '/api/v1/my/appointments';
|
||||
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
|
||||
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid, selectedResourceUuid];
|
||||
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
||||
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
||||
// تب منبع جای تب پزشک را میگیرد، نه اینکه رویش سوار شود: «نوبتهای لیزر CO2» یعنی
|
||||
// همهٔ نوبتهای آن دستگاه، از هر پزشکی.
|
||||
if (selectedResourceUuid) apptParams.set('resource_uuid', selectedResourceUuid);
|
||||
else if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
||||
|
||||
const apptQuery = useQuery<PaginatedResponse<Appointment>>({
|
||||
queryKey: apptQueryKey,
|
||||
@@ -838,7 +863,26 @@ export default function AppointmentsPage() {
|
||||
borderRadius: 'var(--r)', overflow: 'hidden',
|
||||
}}>
|
||||
{showDoctorTabs && (
|
||||
<DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
|
||||
<DoctorTabs
|
||||
doctors={doctors}
|
||||
selected={selectedResourceUuid ? NO_ACTIVE_TAB : selectedDoctorUuid}
|
||||
onSelect={selectDoctor}
|
||||
showAll={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد،
|
||||
نه به پزشکی که پشتش ایستاده. */}
|
||||
{bookableResources.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>منابع</span>
|
||||
<DoctorTabs
|
||||
doctors={bookableResources.map((r) => ({ uuid: r.uuid, name: r.name }))}
|
||||
selected={selectedResourceUuid}
|
||||
onSelect={selectResource}
|
||||
showAll={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: 16 }}>
|
||||
{viewMode === 'table' ? (
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface Appointment {
|
||||
service_section?: { uuid: string; name: string } | null;
|
||||
service_item?: { uuid: string; name: string } | null;
|
||||
staff?: { uuid: string; full_name: string } | null;
|
||||
/** منبعی که نوبت رویش گرفته شده. null = نوبتهای پیش از مدل منبعمحور. */
|
||||
resource?: { uuid: string; name: string } | null;
|
||||
visit_price_rials?: number | null;
|
||||
service_items?: { uuid: string; name: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
|
||||
/** نوع خدمتِ بیمهای و بیمهٔ پایهٔ انتخابشده روی همین نوبت. */
|
||||
|
||||
Reference in New Issue
Block a user