feature/working-hours #42

Merged
admin merged 3 commits from feature/working-hours into master 2026-06-20 00:46:41 +03:30
22 changed files with 1571 additions and 116 deletions
Showing only changes of commit 48ecfd05e1 - Show all commits

View File

@@ -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;

View File

@@ -217,6 +217,7 @@ model Membership {
permissions MembershipPermission[] permissions MembershipPermission[]
invitations StaffInvitation[] invitations StaffInvitation[]
workingHoursSchedule StaffWorkingHoursSchedule?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -225,6 +226,34 @@ model Membership {
@@map("memberships") @@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 { model StaffInvitation {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@@ -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<number, WorkingHoursBlockInput[]>();
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 };
}

View File

@@ -13,6 +13,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppointmentsService } from './appointments.service'; import { AppointmentsService } from './appointments.service';
import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto';
import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto';
@@ -29,9 +30,16 @@ export class AppointmentsController {
summary: summary:
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.', '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); 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() @Get()

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { StaffModule } from '../staff/staff.module';
import { AppointmentsController } from './appointments.controller'; import { AppointmentsController } from './appointments.controller';
import { AppointmentsService } from './appointments.service'; import { AppointmentsService } from './appointments.service';
@Module({ @Module({
imports: [StaffModule],
controllers: [AppointmentsController], controllers: [AppointmentsController],
providers: [AppointmentsService, PrismaService], providers: [AppointmentsService, PrismaService],
}) })

View File

@@ -5,6 +5,12 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; 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 { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto';
@@ -13,7 +19,10 @@ const MS_PER_DAY = 86_400_000;
@Injectable() @Injectable()
export class AppointmentsService { export class AppointmentsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly staffWorkingHoursService: StaffWorkingHoursService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) { getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) { if (!user?.organizationId) {
@@ -22,7 +31,7 @@ export class AppointmentsService {
return user.organizationId; return user.organizationId;
} }
async listColumnProviders(organizationId: string, actorUserId: string) { async listColumnProviders(organizationId: string, actorUserId: string, date?: string) {
await this.assertCanViewAppointments(actorUserId, organizationId); await this.assertCanViewAppointments(actorUserId, organizationId);
const members = await this.prisma.membership.findMany({ const members = await this.prisma.membership.findMany({
@@ -44,7 +53,23 @@ export class AppointmentsService {
orderBy: [{ createdAt: 'asc' }], 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 }; return { success: true, data };
} }
@@ -109,6 +134,12 @@ export class AppointmentsService {
await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensurePatientInOrg(dto.patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
await this.ensureAppointmentWithinProviderWorkingHours(
dto.providerUserId,
organizationId,
startAt,
endAt,
);
const appointment = await this.prisma.appointment.create({ const appointment = await this.prisma.appointment.create({
data: { data: {
@@ -166,6 +197,12 @@ export class AppointmentsService {
await this.ensurePatientInOrg(patientId, organizationId); await this.ensurePatientInOrg(patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
await this.ensureAppointmentWithinProviderWorkingHours(
providerUserId,
organizationId,
startAt,
endAt,
);
const appointment = await this.prisma.appointment.update({ const appointment = await this.prisma.appointment.update({
where: { id }, 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) { private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -0,0 +1,201 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
blocksForDay,
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.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<string, WorkingHoursBlockInput[]>();
}
const schedules = await this.prisma.staffWorkingHoursSchedule.findMany({
where: { membershipId: { in: membershipIds } },
include: {
blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] },
},
});
const map = new Map<string, WorkingHoursBlockInput[]>();
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 findMembership(membershipId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
select: { id: true, isOwner: 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');
}
}

View File

@@ -6,6 +6,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Req, Req,
UseGuards, UseGuards,
@@ -16,13 +17,18 @@ import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { InviteStaffDto } from './dto/invite-staff.dto'; import { InviteStaffDto } from './dto/invite-staff.dto';
import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto'; import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto';
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
import { StaffService } from './staff.service'; import { StaffService } from './staff.service';
import { StaffWorkingHoursService } from './staff-working-hours.service';
@ApiTags('staff') @ApiTags('staff')
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@Controller('staff') @Controller('staff')
export class StaffController { export class StaffController {
constructor(private readonly staffService: StaffService) {} constructor(
private readonly staffService: StaffService,
private readonly staffWorkingHoursService: StaffWorkingHoursService,
) {}
@Get('invitations/preview') @Get('invitations/preview')
@ApiOperation({ summary: 'Preview invite info by token (public)' }) @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); 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') @Patch('members/:membershipId/enable')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ @ApiOperation({

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { StaffController } from './staff.controller'; import { StaffController } from './staff.controller';
import { StaffService } from './staff.service'; import { StaffService } from './staff.service';
import { StaffWorkingHoursService } from './staff-working-hours.service';
@Module({ @Module({
controllers: [StaffController], controllers: [StaffController],
providers: [StaffService, PrismaService], providers: [StaffService, StaffWorkingHoursService, PrismaService],
exports: [StaffWorkingHoursService],
}) })
export class StaffModule {} export class StaffModule {}

View File

@@ -46,7 +46,7 @@ export default function AppointmentsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [bookingOpen, setBookingOpen] = useState(false); const [bookingOpen, setBookingOpen] = useState(false);
const [bookingHour, setBookingHour] = useState(9); const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null); const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState(''); const [bookingProviderName, setBookingProviderName] = useState('');
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null); const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
@@ -87,7 +87,7 @@ export default function AppointmentsPage() {
try { try {
const range = getLocalDayIsoRange(scheduleDate); const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([ const [pRes, aRes] = await Promise.all([
appointmentsApi.columnProviders(), appointmentsApi.columnProviders(scheduleDate),
appointmentsApi.list(range), appointmentsApi.list(range),
]); ]);
if (gen !== scheduleLoadGen.current) { 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) { if (isViewingPastDay) {
toast.showInfo('Past appointments are view-only.'); toast.showInfo('Past appointments are view-only.');
return; return;
@@ -170,7 +170,7 @@ export default function AppointmentsPage() {
toast.showInfo('Select a patient before booking.'); toast.showInfo('Select a patient before booking.');
return; return;
} }
setBookingHour(hour); setBookingStartMinute(startMinute);
setBookingProviderId(providerUserId); setBookingProviderId(providerUserId);
setBookingProviderName(providerName); setBookingProviderName(providerName);
setEditingAppointmentId(null); setEditingAppointmentId(null);
@@ -183,13 +183,20 @@ export default function AppointmentsPage() {
return; return;
} }
const provider = providers.find((p) => p.userId === appointment.providerUserId); 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); setBookingProviderId(appointment.providerUserId);
setBookingProviderName(provider?.name ?? bookingProviderName); setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id); setEditingAppointmentId(appointment.id);
setBookingOpen(true); setBookingOpen(true);
} }
function handleAppointmentOutsideHours(appointment: AppointmentRecord) {
toast.showError(
'This appointment falls outside the providers current working hours and cannot be edited.',
);
}
async function handleSaveAppointment(payload: { async function handleSaveAppointment(payload: {
patientId: string; patientId: string;
providerUserId: string; providerUserId: string;
@@ -298,8 +305,9 @@ export default function AppointmentsPage() {
providers={providers} providers={providers}
appointments={appointments} appointments={appointments}
canBook={canManageAppointments && !isViewingPastDay} canBook={canManageAppointments && !isViewingPastDay}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)} onAppointmentClick={(apt) => handleAppointmentClick(apt)}
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
/> />
</div> </div>
</div> </div>
@@ -310,7 +318,7 @@ export default function AppointmentsPage() {
patient={selectedPatient} patient={selectedPatient}
providerUserId={bookingProviderId} providerUserId={bookingProviderId}
providerName={bookingProviderName} providerName={bookingProviderName}
initialHour={bookingHour} initialStartMinute={bookingStartMinute}
editingAppointment={activeEditingAppointment} editingAppointment={activeEditingAppointment}
onClose={() => { onClose={() => {
setBookingOpen(false); setBookingOpen(false);

View File

@@ -12,10 +12,18 @@ import {
permissionNamesFromFeatureState, permissionNamesFromFeatureState,
emptyFeaturePermissionState, emptyFeaturePermissionState,
featureStateFromPermissionNames, featureStateFromPermissionNames,
featureStateHasTreatmentEdit,
resolveStaffFeatureLabel, resolveStaffFeatureLabel,
formatAccessSummary, formatAccessSummary,
type FeaturePermState, type FeaturePermState,
} from '../../../components/staff/staff-permission-form'; } 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 { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
@@ -144,9 +152,15 @@ export default function StaffPage() {
const toast = useToast(); const toast = useToast();
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [inviteStep, setInviteStep] = useState<1 | 2>(1);
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
const [inviteName, setInviteName] = useState(''); const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteWorkingHoursDays, setInviteWorkingHoursDays] = useState<WorkingHoursEditorDay[]>(
() => createDefaultWorkingHoursState().days,
);
const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true);
const [inviteHoursValidationError, setInviteHoursValidationError] = useState<string | null>(null);
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState<string | null>(null); const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState<string | null>(null);
@@ -160,8 +174,15 @@ export default function StaffPage() {
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({}); const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null); const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editStep, setEditStep] = useState<1 | 2>(1);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editWorkingHoursDays, setEditWorkingHoursDays] = useState<WorkingHoursEditorDay[]>(
() => createDefaultWorkingHoursState().days,
);
const [editAutoRepeatWeekly, setEditAutoRepeatWeekly] = useState(true);
const [editHoursValidationError, setEditHoursValidationError] = useState<string | null>(null);
const [editLoadingWorkingHours, setEditLoadingWorkingHours] = useState(false);
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null); const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null); const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
@@ -169,6 +190,11 @@ export default function StaffPage() {
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null); const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const inviteHasTreatmentEdit = useMemo(
() => featureStateHasTreatmentEdit(invitePerms),
[invitePerms],
);
const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]);
const hasActivePlan = Boolean(currentOrganization?.plan); const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => { const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false; 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); setInviteLoading(true);
toast.setError(''); toast.setError('');
setLastInviteInfo(null); setLastInviteInfo(null);
const displayName = inviteName.trim(); const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim(); const displayEmail = inviteEmail.trim();
try { try {
if (includeWorkingHours && inviteHasTreatmentEdit) {
const validationError = validateEditorDays(inviteWorkingHoursDays);
if (validationError) {
toast.showError(validationError);
return;
}
}
const permissionNames = permissionNamesFromFeatureState(invitePerms); const permissionNames = permissionNamesFromFeatureState(invitePerms);
const res = await staffApi.invite({ const res = await staffApi.invite({
email: displayEmail, email: displayEmail,
name: displayName, name: displayName,
permissionNames, permissionNames,
}); });
if (includeWorkingHours) {
await saveInviteWorkingHours(res.data.membershipId, true);
}
setLastInviteInfo({ setLastInviteInfo({
membershipId: res.data.membershipId, membershipId: res.data.membershipId,
name: displayName, name: displayName,
@@ -304,9 +371,7 @@ export default function StaffPage() {
writeStoredInviteLinks(currentOrganization.id, nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks);
} }
setInviteOpen(false); setInviteOpen(false);
setInviteEmail(''); resetInviteForm();
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
await load(); await load();
} catch (e) { } catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
@@ -315,17 +380,39 @@ export default function StaffPage() {
} }
} }
function openEdit(m: StaffMemberDto) { async function openEdit(m: StaffMemberDto) {
if (m.isOwner) return; if (m.isOwner) return;
setEditing(m); setEditing(m);
setEditStep(1);
setEditName(m.name); setEditName(m.name);
setEditPerms( setEditPerms(featureStateFromPermissionNames(m.permissions ?? []));
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() { async function submitEdit() {
if (!editing) return; if (!editing) return;
if (editHasTreatmentEdit) {
const validationError = validateEditorDays(editWorkingHoursDays);
if (validationError) {
toast.showError(validationError);
return;
}
}
setEditLoading(true); setEditLoading(true);
toast.setError(''); toast.setError('');
try { try {
@@ -333,8 +420,20 @@ export default function StaffPage() {
name: editName.trim(), name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms), permissionNames: permissionNamesFromFeatureState(editPerms),
}); });
if (editHasTreatmentEdit) {
await staffApi.upsertWorkingHours(
editing.id,
workingHoursPayloadFromState({
days: editWorkingHoursDays,
autoRepeatWeekly: editAutoRepeatWeekly,
}),
);
}
toast.showSuccess('Member updated.'); toast.showSuccess('Member updated.');
setEditing(null); setEditing(null);
setEditStep(1);
await load(); await load();
} catch (e) { } catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
@@ -400,6 +499,7 @@ export default function StaffPage() {
size="sm" size="sm"
onClick={() => { onClick={() => {
if (!canEdit || atSeatLimit) return; if (!canEdit || atSeatLimit) return;
resetInviteForm();
setInviteOpen(true); setInviteOpen(true);
setLastInviteInfo(null); setLastInviteInfo(null);
}} }}
@@ -666,43 +766,110 @@ export default function StaffPage() {
aria-labelledby="invite-staff-title" aria-labelledby="invite-staff-title"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2"> <div>
Invite team member <h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
</h2> Invite team member
<DialogCloseButton onClick={() => setInviteOpen(false)} /> </h2>
</div> {inviteHasTreatmentEdit && (
<Input <p className="text-xs text-text-muted mt-1">Step {inviteStep} of 2</p>
label="Email" )}
type="email" </div>
value={inviteEmail} <DialogCloseButton
onChange={(e) => setInviteEmail(e.target.value)} onClick={() => {
autoComplete="off" setInviteOpen(false);
/> resetInviteForm();
<Input }}
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/> />
</div> </div>
{inviteStep === 1 ? (
<>
<Input
label="Email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div>
</>
) : (
<StaffWorkingHoursStep
days={inviteWorkingHoursDays}
autoRepeatWeekly={inviteAutoRepeatWeekly}
onDaysChange={setInviteWorkingHoursDays}
onAutoRepeatWeeklyChange={setInviteAutoRepeatWeekly}
onValidationChange={setInviteHoursValidationError}
disabled={inviteLoading}
/>
)}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button <Button
variant="outline"
type="button" type="button"
isLoading={inviteLoading} onClick={() => {
disabled={!inviteEmail.trim() || !inviteName.trim()} if (inviteStep === 2) {
onClick={() => void submitInvite()} setInviteStep(1);
return;
}
setInviteOpen(false);
resetInviteForm();
}}
> >
Send invite {inviteStep === 2 ? 'Back' : 'Cancel'}
</Button> </Button>
{inviteStep === 1 ? (
inviteHasTreatmentEdit ? (
<Button
type="button"
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => setInviteStep(2)}
>
Next
</Button>
) : (
<Button
type="button"
isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite(false)}
>
Send invite
</Button>
)
) : (
<>
<Button
type="button"
variant="outline"
isLoading={inviteLoading}
onClick={() => void submitInvite(false)}
>
Skip for now
</Button>
<Button
type="button"
isLoading={inviteLoading}
disabled={Boolean(inviteHoursValidationError)}
onClick={() => void submitInvite(true)}
>
Send invite
</Button>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -830,26 +997,85 @@ export default function StaffPage() {
aria-modal="true" aria-modal="true"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2> <div>
<DialogCloseButton onClick={() => setEditing(null)} /> <h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
</div> {editHasTreatmentEdit && (
<p className="text-xs text-text-muted">{editing.email}</p> <p className="text-xs text-text-muted mt-1">Step {editStep} of 2</p>
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} /> )}
<div> </div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p> <DialogCloseButton
<PermissionGrid onClick={() => {
state={editPerms} setEditing(null);
onChange={setEditPerms} setEditStep(1);
organizationType={currentOrganization?.type} }}
/> />
</div> </div>
<p className="text-xs text-text-muted">{editing.email}</p>
{editStep === 1 ? (
<>
<Input
label="Display name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div>
</>
) : editLoadingWorkingHours ? (
<p className="text-sm text-text-secondary">Loading working hours</p>
) : (
<StaffWorkingHoursStep
days={editWorkingHoursDays}
autoRepeatWeekly={editAutoRepeatWeekly}
onDaysChange={setEditWorkingHoursDays}
onAutoRepeatWeeklyChange={setEditAutoRepeatWeekly}
onValidationChange={setEditHoursValidationError}
disabled={editLoading}
/>
)}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}> <Button
Cancel variant="outline"
</Button> type="button"
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}> onClick={() => {
Save if (editStep === 2) {
setEditStep(1);
return;
}
setEditing(null);
setEditStep(1);
}}
>
{editStep === 2 ? 'Back' : 'Cancel'}
</Button> </Button>
{editStep === 1 ? (
editHasTreatmentEdit ? (
<Button type="button" onClick={() => setEditStep(2)}>
Next
</Button>
) : (
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
</Button>
)
) : (
<Button
type="button"
isLoading={editLoading}
disabled={Boolean(editHoursValidationError)}
onClick={() => void submitEdit()}
>
Save
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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 (
<div className="space-y-3">
<div className="rounded-[var(--radius-md)] border border-primary/25 bg-primary/5 px-3 py-3">
<p className="text-sm text-text-primary font-medium">Working hours recommended</p>
<p className="text-sm text-text-secondary mt-1">
Staff with treatment edit access appear as provider columns in Appointments. Set their
weekly hours so the schedule grid shows the right bookable times.
</p>
</div>
<WorkingHoursEditor
days={days}
autoRepeatWeekly={autoRepeatWeekly}
onDaysChange={onDaysChange}
onAutoRepeatWeeklyChange={onAutoRepeatWeeklyChange}
disabled={disabled}
/>
</div>
);
}
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<string | null>(null);
return {
days,
setDays,
autoRepeatWeekly,
setAutoRepeatWeekly,
validationError,
setValidationError,
reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) {
setDays(next?.days ?? emptyWorkingHoursEditorDays());
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
setValidationError(null);
},
};
}

View File

@@ -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<WorkingHoursEditorDay>) {
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 (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Set weekly working hours for this provider. The appointments grid uses these hours to show
bookable time slots.
</p>
<div className="space-y-3">
{days.map((day) => (
<div
key={day.dayOfWeek}
className="rounded-[var(--radius-md)] border border-border/60 bg-background-card/40 px-3 py-3 space-y-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text-primary w-10">
{WEEKDAY_LABELS[day.dayOfWeek]}
</span>
<Checkbox
checked={day.isWorking}
disabled={disabled}
label="Working day"
onChange={(checked) => {
updateDay(day.dayOfWeek, {
isWorking: checked,
shifts: checked
? day.shifts.length > 0
? day.shifts
: [{ startMinute: 9 * 60, endMinute: 17 * 60 }]
: day.shifts,
});
}}
/>
</div>
{day.isWorking && (
<div className="space-y-2 pl-0 sm:pl-10">
{day.shifts.map((shift, shiftIndex) => (
<div key={shiftIndex} className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">Start</label>
<input
type="time"
step={300}
disabled={disabled}
className={timeInputClass}
value={minutesToTimeInput(shift.startMinute)}
onChange={(e) =>
updateShift(day.dayOfWeek, shiftIndex, 'startMinute', e.target.value)
}
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">End</label>
<input
type="time"
step={300}
disabled={disabled}
className={timeInputClass}
value={minutesToTimeInput(shift.endMinute)}
onChange={(e) =>
updateShift(day.dayOfWeek, shiftIndex, 'endMinute', e.target.value)
}
/>
</div>
<button
type="button"
disabled={disabled || day.shifts.length <= 1}
className="p-2 rounded-md text-text-muted hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Remove shift"
onClick={() => removeShift(day.dayOfWeek, shiftIndex)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => addShift(day.dayOfWeek)}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add shift
</Button>
</div>
)}
</div>
))}
</div>
<Checkbox
checked={autoRepeatWeekly}
disabled={disabled}
label="Repeat these hours at the start of each week (copy forward on Monday)"
onChange={onAutoRepeatWeeklyChange}
/>
</div>
);
}

View File

@@ -57,6 +57,10 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string
return out; 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)" */ /** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary( export function formatAccessSummary(
permissionNames: string[] | null | undefined, permissionNames: string[] | null | undefined,

View File

@@ -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: dayOfWeek <= 4,
shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
}));
}
export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursEditorDay[] {
const byDay = new Map<number, WorkingHoursDayBlock[]>();
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;
}

View File

@@ -20,7 +20,7 @@ interface AppointmentBookingModalProps {
patient: Patient | undefined; patient: Patient | undefined;
providerUserId: string | null; providerUserId: string | null;
providerName: string; providerName: string;
initialHour: number; initialStartMinute: number;
onClose: () => void; onClose: () => void;
onSubmit: (payload: { onSubmit: (payload: {
patientId: string; patientId: string;
@@ -42,7 +42,7 @@ export function AppointmentBookingModal({
patient, patient,
providerUserId, providerUserId,
providerName, providerName,
initialHour, initialStartMinute,
onClose, onClose,
onSubmit, onSubmit,
editingAppointment = null, editingAppointment = null,
@@ -81,17 +81,18 @@ export function AppointmentBookingModal({
scheduleDate.getFullYear(), scheduleDate.getFullYear(),
scheduleDate.getMonth(), scheduleDate.getMonth(),
scheduleDate.getDate(), scheduleDate.getDate(),
initialHour, Math.floor(initialStartMinute / 60),
0, initialStartMinute % 60,
0, 0,
0, 0,
); );
const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1);
const end = new Date( const end = new Date(
scheduleDate.getFullYear(), scheduleDate.getFullYear(),
scheduleDate.getMonth(), scheduleDate.getMonth(),
scheduleDate.getDate(), scheduleDate.getDate(),
initialHour < 23 ? initialHour + 1 : 23, Math.floor(endMinute / 60),
initialHour < 23 ? 0 : 59, endMinute % 60,
0, 0,
0, 0,
); );
@@ -100,7 +101,7 @@ export function AppointmentBookingModal({
setPurpose('consultation'); setPurpose('consultation');
} }
setError(''); setError('');
}, [open, scheduleDate, initialHour, editingAppointment]); }, [open, scheduleDate, initialStartMinute, editingAppointment]);
if (!open || !providerUserId) { if (!open || !providerUserId) {
return null; return null;

View File

@@ -2,7 +2,16 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; 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 { import {
computeAppointmentLaneLayouts, computeAppointmentLaneLayouts,
findOverlapCluster, findOverlapCluster,
@@ -10,23 +19,30 @@ import {
} from '@/components/appointments/appointmentOverlapLayout'; } from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
const HOUR_PX = 40; const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i); const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null { function layoutBlockInRange(
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0); apt: AppointmentRecord,
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0); 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 start = new Date(apt.startAt);
const end = new Date(apt.endAt); const end = new Date(apt.endAt);
const ms = dayEnd.getTime() - dayStart.getTime(); const clipStart = Math.max(start.getTime(), rangeStartMs);
const clipStart = Math.max(start.getTime(), dayStart.getTime()); const clipEnd = Math.min(end.getTime(), rangeEndMs);
const clipEnd = Math.min(end.getTime(), dayEnd.getTime());
if (clipEnd <= clipStart) { if (clipEnd <= clipStart) {
return null; return null;
} }
const top = ((clipStart - dayStart.getTime()) / ms) * 100; const rangeMs = rangeEndMs - rangeStartMs;
const height = ((clipEnd - clipStart) / ms) * 100; const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
const height = ((clipEnd - clipStart) / rangeMs) * 100;
return { top: `${top}%`, height: `${height}%` }; return { top: `${top}%`, height: `${height}%` };
} }
@@ -36,12 +52,12 @@ function appointmentDurationMinutes(apt: AppointmentRecord): number {
return Math.max(0, Math.round((end - start) / 60_000)); return Math.max(0, Math.round((end - start) / 60_000));
} }
function appointmentBannerHeightPx(durationMin: number): number { function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX; return (durationMin / rangeMinutes) * gridHeight;
} }
function shortBannerNameClass(durationMin: number): string { function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
const heightPx = appointmentBannerHeightPx(durationMin); const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
if (heightPx < 18) { if (heightPx < 18) {
return 'text-[8px] leading-none'; return 'text-[8px] leading-none';
} }
@@ -61,8 +77,9 @@ interface AppointmentScheduleGridProps {
providers: AppointmentColumnProvider[]; providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[]; appointments: AppointmentRecord[];
canBook: boolean; canBook: boolean;
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void; onAppointmentClick?: (appointment: AppointmentRecord) => void;
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
} }
export function AppointmentScheduleGrid({ export function AppointmentScheduleGrid({
@@ -72,10 +89,39 @@ export function AppointmentScheduleGrid({
canBook, canBook,
onSlotClick, onSlotClick,
onAppointmentClick, onAppointmentClick,
onAppointmentOutsideHours,
}: AppointmentScheduleGridProps) { }: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX;
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null); const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(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 laneLayoutsByProvider = useMemo(() => {
const map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>(); const map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>();
for (const provider of providers) { for (const provider of providers) {
@@ -87,9 +133,19 @@ export function AppointmentScheduleGrid({
function handleAppointmentBannerClick( function handleAppointmentBannerClick(
apt: AppointmentRecord, apt: AppointmentRecord,
provider: AppointmentColumnProvider,
providerAppointments: AppointmentRecord[], providerAppointments: AppointmentRecord[],
anchor: HTMLElement, 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); const cluster = findOverlapCluster(apt.id, providerAppointments);
if (cluster.length > 1) { if (cluster.length > 1) {
setOverlapPopover({ setOverlapPopover({
@@ -109,6 +165,15 @@ export function AppointmentScheduleGrid({
); );
} }
if (!snappedRange || slotStarts.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No working hours are configured for this day. Set provider working hours in Staff
management.
</div>
);
}
return ( return (
<> <>
<div className="surface-card overflow-x-auto"> <div className="surface-card overflow-x-auto">
@@ -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" 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.name}
{!p.hasWorkingHours && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
No hours set
</span>
)}
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
Off today
</span>
)}
</div> </div>
))} ))}
</div> </div>
<div className="flex"> <div className="flex">
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40"> <div
{HOURS.map((h) => ( className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
<div style={{ height: gridHeight }}
key={h} >
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50" {hourLabels.map((hour) => {
style={{ height: HOUR_PX }} const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
> const height = (60 / rangeMinutes) * gridHeight;
{formatHourLabel(h)} return (
</div> <div
))} key={hour}
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ top, height }}
>
{formatMinuteLabel(hour * 60)}
</div>
);
})}
</div> </div>
<div className="flex-1 flex min-w-0"> <div className="flex-1 flex min-w-0">
@@ -144,6 +226,7 @@ export function AppointmentScheduleGrid({
(a) => a.providerUserId === p.userId, (a) => a.providerUserId === p.userId,
); );
const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map(); const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map();
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
return ( return (
<div <div
@@ -151,31 +234,50 @@ export function AppointmentScheduleGrid({
className="flex-1 min-w-[130px] border-l border-border relative" className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }} style={{ height: gridHeight }}
> >
{HOURS.map((h) => { {slotStarts.map((slotStartMinute, index) => {
const slotDisabled = !canBook; const slotActive =
!columnFullyDisabled &&
isSlotWithinWorkingBlocks(
slotStartMinute,
SCHEDULE_SLOT_MINUTES,
p.dayBlocks,
);
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
return ( return (
<button <button
key={h} key={`${p.userId}-${slotStartMinute}`}
type="button" type="button"
disabled={slotDisabled} disabled={slotDisabled}
title={ title={
slotDisabled columnFullyDisabled
? 'You cannot create appointments' ? p.hasWorkingHours
: `Book ${formatHourLabel(h)}` ? 'Provider is off today'
: 'Working hours not configured'
: !slotActive
? 'Outside working hours'
: slotDisabled
? 'You cannot create appointments'
: `Book ${formatMinuteLabel(slotStartMinute)}`
} }
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${ className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled slotDisabled
? 'cursor-not-allowed opacity-50' ? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
: 'hover:bg-primary/8 cursor-pointer' : 'hover:bg-primary/8 cursor-pointer'
}`} }`}
style={{ top: h * HOUR_PX, height: HOUR_PX }} style={{ top: index * SLOT_PX, height: SLOT_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)} onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
/> />
); );
})} })}
{providerAppointments.map((apt) => { {providerAppointments.map((apt) => {
const pos = layoutBlock(apt, day); const pos = layoutBlockInRange(
apt,
day,
snappedRange.startMinute,
snappedRange.endMinute,
);
if (!pos) { if (!pos) {
return null; return null;
} }
@@ -184,9 +286,18 @@ export function AppointmentScheduleGrid({
const durationMin = appointmentDurationMinutes(apt); const durationMin = appointmentDurationMinutes(apt);
const clusterSize = findOverlapCluster(apt.id, providerAppointments).length; const clusterSize = findOverlapCluster(apt.id, providerAppointments).length;
const isUnderOneHour = durationMin < 60; const isUnderOneHour = durationMin < 60;
const outsideHours =
p.hasWorkingHours &&
p.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
p.dayBlocks,
);
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`; const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const bannerTitle = [ const bannerTitle = [
patientName, patientName,
outsideHours ? 'Outside working hours — editing blocked' : null,
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null, clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, !isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
] ]
@@ -198,9 +309,16 @@ export function AppointmentScheduleGrid({
type="button" type="button"
key={apt.id} key={apt.id}
onClick={(e) => onClick={(e) =>
handleAppointmentBannerClick(apt, providerAppointments, e.currentTarget) handleAppointmentBannerClick(
apt,
p,
providerAppointments,
e.currentTarget,
)
} }
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${
isUnderOneHour isUnderOneHour
? 'items-center justify-center px-0.5 py-0' ? 'items-center justify-center px-0.5 py-0'
: 'flex-col justify-start gap-0.5 px-1 py-0.5' : 'flex-col justify-start gap-0.5 px-1 py-0.5'
@@ -214,7 +332,7 @@ export function AppointmentScheduleGrid({
title={bannerTitle} title={bannerTitle}
> >
<span <span
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin)}`} className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
> >
{patientName} {patientName}
</span> </span>
@@ -245,7 +363,24 @@ export function AppointmentScheduleGrid({
<AppointmentOverlapPopover <AppointmentOverlapPopover
appointments={overlapPopover.appointments} appointments={overlapPopover.appointments}
anchorRect={overlapPopover.anchorRect} anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => onAppointmentClick?.(apt)} onSelect={(apt) => {
const provider = providers.find((p) => p.userId === apt.providerUserId);
if (
provider &&
provider.hasWorkingHours &&
provider.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
provider.dayBlocks,
)
) {
onAppointmentOutsideHours?.(apt);
setOverlapPopover(null);
return;
}
onAppointmentClick?.(apt);
}}
onClose={() => setOverlapPopover(null)} onClose={() => setOverlapPopover(null)}
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { toDateInputValue } from '@/components/appointments/appointmentTime';
export interface CreateAppointmentBody { export interface CreateAppointmentBody {
patientId: string; patientId: string;
@@ -12,8 +13,11 @@ export interface CreateAppointmentBody {
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>; export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
export const appointmentsApi = { export const appointmentsApi = {
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => { columnProviders: async (
const response = await apiClient.get('/appointments/column-providers'); scheduleDate?: Date,
): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const params = scheduleDate ? { date: toDateInputValue(scheduleDate) } : undefined;
const response = await apiClient.get('/appointments/column-providers', { params });
return response.data; return response.data;
}, },

View File

@@ -120,4 +120,29 @@ export const staffApi = {
const response = await apiClient.delete(`/staff/members/${membershipId}`); const response = await apiClient.delete(`/staff/members/${membershipId}`);
return response.data; return response.data;
}, },
getWorkingHours: async (
membershipId: string,
): Promise<{
success: boolean;
data: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
hasWorkingHours: boolean;
};
}> => {
const response = await apiClient.get(`/staff/members/${membershipId}/working-hours`);
return response.data;
},
upsertWorkingHours: async (
membershipId: string,
body: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
},
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, body);
return response.data;
},
}; };

View File

@@ -13,6 +13,8 @@ export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export interface AppointmentColumnProvider { export interface AppointmentColumnProvider {
userId: string; userId: string;
name: string; name: string;
hasWorkingHours: boolean;
dayBlocks: { startMinute: number; endMinute: number }[];
} }
export interface AppointmentRecord { export interface AppointmentRecord {