diff --git a/backend/prisma/migrations/20260608120000_add_staff_working_hours/migration.sql b/backend/prisma/migrations/20260608120000_add_staff_working_hours/migration.sql new file mode 100644 index 0000000..d08ba3c --- /dev/null +++ b/backend/prisma/migrations/20260608120000_add_staff_working_hours/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "staff_working_hours_schedules" ( + "id" TEXT NOT NULL, + "membershipId" TEXT NOT NULL, + "autoRepeatWeekly" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "staff_working_hours_schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff_working_hours_blocks" ( + "id" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "dayOfWeek" INTEGER NOT NULL, + "startMinute" INTEGER NOT NULL, + "endMinute" INTEGER NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "staff_working_hours_blocks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_working_hours_schedules_membershipId_key" ON "staff_working_hours_schedules"("membershipId"); + +-- CreateIndex +CREATE INDEX "staff_working_hours_blocks_scheduleId_dayOfWeek_sortOrder_idx" ON "staff_working_hours_blocks"("scheduleId", "dayOfWeek", "sortOrder"); + +-- AddForeignKey +ALTER TABLE "staff_working_hours_schedules" ADD CONSTRAINT "staff_working_hours_schedules_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "staff_working_hours_blocks" ADD CONSTRAINT "staff_working_hours_blocks_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "staff_working_hours_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8d3836b..fcc9408 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -217,6 +217,7 @@ model Membership { permissions MembershipPermission[] invitations StaffInvitation[] + workingHoursSchedule StaffWorkingHoursSchedule? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -225,6 +226,34 @@ model Membership { @@map("memberships") } +model StaffWorkingHoursSchedule { + id String @id @default(uuid()) + membershipId String @unique + autoRepeatWeekly Boolean @default(true) + + membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade) + blocks StaffWorkingHoursBlock[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("staff_working_hours_schedules") +} + +model StaffWorkingHoursBlock { + id String @id @default(uuid()) + scheduleId String + dayOfWeek Int + startMinute Int + endMinute Int + sortOrder Int @default(0) + + schedule StaffWorkingHoursSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@index([scheduleId, dayOfWeek, sortOrder]) + @@map("staff_working_hours_blocks") +} + model StaffInvitation { id String @id @default(uuid()) diff --git a/backend/src/common/working-hours.ts b/backend/src/common/working-hours.ts new file mode 100644 index 0000000..0cbdf60 --- /dev/null +++ b/backend/src/common/working-hours.ts @@ -0,0 +1,118 @@ +export const MINUTES_PER_DAY = 24 * 60; + +export type WorkingHoursBlockInput = { + dayOfWeek: number; + startMinute: number; + endMinute: number; + sortOrder?: number; +}; + +export type WorkingHoursDayBlock = { + startMinute: number; + endMinute: number; +}; + +export function localDayOfWeekMondayZero(dayOfWeekJs: number): number { + return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1; +} + +export function validateWorkingHoursBlocks(blocks: WorkingHoursBlockInput[]): string | null { + if (!Array.isArray(blocks)) { + return 'Working hours blocks must be an array'; + } + + const byDay = new Map(); + for (const block of blocks) { + if (!Number.isInteger(block.dayOfWeek) || block.dayOfWeek < 0 || block.dayOfWeek > 6) { + return 'dayOfWeek must be an integer from 0 (Monday) to 6 (Sunday)'; + } + if (!Number.isInteger(block.startMinute) || block.startMinute < 0 || block.startMinute >= MINUTES_PER_DAY) { + return 'startMinute must be between 0 and 1439'; + } + if (!Number.isInteger(block.endMinute) || block.endMinute <= 0 || block.endMinute > MINUTES_PER_DAY) { + return 'endMinute must be between 1 and 1440'; + } + if (block.endMinute <= block.startMinute) { + return 'Each shift end time must be after its start time'; + } + const list = byDay.get(block.dayOfWeek) ?? []; + list.push(block); + byDay.set(block.dayOfWeek, list); + } + + for (const [, dayBlocks] of byDay) { + const sorted = [...dayBlocks].sort((a, b) => a.startMinute - b.startMinute); + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].startMinute < sorted[i - 1].endMinute) { + return 'Shifts on the same day cannot overlap'; + } + } + } + + return null; +} + +export function blocksForDay( + blocks: WorkingHoursBlockInput[], + dayOfWeek: number, +): WorkingHoursDayBlock[] { + return blocks + .filter((b) => b.dayOfWeek === dayOfWeek) + .sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute) + .map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute })); +} + +export function isMinuteWithinWorkingBlocks(minute: number, dayBlocks: WorkingHoursDayBlock[]): boolean { + return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute); +} + +export function isRangeWithinWorkingBlocks( + startMinute: number, + endMinute: number, + dayBlocks: WorkingHoursDayBlock[], +): boolean { + if (endMinute <= startMinute) { + return false; + } + for (let m = startMinute; m < endMinute; m += 1) { + if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) { + return false; + } + } + return true; +} + +export function dateToLocalMinutes(date: Date): number { + return date.getHours() * 60 + date.getMinutes(); +} + +export function appointmentWithinWorkingHours( + startAt: Date, + endAt: Date, + dayBlocks: WorkingHoursDayBlock[], +): boolean { + const startMinute = dateToLocalMinutes(startAt); + const endMinute = dateToLocalMinutes(endAt); + return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks); +} + +export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): { + startMinute: number; + endMinute: number; +} | null { + let startMinute: number | null = null; + let endMinute: number | null = null; + + for (const dayBlocks of dayBlocksList) { + for (const block of dayBlocks) { + startMinute = startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute); + endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute); + } + } + + if (startMinute == null || endMinute == null) { + return null; + } + + return { startMinute, endMinute }; +} diff --git a/backend/src/modules/appointments/appointments.controller.ts b/backend/src/modules/appointments/appointments.controller.ts index 8c32934..f6dfa7f 100644 --- a/backend/src/modules/appointments/appointments.controller.ts +++ b/backend/src/modules/appointments/appointments.controller.ts @@ -13,6 +13,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AppointmentsService } from './appointments.service'; +import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @@ -29,9 +30,16 @@ export class AppointmentsController { summary: 'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.', }) - columnProviders(@Req() req: { user: { id: string; organizationId?: string } }) { + columnProviders( + @Query() query: ColumnProvidersQueryDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user); - return this.appointmentsService.listColumnProviders(organizationId, req.user.id); + return this.appointmentsService.listColumnProviders( + organizationId, + req.user.id, + query.date, + ); } @Get() diff --git a/backend/src/modules/appointments/appointments.module.ts b/backend/src/modules/appointments/appointments.module.ts index 4ec86d1..e6f0b5f 100644 --- a/backend/src/modules/appointments/appointments.module.ts +++ b/backend/src/modules/appointments/appointments.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { StaffModule } from '../staff/staff.module'; import { AppointmentsController } from './appointments.controller'; import { AppointmentsService } from './appointments.service'; @Module({ + imports: [StaffModule], controllers: [AppointmentsController], providers: [AppointmentsService, PrismaService], }) diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index d5baa9f..2293082 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -5,6 +5,12 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; +import { + appointmentWithinWorkingHours, + blocksForDay, + localDayOfWeekMondayZero, +} from '../../common/working-hours'; +import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; @@ -13,7 +19,10 @@ const MS_PER_DAY = 86_400_000; @Injectable() export class AppointmentsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly staffWorkingHoursService: StaffWorkingHoursService, + ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { @@ -22,7 +31,7 @@ export class AppointmentsService { return user.organizationId; } - async listColumnProviders(organizationId: string, actorUserId: string) { + async listColumnProviders(organizationId: string, actorUserId: string, date?: string) { await this.assertCanViewAppointments(actorUserId, organizationId); const members = await this.prisma.membership.findMany({ @@ -44,7 +53,23 @@ export class AppointmentsService { orderBy: [{ createdAt: 'asc' }], }); - const data = members.map((m) => ({ userId: m.user.id, name: m.user.name })); + const scheduleBlocksByMembership = + await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds( + members.map((m) => m.id), + ); + + const dayOfWeek = this.resolveDayOfWeekMondayZero(date); + + const data = members.map((m) => { + const allBlocks = scheduleBlocksByMembership.get(m.id) ?? []; + const dayBlocks = blocksForDay(allBlocks, dayOfWeek); + return { + userId: m.user.id, + name: m.user.name, + hasWorkingHours: allBlocks.length > 0, + dayBlocks, + }; + }); return { success: true, data }; } @@ -109,6 +134,12 @@ export class AppointmentsService { await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); + await this.ensureAppointmentWithinProviderWorkingHours( + dto.providerUserId, + organizationId, + startAt, + endAt, + ); const appointment = await this.prisma.appointment.create({ data: { @@ -166,6 +197,12 @@ export class AppointmentsService { await this.ensurePatientInOrg(patientId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); + await this.ensureAppointmentWithinProviderWorkingHours( + providerUserId, + organizationId, + startAt, + endAt, + ); const appointment = await this.prisma.appointment.update({ where: { id }, @@ -273,6 +310,47 @@ export class AppointmentsService { } } + private resolveDayOfWeekMondayZero(date?: string): number { + if (!date) { + return localDayOfWeekMondayZero(new Date().getDay()); + } + const [y, m, d] = date.split('-').map(Number); + const parsed = new Date(y, m - 1, d, 12, 0, 0, 0); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException('Invalid date query parameter'); + } + return localDayOfWeekMondayZero(parsed.getDay()); + } + + private async ensureAppointmentWithinProviderWorkingHours( + providerUserId: string, + organizationId: string, + startAt: Date, + endAt: Date, + ) { + const membership = await this.getMembership(providerUserId, organizationId); + if (!membership) { + throw new BadRequestException('Provider is not a member of this organization'); + } + + const scheduleBlocksByMembership = + await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]); + const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? []; + if (allBlocks.length === 0) { + throw new BadRequestException('Provider has no working hours configured'); + } + + const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); + const dayBlocks = blocksForDay(allBlocks, dayOfWeek); + if (dayBlocks.length === 0) { + throw new BadRequestException('Provider is not working on this day'); + } + + if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) { + throw new BadRequestException('Appointment must fall within the provider working hours'); + } + } + private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, diff --git a/backend/src/modules/appointments/dto/column-providers-query.dto.ts b/backend/src/modules/appointments/dto/column-providers-query.dto.ts new file mode 100644 index 0000000..04ccc58 --- /dev/null +++ b/backend/src/modules/appointments/dto/column-providers-query.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString, Matches } from 'class-validator'; + +export class ColumnProvidersQueryDto { + /** Local calendar date (YYYY-MM-DD) used to resolve weekday working hours. */ + @IsOptional() + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + date?: string; +} diff --git a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts new file mode 100644 index 0000000..68c508a --- /dev/null +++ b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts @@ -0,0 +1,44 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +export class WorkingHoursBlockDto { + @IsInt() + @Min(0) + @Max(6) + dayOfWeek: number; + + @IsInt() + @Min(0) + @Max(1439) + startMinute: number; + + @IsInt() + @Min(1) + @Max(1440) + endMinute: number; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class UpsertWorkingHoursDto { + @IsBoolean() + autoRepeatWeekly: boolean; + + @IsArray() + @ArrayMaxSize(42) + @ValidateNested({ each: true }) + @Type(() => WorkingHoursBlockDto) + blocks: WorkingHoursBlockDto[]; +} diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts new file mode 100644 index 0000000..d7568a8 --- /dev/null +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -0,0 +1,264 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { + appointmentWithinWorkingHours, + blocksForDay, + localDayOfWeekMondayZero, + validateWorkingHoursBlocks, + type WorkingHoursBlockInput, +} from '../../common/working-hours'; +import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; + +@Injectable() +export class StaffWorkingHoursService { + constructor(private readonly prisma: PrismaService) {} + + async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) { + await this.assertCanViewStaff(actorUserId, organizationId); + + const membership = await this.findMembership(membershipId, organizationId); + const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ + where: { membershipId: membership.id }, + include: { + blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, + }, + }); + + if (!schedule) { + return { + success: true, + data: { + autoRepeatWeekly: true, + blocks: [], + hasWorkingHours: false, + }, + }; + } + + return { + success: true, + data: { + autoRepeatWeekly: schedule.autoRepeatWeekly, + blocks: schedule.blocks.map((b) => ({ + dayOfWeek: b.dayOfWeek, + startMinute: b.startMinute, + endMinute: b.endMinute, + sortOrder: b.sortOrder, + })), + hasWorkingHours: schedule.blocks.length > 0, + }, + }; + } + + async upsertWorkingHours( + actorUserId: string, + organizationId: string, + membershipId: string, + dto: UpsertWorkingHoursDto, + ) { + await this.assertCanEditStaff(actorUserId, organizationId); + + const membership = await this.findMembership(membershipId, organizationId); + const validationError = validateWorkingHoursBlocks(dto.blocks); + if (validationError) { + throw new BadRequestException(validationError); + } + + 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({ + where: { membershipId: membership.id }, + create: { + membershipId: membership.id, + autoRepeatWeekly: dto.autoRepeatWeekly, + }, + update: { + autoRepeatWeekly: dto.autoRepeatWeekly, + }, + }); + + await tx.staffWorkingHoursBlock.deleteMany({ where: { scheduleId: schedule.id } }); + + if (normalizedBlocks.length > 0) { + await tx.staffWorkingHoursBlock.createMany({ + data: normalizedBlocks.map((block, index) => ({ + scheduleId: schedule.id, + dayOfWeek: block.dayOfWeek, + startMinute: block.startMinute, + endMinute: block.endMinute, + sortOrder: block.sortOrder ?? index, + })), + }); + } + }); + + return { + success: true, + message: 'Working hours saved', + }; + } + + async loadScheduleBlocksByMembershipIds(membershipIds: string[]) { + if (membershipIds.length === 0) { + return new Map(); + } + + const schedules = await this.prisma.staffWorkingHoursSchedule.findMany({ + where: { membershipId: { in: membershipIds } }, + include: { + blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, + }, + }); + + const map = new Map(); + for (const schedule of schedules) { + map.set( + schedule.membershipId, + schedule.blocks.map((b) => ({ + dayOfWeek: b.dayOfWeek, + startMinute: b.startMinute, + endMinute: b.endMinute, + sortOrder: b.sortOrder, + })), + ); + } + return map; + } + + dayBlocksFromMembershipBlocks(blocks: WorkingHoursBlockInput[], dayOfWeekMondayZero: number) { + return blocksForDay(blocks, dayOfWeekMondayZero); + } + + private normalizeBlocks(blocks: UpsertWorkingHoursDto['blocks']): WorkingHoursBlockInput[] { + return blocks.map((block, index) => ({ + dayOfWeek: block.dayOfWeek, + startMinute: block.startMinute, + endMinute: block.endMinute, + sortOrder: block.sortOrder ?? index, + })); + } + + 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, userId: true }, + }); + if (!membership) { + throw new NotFoundException('Member not found'); + } + if (membership.isOwner) { + throw new BadRequestException('Working hours cannot be set for the organization owner'); + } + return membership; + } + + private async assertCanViewStaff(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canViewStaff(actor)) { + throw new ForbiddenException('You do not have access to staff management'); + } + } + + private async assertCanEditStaff(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot manage staff working hours'); + } + } + + private async getActorMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId }, + include: { + permissions: { include: { permission: true } }, + organization: { select: { planId: true } }, + }, + }); + } + + private canViewStaff(m: { + isOwner: boolean; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return true; + return m.permissions.some( + (p) => p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT', + ); + } + + private canEditStaff(m: { + isOwner: boolean; + organization?: { planId: string | null }; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return Boolean(m.organization?.planId); + return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); + } +} diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index 8183f32..a6fc1a8 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, Query, Req, UseGuards, @@ -16,13 +17,18 @@ import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; import { StaffService } from './staff.service'; +import { StaffWorkingHoursService } from './staff-working-hours.service'; @ApiTags('staff') @ApiBearerAuth('JWT-auth') @Controller('staff') export class StaffController { - constructor(private readonly staffService: StaffService) {} + constructor( + private readonly staffService: StaffService, + private readonly staffWorkingHoursService: StaffWorkingHoursService, + ) {} @Get('invitations/preview') @ApiOperation({ summary: 'Preview invite info by token (public)' }) @@ -80,6 +86,38 @@ export class StaffController { return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); } + @Get('members/:membershipId/working-hours') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get weekly working hours for a staff member' }) + getWorkingHours( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffWorkingHoursService.getWorkingHours( + req.user.id, + organizationId, + membershipId, + ); + } + + @Put('members/:membershipId/working-hours') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Save weekly working hours for a staff member' }) + upsertWorkingHours( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + @Body() dto: UpsertWorkingHoursDto, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffWorkingHoursService.upsertWorkingHours( + req.user.id, + organizationId, + membershipId, + dto, + ); + } + @Patch('members/:membershipId/enable') @UseGuards(JwtAuthGuard) @ApiOperation({ diff --git a/backend/src/modules/staff/staff.module.ts b/backend/src/modules/staff/staff.module.ts index c297ea0..0fefc9c 100644 --- a/backend/src/modules/staff/staff.module.ts +++ b/backend/src/modules/staff/staff.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { StaffController } from './staff.controller'; import { StaffService } from './staff.service'; +import { StaffWorkingHoursService } from './staff-working-hours.service'; @Module({ controllers: [StaffController], - providers: [StaffService, PrismaService], + providers: [StaffService, StaffWorkingHoursService, PrismaService], + exports: [StaffWorkingHoursService], }) export class StaffModule {} diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index ef0152d..f7ec1ad 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -46,7 +46,7 @@ export default function AppointmentsPage() { const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); const [bookingOpen, setBookingOpen] = useState(false); - const [bookingHour, setBookingHour] = useState(9); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); const [bookingProviderId, setBookingProviderId] = useState(null); const [bookingProviderName, setBookingProviderName] = useState(''); const [editingAppointmentId, setEditingAppointmentId] = useState(null); @@ -87,7 +87,7 @@ export default function AppointmentsPage() { try { const range = getLocalDayIsoRange(scheduleDate); const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(), + appointmentsApi.columnProviders(scheduleDate), appointmentsApi.list(range), ]); if (gen !== scheduleLoadGen.current) { @@ -161,7 +161,7 @@ export default function AppointmentsPage() { } } - function handleSlotClick(hour: number, providerUserId: string, providerName: string) { + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { if (isViewingPastDay) { toast.showInfo('Past appointments are view-only.'); return; @@ -170,7 +170,7 @@ export default function AppointmentsPage() { toast.showInfo('Select a patient before booking.'); return; } - setBookingHour(hour); + setBookingStartMinute(startMinute); setBookingProviderId(providerUserId); setBookingProviderName(providerName); setEditingAppointmentId(null); @@ -183,13 +183,20 @@ export default function AppointmentsPage() { return; } const provider = providers.find((p) => p.userId === appointment.providerUserId); - setBookingHour(new Date(appointment.startAt).getHours()); + const start = new Date(appointment.startAt); + setBookingStartMinute(start.getHours() * 60 + start.getMinutes()); setBookingProviderId(appointment.providerUserId); setBookingProviderName(provider?.name ?? bookingProviderName); setEditingAppointmentId(appointment.id); setBookingOpen(true); } + function handleAppointmentOutsideHours(appointment: AppointmentRecord) { + toast.showError( + 'This appointment falls outside the provider’s current working hours and cannot be edited.', + ); + } + async function handleSaveAppointment(payload: { patientId: string; providerUserId: string; @@ -298,8 +305,9 @@ export default function AppointmentsPage() { providers={providers} appointments={appointments} canBook={canManageAppointments && !isViewingPastDay} - onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} + onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)} onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} /> @@ -310,7 +318,7 @@ export default function AppointmentsPage() { patient={selectedPatient} providerUserId={bookingProviderId} providerName={bookingProviderName} - initialHour={bookingHour} + initialStartMinute={bookingStartMinute} editingAppointment={activeEditingAppointment} onClose={() => { setBookingOpen(false); diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 0f8eb87..b305f65 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -12,10 +12,18 @@ import { permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, + featureStateHasTreatmentEdit, resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; +import { + StaffWorkingHoursStep, + createDefaultWorkingHoursState, + workingHoursPayloadFromState, + workingHoursStateFromApi, +} from '@/components/staff/StaffWorkingHoursStep'; +import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours'; import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -144,9 +152,15 @@ export default function StaffPage() { const toast = useToast(); const [inviteOpen, setInviteOpen] = useState(false); + const [inviteStep, setInviteStep] = useState<1 | 2>(1); const [inviteEmail, setInviteEmail] = useState(''); const [inviteName, setInviteName] = useState(''); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); + const [inviteWorkingHoursDays, setInviteWorkingHoursDays] = useState( + () => createDefaultWorkingHoursState().days, + ); + const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true); + const [inviteHoursValidationError, setInviteHoursValidationError] = useState(null); const [inviteLoading, setInviteLoading] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState(null); @@ -160,8 +174,15 @@ export default function StaffPage() { const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); const [editing, setEditing] = useState(null); + const [editStep, setEditStep] = useState<1 | 2>(1); const [editName, setEditName] = useState(''); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); + const [editWorkingHoursDays, setEditWorkingHoursDays] = useState( + () => createDefaultWorkingHoursState().days, + ); + const [editAutoRepeatWeekly, setEditAutoRepeatWeekly] = useState(true); + const [editHoursValidationError, setEditHoursValidationError] = useState(null); + const [editLoadingWorkingHours, setEditLoadingWorkingHours] = useState(false); const [editLoading, setEditLoading] = useState(false); const [disableTarget, setDisableTarget] = useState(null); const [disablingMembershipId, setDisablingMembershipId] = useState(null); @@ -169,6 +190,11 @@ export default function StaffPage() { const [enablingMembershipId, setEnablingMembershipId] = useState(null); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); + const inviteHasTreatmentEdit = useMemo( + () => featureStateHasTreatmentEdit(invitePerms), + [invitePerms], + ); + const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]); const hasActivePlan = Boolean(currentOrganization?.plan); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; @@ -271,19 +297,60 @@ export default function StaffPage() { } } - async function submitInvite() { + function resetInviteForm() { + setInviteStep(1); + setInviteEmail(''); + setInviteName(''); + setInvitePerms(emptyFeaturePermissionState()); + const defaults = createDefaultWorkingHoursState(); + setInviteWorkingHoursDays(defaults.days); + setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly); + setInviteHoursValidationError(null); + } + + async function saveInviteWorkingHours(membershipId: string, includeHours: boolean) { + if (!includeHours || !inviteHasTreatmentEdit) { + return; + } + const validationError = validateEditorDays(inviteWorkingHoursDays); + if (validationError) { + throw new Error(validationError); + } + await staffApi.upsertWorkingHours( + membershipId, + workingHoursPayloadFromState({ + days: inviteWorkingHoursDays, + autoRepeatWeekly: inviteAutoRepeatWeekly, + }), + ); + } + + async function submitInvite(includeWorkingHours: boolean) { setInviteLoading(true); toast.setError(''); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); try { + if (includeWorkingHours && inviteHasTreatmentEdit) { + const validationError = validateEditorDays(inviteWorkingHoursDays); + if (validationError) { + toast.showError(validationError); + return; + } + } + const permissionNames = permissionNamesFromFeatureState(invitePerms); const res = await staffApi.invite({ email: displayEmail, name: displayName, permissionNames, }); + + if (includeWorkingHours) { + await saveInviteWorkingHours(res.data.membershipId, true); + } + setLastInviteInfo({ membershipId: res.data.membershipId, name: displayName, @@ -304,9 +371,7 @@ export default function StaffPage() { writeStoredInviteLinks(currentOrganization.id, nextLinks); } setInviteOpen(false); - setInviteEmail(''); - setInviteName(''); - setInvitePerms(emptyFeaturePermissionState()); + resetInviteForm(); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); @@ -315,26 +380,60 @@ export default function StaffPage() { } } - function openEdit(m: StaffMemberDto) { + async function openEdit(m: StaffMemberDto) { if (m.isOwner) return; setEditing(m); + setEditStep(1); setEditName(m.name); - setEditPerms( - featureStateFromPermissionNames(m.permissions ?? []), - ); + setEditPerms(featureStateFromPermissionNames(m.permissions ?? [])); + setEditHoursValidationError(null); + const defaults = createDefaultWorkingHoursState(); + setEditWorkingHoursDays(defaults.days); + setEditAutoRepeatWeekly(defaults.autoRepeatWeekly); + setEditLoadingWorkingHours(true); + try { + const res = await staffApi.getWorkingHours(m.id); + const state = workingHoursStateFromApi(res.data); + setEditWorkingHoursDays(state.days); + setEditAutoRepeatWeekly(state.autoRepeatWeekly); + } catch (e) { + toast.showError(formatApiErrorMessage(e, 'Failed to load working hours.')); + } finally { + setEditLoadingWorkingHours(false); + } } async function submitEdit() { if (!editing) return; + if (editHasTreatmentEdit) { + const validationError = validateEditorDays(editWorkingHoursDays); + if (validationError) { + toast.showError(validationError); + return; + } + } + setEditLoading(true); toast.setError(''); try { + if (editHasTreatmentEdit) { + await staffApi.upsertWorkingHours( + editing.id, + workingHoursPayloadFromState({ + days: editWorkingHoursDays, + autoRepeatWeekly: editAutoRepeatWeekly, + }), + ); + } + await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); + toast.showSuccess('Member updated.'); setEditing(null); + setEditStep(1); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); @@ -400,6 +499,7 @@ export default function StaffPage() { size="sm" onClick={() => { if (!canEdit || atSeatLimit) return; + resetInviteForm(); setInviteOpen(true); setLastInviteInfo(null); }} @@ -666,43 +766,110 @@ export default function StaffPage() { aria-labelledby="invite-staff-title" >
-

- Invite team member -

- setInviteOpen(false)} /> -
- setInviteEmail(e.target.value)} - autoComplete="off" - /> - setInviteName(e.target.value)} - /> -
-

