Merge pull request 'feature/working-hours' (#42) from feature/working-hours into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 1s
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 1s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/42
This commit was merged in pull request #42.
This commit is contained in:
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
|
||||
118
backend/src/common/working-hours.ts
Normal file
118
backend/src/common/working-hours.ts
Normal 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 };
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
44
backend/src/modules/staff/dto/upsert-working-hours.dto.ts
Normal file
44
backend/src/modules/staff/dto/upsert-working-hours.dto.ts
Normal 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[];
|
||||
}
|
||||
264
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
264
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
@@ -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<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 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');
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function AppointmentsPage() {
|
||||
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
|
||||
|
||||
const [bookingOpen, setBookingOpen] = useState(false);
|
||||
const [bookingHour, setBookingHour] = useState(9);
|
||||
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
|
||||
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
||||
const [bookingProviderName, setBookingProviderName] = useState('');
|
||||
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +318,7 @@ export default function AppointmentsPage() {
|
||||
patient={selectedPatient}
|
||||
providerUserId={bookingProviderId}
|
||||
providerName={bookingProviderName}
|
||||
initialHour={bookingHour}
|
||||
initialStartMinute={bookingStartMinute}
|
||||
editingAppointment={activeEditingAppointment}
|
||||
onClose={() => {
|
||||
setBookingOpen(false);
|
||||
|
||||
@@ -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<WorkingHoursEditorDay[]>(
|
||||
() => createDefaultWorkingHoursState().days,
|
||||
);
|
||||
const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true);
|
||||
const [inviteHoursValidationError, setInviteHoursValidationError] = useState<string | null>(null);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = 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 [editing, setEditing] = useState<StaffMemberDto | null>(null);
|
||||
const [editStep, setEditStep] = useState<1 | 2>(1);
|
||||
const [editName, setEditName] = useState('');
|
||||
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 [disableTarget, setDisableTarget] = useState<StaffMemberDto | 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 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,11 +766,24 @@ export default function StaffPage() {
|
||||
aria-labelledby="invite-staff-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
Invite team member
|
||||
</h2>
|
||||
<DialogCloseButton onClick={() => setInviteOpen(false)} />
|
||||
{inviteHasTreatmentEdit && (
|
||||
<p className="text-xs text-text-muted mt-1">Step {inviteStep} of 2</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton
|
||||
onClick={() => {
|
||||
setInviteOpen(false);
|
||||
resetInviteForm();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inviteStep === 1 ? (
|
||||
<>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
@@ -691,18 +804,72 @@ export default function StaffPage() {
|
||||
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">
|
||||
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (inviteStep === 2) {
|
||||
setInviteStep(1);
|
||||
return;
|
||||
}
|
||||
setInviteOpen(false);
|
||||
resetInviteForm();
|
||||
}}
|
||||
>
|
||||
{inviteStep === 2 ? 'Back' : 'Cancel'}
|
||||
</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()}
|
||||
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>
|
||||
@@ -830,11 +997,28 @@ export default function StaffPage() {
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
|
||||
<DialogCloseButton onClick={() => setEditing(null)} />
|
||||
{editHasTreatmentEdit && (
|
||||
<p className="text-xs text-text-muted mt-1">Step {editStep} of 2</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setEditStep(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{editing.email}</p>
|
||||
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
|
||||
{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
|
||||
@@ -843,13 +1027,55 @@ export default function StaffPage() {
|
||||
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">
|
||||
<Button variant="outline" type="button" onClick={() => setEditing(null)}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (editStep === 2) {
|
||||
setEditStep(1);
|
||||
return;
|
||||
}
|
||||
setEditing(null);
|
||||
setEditStep(1);
|
||||
}}
|
||||
>
|
||||
{editStep === 2 ? 'Back' : 'Cancel'}
|
||||
</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>
|
||||
|
||||
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal file
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal file
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
213
frontend/src/components/staff/workingHours.ts
Normal file
213
frontend/src/components/staff/workingHours.ts
Normal 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: false,
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>();
|
||||
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 (
|
||||
<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 (
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
{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 className="flex">
|
||||
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
|
||||
style={{ height: HOUR_PX }}
|
||||
className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
|
||||
style={{ height: gridHeight }}
|
||||
>
|
||||
{formatHourLabel(h)}
|
||||
{hourLabels.map((hour) => {
|
||||
const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
|
||||
const height = (60 / rangeMinutes) * gridHeight;
|
||||
return (
|
||||
<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 className="flex-1 flex min-w-0">
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -151,31 +234,50 @@ export function AppointmentScheduleGrid({
|
||||
className="flex-1 min-w-[130px] border-l border-border relative"
|
||||
style={{ height: gridHeight }}
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={h}
|
||||
key={`${p.userId}-${slotStartMinute}`}
|
||||
type="button"
|
||||
disabled={slotDisabled}
|
||||
title={
|
||||
slotDisabled
|
||||
columnFullyDisabled
|
||||
? p.hasWorkingHours
|
||||
? 'Provider is off today'
|
||||
: 'Working hours not configured'
|
||||
: !slotActive
|
||||
? 'Outside working hours'
|
||||
: slotDisabled
|
||||
? 'You cannot create appointments'
|
||||
: `Book ${formatHourLabel(h)}`
|
||||
: `Book ${formatMinuteLabel(slotStartMinute)}`
|
||||
}
|
||||
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
|
||||
slotDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
|
||||
: 'hover:bg-primary/8 cursor-pointer'
|
||||
}`}
|
||||
style={{ top: h * HOUR_PX, height: HOUR_PX }}
|
||||
onClick={() => onSlotClick(h, p.userId, p.name)}
|
||||
style={{ top: index * SLOT_PX, height: SLOT_PX }}
|
||||
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{providerAppointments.map((apt) => {
|
||||
const pos = layoutBlock(apt, day);
|
||||
const pos = layoutBlockInRange(
|
||||
apt,
|
||||
day,
|
||||
snappedRange.startMinute,
|
||||
snappedRange.endMinute,
|
||||
);
|
||||
if (!pos) {
|
||||
return null;
|
||||
}
|
||||
@@ -184,9 +286,18 @@ export function AppointmentScheduleGrid({
|
||||
const durationMin = appointmentDurationMinutes(apt);
|
||||
const clusterSize = findOverlapCluster(apt.id, providerAppointments).length;
|
||||
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 bannerTitle = [
|
||||
patientName,
|
||||
outsideHours ? 'Outside working hours — editing blocked' : null,
|
||||
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
|
||||
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
|
||||
]
|
||||
@@ -198,9 +309,16 @@ export function AppointmentScheduleGrid({
|
||||
type="button"
|
||||
key={apt.id}
|
||||
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 ${
|
||||
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
|
||||
} ${
|
||||
isUnderOneHour
|
||||
? 'items-center justify-center px-0.5 py-0'
|
||||
: 'flex-col justify-start gap-0.5 px-1 py-0.5'
|
||||
@@ -214,7 +332,7 @@ export function AppointmentScheduleGrid({
|
||||
title={bannerTitle}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
@@ -245,7 +363,24 @@ export function AppointmentScheduleGrid({
|
||||
<AppointmentOverlapPopover
|
||||
appointments={overlapPopover.appointments}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient } from './client';
|
||||
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
|
||||
import { toDateInputValue } from '@/components/appointments/appointmentTime';
|
||||
|
||||
export interface CreateAppointmentBody {
|
||||
patientId: string;
|
||||
@@ -12,8 +13,11 @@ export interface CreateAppointmentBody {
|
||||
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
|
||||
|
||||
export const appointmentsApi = {
|
||||
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
|
||||
const response = await apiClient.get('/appointments/column-providers');
|
||||
columnProviders: async (
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -120,4 +120,29 @@ export const staffApi = {
|
||||
const response = await apiClient.delete(`/staff/members/${membershipId}`);
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
|
||||
export interface AppointmentColumnProvider {
|
||||
userId: string;
|
||||
name: string;
|
||||
hasWorkingHours: boolean;
|
||||
dayBlocks: { startMinute: number; endMinute: number }[];
|
||||
}
|
||||
|
||||
export interface AppointmentRecord {
|
||||
|
||||
Reference in New Issue
Block a user