diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts index 1fb9b1a..d7568a8 100644 --- a/backend/src/modules/staff/staff-working-hours.service.ts +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -6,7 +6,9 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { + appointmentWithinWorkingHours, blocksForDay, + localDayOfWeekMondayZero, validateWorkingHoursBlocks, type WorkingHoursBlockInput, } from '../../common/working-hours'; @@ -68,6 +70,11 @@ export class StaffWorkingHoursService { } const normalizedBlocks = this.normalizeBlocks(dto.blocks); + await this.assertNoConflictingAppointments( + organizationId, + membership.userId, + normalizedBlocks, + ); await this.prisma.$transaction(async (tx) => { const schedule = await tx.staffWorkingHoursSchedule.upsert({ @@ -142,10 +149,66 @@ export class StaffWorkingHoursService { })); } + private async assertNoConflictingAppointments( + organizationId: string, + providerUserId: string, + blocks: WorkingHoursBlockInput[], + ) { + const now = new Date(); + const appointments = await this.prisma.appointment.findMany({ + where: { + organizationId, + providerUserId, + endAt: { gt: now }, + }, + include: { + patient: { select: { firstName: true, lastName: true } }, + }, + orderBy: { startAt: 'asc' }, + }); + + const conflicts = appointments.filter((appointment) => { + const startAt = new Date(appointment.startAt); + const endAt = new Date(appointment.endAt); + const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); + const dayBlocks = blocksForDay(blocks, dayOfWeek); + if (dayBlocks.length === 0) { + return true; + } + return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks); + }); + + if (conflicts.length === 0) { + return; + } + + const examples = conflicts.slice(0, 3).map((appointment) => { + const startAt = new Date(appointment.startAt); + const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`; + const when = startAt.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + return `${patientName} (${when})`; + }); + + const extra = + conflicts.length > examples.length + ? ` and ${conflicts.length - examples.length} more` + : ''; + + throw new BadRequestException( + `Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`, + ); + } + private async findMembership(membershipId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, - select: { id: true, isOwner: true }, + select: { id: true, isOwner: true, userId: true }, }); if (!membership) { throw new NotFoundException('Member not found'); diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index e8f0a32..b305f65 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -416,11 +416,6 @@ export default function StaffPage() { setEditLoading(true); toast.setError(''); try { - await staffApi.updateMember(editing.id, { - name: editName.trim(), - permissionNames: permissionNamesFromFeatureState(editPerms), - }); - if (editHasTreatmentEdit) { await staffApi.upsertWorkingHours( editing.id, @@ -431,6 +426,11 @@ export default function StaffPage() { ); } + await staffApi.updateMember(editing.id, { + name: editName.trim(), + permissionNames: permissionNamesFromFeatureState(editPerms), + }); + toast.showSuccess('Member updated.'); setEditing(null); setEditStep(1);