Tab access

- +

+ Invite team member +

+ {inviteHasTreatmentEdit && ( +

Step {inviteStep} of 2

+ )} +
+ { + setInviteOpen(false); + resetInviteForm(); + }} /> + + {inviteStep === 1 ? ( + <> + setInviteEmail(e.target.value)} + autoComplete="off" + /> + setInviteName(e.target.value)} + /> +
+

Tab access

+ +
+ + ) : ( + + )} +
- + {inviteStep === 1 ? ( + inviteHasTreatmentEdit ? ( + + ) : ( + + ) + ) : ( + <> + + + + )}
@@ -830,26 +997,85 @@ export default function StaffPage() { aria-modal="true" >
-

Edit member

- setEditing(null)} /> -
-

{editing.email}

- setEditName(e.target.value)} /> -
-

Tab access

- +

Edit member

+ {editHasTreatmentEdit && ( +

Step {editStep} of 2

+ )} +
+ { + setEditing(null); + setEditStep(1); + }} /> +

{editing.email}

+ + {editStep === 1 ? ( + <> + setEditName(e.target.value)} + /> +
+

Tab access

+ +
+ + ) : editLoadingWorkingHours ? ( +

Loading working hours…

+ ) : ( + + )} +
- - + {editStep === 1 ? ( + editHasTreatmentEdit ? ( + + ) : ( + + ) + ) : ( + + )}
diff --git a/frontend/src/components/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/staff/StaffWorkingHoursStep.tsx new file mode 100644 index 0000000..069f653 --- /dev/null +++ b/frontend/src/components/staff/StaffWorkingHoursStep.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; +import { + blocksFromEditorDays, + editorDaysFromBlocks, + emptyWorkingHoursEditorDays, + validateEditorDays, + type WorkingHoursEditorDay, +} from '@/components/staff/workingHours'; + +interface StaffWorkingHoursStepProps { + days: WorkingHoursEditorDay[]; + autoRepeatWeekly: boolean; + onDaysChange: (days: WorkingHoursEditorDay[]) => void; + onAutoRepeatWeeklyChange: (value: boolean) => void; + onValidationChange?: (error: string | null) => void; + disabled?: boolean; +} + +export function StaffWorkingHoursStep({ + days, + autoRepeatWeekly, + onDaysChange, + onAutoRepeatWeeklyChange, + onValidationChange, + disabled, +}: StaffWorkingHoursStepProps) { + useEffect(() => { + onValidationChange?.(validateEditorDays(days)); + }, [days, onValidationChange]); + + return ( +
+
+

Working hours recommended

+

+ Staff with treatment edit access appear as provider columns in Appointments. Set their + weekly hours so the schedule grid shows the right bookable times. +

+
+ +
+ ); +} + +export function createDefaultWorkingHoursState() { + return { + days: emptyWorkingHoursEditorDays(), + autoRepeatWeekly: true, + }; +} + +export function workingHoursPayloadFromState(state: { + days: WorkingHoursEditorDay[]; + autoRepeatWeekly: boolean; +}) { + return { + autoRepeatWeekly: state.autoRepeatWeekly, + blocks: blocksFromEditorDays(state.days), + }; +} + +export function workingHoursStateFromApi(data: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; +}) { + const hasBlocks = data.blocks.length > 0; + return { + days: hasBlocks ? editorDaysFromBlocks(data.blocks) : emptyWorkingHoursEditorDays(), + autoRepeatWeekly: data.autoRepeatWeekly, + }; +} + +export function useWorkingHoursForm(initial?: { + days: WorkingHoursEditorDay[]; + autoRepeatWeekly: boolean; +}) { + const [days, setDays] = useState(initial?.days ?? emptyWorkingHoursEditorDays()); + const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true); + const [validationError, setValidationError] = useState(null); + + return { + days, + setDays, + autoRepeatWeekly, + setAutoRepeatWeekly, + validationError, + setValidationError, + reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) { + setDays(next?.days ?? emptyWorkingHoursEditorDays()); + setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true); + setValidationError(null); + }, + }; +} diff --git a/frontend/src/components/staff/WorkingHoursEditor.tsx b/frontend/src/components/staff/WorkingHoursEditor.tsx new file mode 100644 index 0000000..14d881d --- /dev/null +++ b/frontend/src/components/staff/WorkingHoursEditor.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { Plus, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { + MINUTES_PER_DAY, + WEEKDAY_LABELS, + minutesToTimeInput, + timeInputToMinutes, + type WorkingHoursEditorDay, +} from '@/components/staff/workingHours'; + +interface WorkingHoursEditorProps { + days: WorkingHoursEditorDay[]; + autoRepeatWeekly: boolean; + onDaysChange: (days: WorkingHoursEditorDay[]) => void; + onAutoRepeatWeeklyChange: (value: boolean) => void; + disabled?: boolean; +} + +const timeInputClass = + 'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35'; + +export function WorkingHoursEditor({ + days, + autoRepeatWeekly, + onDaysChange, + onAutoRepeatWeeklyChange, + disabled = false, +}: WorkingHoursEditorProps) { + function updateDay(dayOfWeek: number, patch: Partial) { + onDaysChange( + days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)), + ); + } + + function updateShift( + dayOfWeek: number, + shiftIndex: number, + field: 'startMinute' | 'endMinute', + value: string, + ) { + const minutes = timeInputToMinutes(value); + if (minutes == null) return; + const day = days.find((d) => d.dayOfWeek === dayOfWeek); + if (!day) return; + const shifts = day.shifts.map((shift, index) => + index === shiftIndex ? { ...shift, [field]: minutes } : shift, + ); + updateDay(dayOfWeek, { shifts }); + } + + function addShift(dayOfWeek: number) { + const day = days.find((d) => d.dayOfWeek === dayOfWeek); + if (!day) return; + const last = day.shifts[day.shifts.length - 1]; + const startMinute = last ? Math.min(last.endMinute + 60, MINUTES_PER_DAY - 60) : 9 * 60; + updateDay(dayOfWeek, { + shifts: [...day.shifts, { startMinute, endMinute: Math.min(startMinute + 120, MINUTES_PER_DAY) }], + }); + } + + function removeShift(dayOfWeek: number, shiftIndex: number) { + const day = days.find((d) => d.dayOfWeek === dayOfWeek); + if (!day || day.shifts.length <= 1) return; + updateDay(dayOfWeek, { + shifts: day.shifts.filter((_, index) => index !== shiftIndex), + }); + } + + return ( +
+

+ Set weekly working hours for this provider. The appointments grid uses these hours to show + bookable time slots. +

+ +
+ {days.map((day) => ( +
+
+ + {WEEKDAY_LABELS[day.dayOfWeek]} + + { + updateDay(day.dayOfWeek, { + isWorking: checked, + shifts: checked + ? day.shifts.length > 0 + ? day.shifts + : [{ startMinute: 9 * 60, endMinute: 17 * 60 }] + : day.shifts, + }); + }} + /> +
+ + {day.isWorking && ( +
+ {day.shifts.map((shift, shiftIndex) => ( +
+
+ + + updateShift(day.dayOfWeek, shiftIndex, 'startMinute', e.target.value) + } + /> +
+
+ + + updateShift(day.dayOfWeek, shiftIndex, 'endMinute', e.target.value) + } + /> +
+ +
+ ))} + +
+ )} +
+ ))} +
+ + +
+ ); +} diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index 54787f2..0ec4a32 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -57,6 +57,10 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string return out; } +export function featureStateHasTreatmentEdit(state: FeaturePermState): boolean { + return Boolean(state.TAB_TREATMENT_EDIT?.edit); +} + /** Human-readable access for the team table — feature name, or "Feature (Read only)" */ export function formatAccessSummary( permissionNames: string[] | null | undefined, diff --git a/frontend/src/components/staff/workingHours.ts b/frontend/src/components/staff/workingHours.ts new file mode 100644 index 0000000..87b7af7 --- /dev/null +++ b/frontend/src/components/staff/workingHours.ts @@ -0,0 +1,213 @@ +export const MINUTES_PER_DAY = 24 * 60; +export const SCHEDULE_SLOT_MINUTES = 15; + +export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; + +export type WorkingHoursBlock = { + dayOfWeek: number; + startMinute: number; + endMinute: number; + sortOrder?: number; +}; + +export type WorkingHoursDayBlock = { + startMinute: number; + endMinute: number; +}; + +export type WorkingHoursEditorDay = { + dayOfWeek: number; + isWorking: boolean; + shifts: { startMinute: number; endMinute: number }[]; +}; + +export function localDayOfWeekMondayZero(dayOfWeekJs: number): number { + return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1; +} + +export function minutesToTimeInput(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +export function timeInputToMinutes(value: string): number | null { + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); + if (!match) return null; + const h = Number(match[1]); + const m = Number(match[2]); + if (h < 0 || h > 23 || m < 0 || m > 59) return null; + return h * 60 + m; +} + +export function formatMinuteLabel(minute: number): string { + const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0); + return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true }); +} + +export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] { + return WEEKDAY_LABELS.map((_, dayOfWeek) => ({ + dayOfWeek, + isWorking: false, + shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }], + })); +} + +export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursEditorDay[] { + const byDay = new Map(); + for (const block of blocks) { + const list = byDay.get(block.dayOfWeek) ?? []; + list.push({ startMinute: block.startMinute, endMinute: block.endMinute }); + byDay.set(block.dayOfWeek, list); + } + + return WEEKDAY_LABELS.map((_, dayOfWeek) => { + const shifts = (byDay.get(dayOfWeek) ?? []).sort( + (a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute, + ); + return { + dayOfWeek, + isWorking: shifts.length > 0, + shifts: shifts.length > 0 ? shifts : [{ startMinute: 9 * 60, endMinute: 17 * 60 }], + }; + }); +} + +export function blocksFromEditorDays(days: WorkingHoursEditorDay[]): WorkingHoursBlock[] { + const blocks: WorkingHoursBlock[] = []; + for (const day of days) { + if (!day.isWorking) continue; + day.shifts.forEach((shift, index) => { + blocks.push({ + dayOfWeek: day.dayOfWeek, + startMinute: shift.startMinute, + endMinute: shift.endMinute, + sortOrder: index, + }); + }); + } + return blocks; +} + +export function validateEditorDays(days: WorkingHoursEditorDay[]): string | null { + for (const day of days) { + if (!day.isWorking) continue; + if (day.shifts.length === 0) { + return `${WEEKDAY_LABELS[day.dayOfWeek]} needs at least one shift or should be marked off.`; + } + const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute); + for (const shift of sorted) { + if (shift.endMinute <= shift.startMinute) { + return `${WEEKDAY_LABELS[day.dayOfWeek]} shift end time must be after start time.`; + } + } + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].startMinute < sorted[i - 1].endMinute) { + return `${WEEKDAY_LABELS[day.dayOfWeek]} shifts cannot overlap.`; + } + } + } + return null; +} + +export function blocksForDay(blocks: WorkingHoursBlock[], dayOfWeek: number): WorkingHoursDayBlock[] { + return blocks + .filter((b) => b.dayOfWeek === dayOfWeek) + .sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute) + .map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute })); +} + +export function isMinuteWithinWorkingBlocks( + minute: number, + dayBlocks: WorkingHoursDayBlock[], +): boolean { + return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute); +} + +export function isSlotWithinWorkingBlocks( + slotStartMinute: number, + slotMinutes: number, + dayBlocks: WorkingHoursDayBlock[], +): boolean { + const slotEnd = slotStartMinute + slotMinutes; + for (let m = slotStartMinute; m < slotEnd; m += 1) { + if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) { + return false; + } + } + return true; +} + +export function appointmentWithinWorkingHours( + startAt: Date, + endAt: Date, + dayBlocks: WorkingHoursDayBlock[], +): boolean { + const startMinute = startAt.getHours() * 60 + startAt.getMinutes(); + const endMinute = endAt.getHours() * 60 + endAt.getMinutes(); + if (endMinute <= startMinute) return false; + for (let m = startMinute; m < endMinute; m += 1) { + if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) { + return false; + } + } + return true; +} + +export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): { + startMinute: number; + endMinute: number; +} | null { + let startMinute: number | null = null; + let endMinute: number | null = null; + + for (const dayBlocks of dayBlocksList) { + for (const block of dayBlocks) { + startMinute = + startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute); + endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute); + } + } + + if (startMinute == null || endMinute == null) { + return null; + } + + return { startMinute, endMinute }; +} + +export function snapRangeToSlots( + startMinute: number, + endMinute: number, + slotMinutes: number, +): { startMinute: number; endMinute: number; slotCount: number } { + const start = Math.floor(startMinute / slotMinutes) * slotMinutes; + const end = Math.ceil(endMinute / slotMinutes) * slotMinutes; + return { + startMinute: start, + endMinute: end, + slotCount: Math.max(1, (end - start) / slotMinutes), + }; +} + +export function generateSlotStarts( + startMinute: number, + endMinute: number, + slotMinutes: number, +): number[] { + const slots: number[] = []; + for (let m = startMinute; m < endMinute; m += slotMinutes) { + slots.push(m); + } + return slots; +} + +export function generateHourLabelsInRange(startMinute: number, endMinute: number): number[] { + const firstHour = Math.floor(startMinute / 60); + const lastHour = Math.ceil(endMinute / 60); + const hours: number[] = []; + for (let h = firstHour; h < lastHour; h += 1) { + hours.push(h); + } + return hours; +} diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 17921df..16b61d0 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -20,7 +20,7 @@ interface AppointmentBookingModalProps { patient: Patient | undefined; providerUserId: string | null; providerName: string; - initialHour: number; + initialStartMinute: number; onClose: () => void; onSubmit: (payload: { patientId: string; @@ -42,7 +42,7 @@ export function AppointmentBookingModal({ patient, providerUserId, providerName, - initialHour, + initialStartMinute, onClose, onSubmit, editingAppointment = null, @@ -81,17 +81,18 @@ export function AppointmentBookingModal({ scheduleDate.getFullYear(), scheduleDate.getMonth(), scheduleDate.getDate(), - initialHour, - 0, + Math.floor(initialStartMinute / 60), + initialStartMinute % 60, 0, 0, ); + const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1); const end = new Date( scheduleDate.getFullYear(), scheduleDate.getMonth(), scheduleDate.getDate(), - initialHour < 23 ? initialHour + 1 : 23, - initialHour < 23 ? 0 : 59, + Math.floor(endMinute / 60), + endMinute % 60, 0, 0, ); @@ -100,7 +101,7 @@ export function AppointmentBookingModal({ setPurpose('consultation'); } setError(''); - }, [open, scheduleDate, initialHour, editingAppointment]); + }, [open, scheduleDate, initialStartMinute, editingAppointment]); if (!open || !providerUserId) { return null; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 041d050..4a3a0d5 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -2,7 +2,16 @@ import { useMemo, useState } from 'react'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import { formatHourLabel } from '@/components/appointments/appointmentTime'; +import { + SCHEDULE_SLOT_MINUTES, + appointmentWithinWorkingHours, + formatMinuteLabel, + generateHourLabelsInRange, + generateSlotStarts, + isSlotWithinWorkingBlocks, + snapRangeToSlots, + unionDayBlockRange, +} from '@/components/staff/workingHours'; import { computeAppointmentLaneLayouts, findOverlapCluster, @@ -10,23 +19,30 @@ import { } from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; +import { startOfLocalDay } from '@/components/appointments/appointmentTime'; -const HOUR_PX = 40; -const HOURS = Array.from({ length: 24 }, (_, i) => i); +const HOUR_PX = 80; +const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; -function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null { - const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0); - const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0); +function layoutBlockInRange( + apt: AppointmentRecord, + day: Date, + rangeStartMinute: number, + rangeEndMinute: number, +): { top: string; height: string } | null { + const dayStart = startOfLocalDay(day); + const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000; + const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000; const start = new Date(apt.startAt); const end = new Date(apt.endAt); - const ms = dayEnd.getTime() - dayStart.getTime(); - const clipStart = Math.max(start.getTime(), dayStart.getTime()); - const clipEnd = Math.min(end.getTime(), dayEnd.getTime()); + const clipStart = Math.max(start.getTime(), rangeStartMs); + const clipEnd = Math.min(end.getTime(), rangeEndMs); if (clipEnd <= clipStart) { return null; } - const top = ((clipStart - dayStart.getTime()) / ms) * 100; - const height = ((clipEnd - clipStart) / ms) * 100; + const rangeMs = rangeEndMs - rangeStartMs; + const top = ((clipStart - rangeStartMs) / rangeMs) * 100; + const height = ((clipEnd - clipStart) / rangeMs) * 100; return { top: `${top}%`, height: `${height}%` }; } @@ -36,12 +52,12 @@ function appointmentDurationMinutes(apt: AppointmentRecord): number { return Math.max(0, Math.round((end - start) / 60_000)); } -function appointmentBannerHeightPx(durationMin: number): number { - return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX; +function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number { + return (durationMin / rangeMinutes) * gridHeight; } -function shortBannerNameClass(durationMin: number): string { - const heightPx = appointmentBannerHeightPx(durationMin); +function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string { + const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight); if (heightPx < 18) { return 'text-[8px] leading-none'; } @@ -61,8 +77,9 @@ interface AppointmentScheduleGridProps { providers: AppointmentColumnProvider[]; appointments: AppointmentRecord[]; canBook: boolean; - onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; + onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void; + onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void; } export function AppointmentScheduleGrid({ @@ -72,10 +89,39 @@ export function AppointmentScheduleGrid({ canBook, onSlotClick, onAppointmentClick, + onAppointmentOutsideHours, }: AppointmentScheduleGridProps) { - const gridHeight = HOURS.length * HOUR_PX; const [overlapPopover, setOverlapPopover] = useState(null); + const visibleRange = useMemo(() => { + const activeDayBlocks = providers + .filter((p) => p.hasWorkingHours && p.dayBlocks.length > 0) + .map((p) => p.dayBlocks); + return unionDayBlockRange(activeDayBlocks); + }, [providers]); + + const snappedRange = useMemo(() => { + if (!visibleRange) return null; + return snapRangeToSlots(visibleRange.startMinute, visibleRange.endMinute, SCHEDULE_SLOT_MINUTES); + }, [visibleRange]); + + const slotStarts = useMemo(() => { + if (!snappedRange) return []; + return generateSlotStarts( + snappedRange.startMinute, + snappedRange.endMinute, + SCHEDULE_SLOT_MINUTES, + ); + }, [snappedRange]); + + const hourLabels = useMemo(() => { + if (!snappedRange) return []; + return generateHourLabelsInRange(snappedRange.startMinute, snappedRange.endMinute); + }, [snappedRange]); + + const gridHeight = slotStarts.length * SLOT_PX; + const rangeMinutes = snappedRange ? snappedRange.endMinute - snappedRange.startMinute : 0; + const laneLayoutsByProvider = useMemo(() => { const map = new Map>(); for (const provider of providers) { @@ -87,9 +133,19 @@ export function AppointmentScheduleGrid({ function handleAppointmentBannerClick( apt: AppointmentRecord, + provider: AppointmentColumnProvider, providerAppointments: AppointmentRecord[], anchor: HTMLElement, ) { + if ( + provider.hasWorkingHours && + provider.dayBlocks.length > 0 && + !appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks) + ) { + onAppointmentOutsideHours?.(apt); + return; + } + const cluster = findOverlapCluster(apt.id, providerAppointments); if (cluster.length > 1) { setOverlapPopover({ @@ -109,6 +165,15 @@ export function AppointmentScheduleGrid({ ); } + if (!snappedRange || slotStarts.length === 0) { + return ( +
+ No working hours are configured for this day. Set provider working hours in Staff + management. +
+ ); + } + return ( <>
@@ -121,21 +186,38 @@ export function AppointmentScheduleGrid({ className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border" > {p.name} + {!p.hasWorkingHours && ( + + No hours set + + )} + {p.hasWorkingHours && p.dayBlocks.length === 0 && ( + + Off today + + )}
))}
-
- {HOURS.map((h) => ( -
- {formatHourLabel(h)} -
- ))} +
+ {hourLabels.map((hour) => { + const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight; + const height = (60 / rangeMinutes) * gridHeight; + return ( +
+ {formatMinuteLabel(hour * 60)} +
+ ); + })}
@@ -144,6 +226,7 @@ export function AppointmentScheduleGrid({ (a) => a.providerUserId === p.userId, ); const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); + const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0; return (
- {HOURS.map((h) => { - const slotDisabled = !canBook; + {slotStarts.map((slotStartMinute, index) => { + const slotActive = + !columnFullyDisabled && + isSlotWithinWorkingBlocks( + slotStartMinute, + SCHEDULE_SLOT_MINUTES, + p.dayBlocks, + ); + const slotDisabled = !canBook || columnFullyDisabled || !slotActive; + return (