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

- Invite team member -

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

Tab access

- +

+ Invite team member +

+ {inviteHasTreatmentEdit && ( +

Step {inviteStep} of 2

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

Tab access

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

Edit member

- setEditing(null)} /> -
-

{editing.email}

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

Tab access

- +

Edit member

+ {editHasTreatmentEdit && ( +

Step {editStep} of 2

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

{editing.email}

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

Tab access

+ +
+ + ) : editLoadingWorkingHours ? ( +

Loading working hours…

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

Working hours recommended

+

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

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

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

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

Loading...

-
- ); - } - - return ( -
-
- - DyoLink - -

- Sign in to your account -

-

- Or{' '} - - start your free trial - -

-
- -
-
-
- } - /> - } - /> - -
-
- - -
-
- - Forgot your password? - -
-
- - {error && ( -
-

{error}

-
- )} - - -
-
-
-
- ); -} \ No newline at end of file diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx deleted file mode 100644 index c1372fe..0000000 --- a/frontend/src/app/(public)/register/page.tsx +++ /dev/null @@ -1,206 +0,0 @@ -// src/app/register/page.tsx\ -'use client'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import Link from 'next/link'; -import { Mail, Lock, User } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; -import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; -const registerSchema = z.object({ - name: z.string().min(2, 'Name must be at least 2 characters'), - email: z.string().email('Please enter a valid email address'), - password: z.string() - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') - .regex(/[0-9]/, 'Password must contain at least one number'), - confirmPassword: z.string(), - organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), - organizationEmail: z.string().email('Please enter a valid organization email'), - organizationType: z.enum(['CLINIC', 'LAB'], { - message: 'Please select organization type', - }), -}).refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ['confirmPassword'], -}); -type RegisterForm = z.infer; - -export default function RegisterPage() { - const { registerTrial, isLoading } = useAuth(); - const [step, setStep] = useState(1); - const [error, setError] = useState(null); - - const { - register, - handleSubmit, - watch, - formState: { errors }, - trigger, - setValue, - } = useForm({ - resolver: zodResolver(registerSchema), - mode: 'onChange', - }); - const organizationType = watch('organizationType'); - const handleNext = async () => { - const fieldsToValidate = step === 1 - ? ['name', 'email', 'password', 'confirmPassword'] - : ['organizationName', 'organizationEmail', 'organizationType']; - - const isValid = await trigger(fieldsToValidate as any); - if (isValid) { - setStep(step + 1); - } - }; - const onSubmit = async (data: RegisterForm) => { - try { - setError(null); - await registerTrial( - data.email, - data.password, - data.name, - data.organizationName, - data.organizationEmail, - data.organizationType - ); - // No need to redirect - auth context will handle it - } catch (err: any) { - setError(err.message || 'Registration failed. Please try again.'); - } - }; - return ( -
-
- - DyoLink - -

- Start your 30-day free trial -

-

- Already have an account?{' '} - - Sign in - -

-
-
-
- - {/* Trial Info Banner */} -
-

Your trial - includes:

-
    -
  • - Up to 5 team members -
  • -
  • - Full access to all features -
  • -
  • - 30 days free, no credit card - required -
  • -
-
-
- {step === 1 && ( - <> - } - /> - } - /> - } - /> - } - /> - - - )} - {step === 2 && ( - <> - - {error && ( -
-

{error}

-
- )} -
- - -
- - )} - -

- By signing up, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -

-
-
- -
- ); -} - - diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/appointments/page.tsx rename to frontend/src/app/[locale]/(dashboard)/appointments/page.tsx diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/billing/page.tsx rename to frontend/src/app/[locale]/(dashboard)/billing/page.tsx diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx similarity index 89% rename from frontend/src/app/(dashboard)/layout.tsx rename to frontend/src/app/[locale]/(dashboard)/layout.tsx index 4c0978c..d3b4c39 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -1,10 +1,11 @@ 'use client'; import { memo, useEffect } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import Sidebar from '@/components/ui/shared/Sidebar'; -import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { canAccessAppointmentsSection, @@ -14,11 +15,11 @@ import { } from '@/components/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations('common'); const { user, currentOrganization, isAuthReady } = useAuth(); const router = useRouter(); const pathname = usePathname(); - // ✅ AUTH GUARD (runs once per navigation group) useEffect(() => { if (!isAuthReady) return; @@ -44,11 +45,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } }, [isAuthReady, user, currentOrganization, router, pathname]); - // ✅ LOADING ONLY FOR INITIAL LOAD if (!isAuthReady) { return (
- Loading app... + {t('loadingApp')}
); } @@ -56,7 +56,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod if (!user || !currentOrganization) { return (
- Loading workspace... + {t('loadingWorkspace')}
); } @@ -88,9 +88,9 @@ const DashboardHeader = memo(function DashboardHeader({

{organizationName}

- +
); -}); \ No newline at end of file +}); diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/organizations/page.tsx diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/patients/page.tsx rename to frontend/src/app/[locale]/(dashboard)/patients/page.tsx diff --git a/frontend/src/app/(dashboard)/reports/page.tsx b/frontend/src/app/[locale]/(dashboard)/reports/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/reports/page.tsx rename to frontend/src/app/[locale]/(dashboard)/reports/page.tsx diff --git a/frontend/src/app/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx similarity index 94% rename from frontend/src/app/(dashboard)/settings/account/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 357f4f9..cc755d4 100644 --- a/frontend/src/app/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; export default function AccountSettingsPage() { return ( diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx similarity index 91% rename from frontend/src/app/(dashboard)/settings/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx index ccbdb3b..f1e7696 100644 --- a/frontend/src/app/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx similarity index 99% rename from frontend/src/app/(dashboard)/settings/subscriptions/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 7116c8c..9a2b670 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; import { Button } from '@/components/ui/shared/Button'; diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx similarity index 99% rename from frontend/src/app/(dashboard)/staff/page.tsx rename to frontend/src/app/[locale]/(dashboard)/staff/page.tsx index b305f65..03d5a96 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '@/i18n/navigation'; import { firstAccessibleDashboardPath, canEditStaff, diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx similarity index 97% rename from frontend/src/app/(dashboard)/today/page.tsx rename to frontend/src/app/[locale]/(dashboard)/today/page.tsx index 9e7ecf7..47c2b3e 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Card } from '@/components/ui/shared/Card'; diff --git a/frontend/src/app/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/treatment/page.tsx rename to frontend/src/app/[locale]/(dashboard)/treatment/page.tsx diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx similarity index 98% rename from frontend/src/app/(public)/accept-invite/page.tsx rename to frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 67f6fbb..c1bdc43 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { staffApi } from '@/lib/api/staff'; diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx similarity index 98% rename from frontend/src/app/(public)/accept-organization-invite/page.tsx rename to frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 782ea2f..6c87eb5 100644 --- a/frontend/src/app/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { Suspense, useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form'; import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx new file mode 100644 index 0000000..aaacf4c --- /dev/null +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useRouter } from '@/i18n/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { Mail, Lock } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; + +type LoginForm = { + email: string; + password: string; +}; + +export default function LoginPage() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { login, isLoading, user, isAuthReady } = useAuth(); + const router = useRouter(); + const [error, setError] = useState(null); + + const loginSchema = useMemo( + () => + z.object({ + email: z.string().email(tValidation('emailInvalid')), + password: z.string().min(1, tValidation('passwordRequired')), + }), + [tValidation], + ); + + useEffect(() => { + if (isAuthReady && user) { + router.push('/today'); + } + }, [user, isAuthReady, router]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginForm) => { + try { + setError(null); + await login(data.email, data.password); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('invalidCredentials'); + setError(message || t('invalidCredentials')); + } + }; + + if (!isAuthReady) { + return ( +
+

{tCommon('loading')}

+
+ ); + } + + return ( +
+
+ +
+ +
+ + {tCommon('appName')} + +

+ {t('signInTitle')} +

+

+ Or{' '} + + {t('startTrialLink')} + +

+
+ +
+
+
+ } + /> + } + /> + +
+
+ + +
+
+ + {t('forgotPassword')} + +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/[locale]/(public)/page.tsx similarity index 64% rename from frontend/src/app/(public)/page.tsx rename to frontend/src/app/[locale]/(public)/page.tsx index a654924..4ce81db 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/[locale]/(public)/page.tsx @@ -1,120 +1,112 @@ 'use client'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Button } from '@/components/ui/shared/Button'; -import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { + const t = useTranslations('landing'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); const { user } = useAuth(); return (
- - {/* Header */}
- DyoLink + {tCommon('appName')}
- + {user ? ( - + ) : ( <> - + - + )}
-
- {/* Hero Section */}
-
-

- Connect Dental Clinics & Labs - Seamlessly + {t('heroTitle')} + {t('heroHighlight')}

- Streamline communication between dental professionals. Start with - a 30-day free trial, no credit card required. + {t('heroSubtitle')}

{!user && ( )}
- {/* Features */}
} - title="For Clinics" - description="Manage patients, appointments, and send cases to labs instantly." + title={t('featureClinicsTitle')} + description={t('featureClinicsDescription')} /> } - title="For Labs" - description="Receive cases, track progress, and communicate with clinics." + title={t('featureLabsTitle')} + description={t('featureLabsDescription')} /> } - title="Team Management" - description="Add up to 5 team members during trial. Scale as you grow." + title={t('featureTeamTitle')} + description={t('featureTeamDescription')} /> } - title="30-Day Trial" - description="Full access to all features. No credit card required." + title={t('featureTrialTitle')} + description={t('featureTrialDescription')} /> } - title="Real-time Updates" - description="Get instant notifications on case status changes." + title={t('featureRealtimeTitle')} + description={t('featureRealtimeDescription')} /> } - title="Secure & Compliant" - description="HIPAA-compliant with enterprise-grade security." + title={t('featureSecurityTitle')} + description={t('featureSecurityDescription')} />
-
- {/* Footer */}
- -
© 2026 DyoLink. All rights reserved.
+
{t('footerCopyright')}
- Terms & Conditions + {t('termsAndConditions')} - Privacy Policy + {tAuth('privacyPolicy')}
-
@@ -132,19 +124,9 @@ function FeatureCard({ }) { return (
- -
- {icon} -
- -

- {title} -

- -

- {description} -

- +
{icon}
+

{title}

+

{description}

); -} \ No newline at end of file +} diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx new file mode 100644 index 0000000..2923e8d --- /dev/null +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { Mail, Lock, User } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; +import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; + +type RegisterForm = { + name: string; + email: string; + password: string; + confirmPassword: string; + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; + +export default function RegisterPage() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { registerTrial, isLoading } = useAuth(); + const [step, setStep] = useState(1); + const [error, setError] = useState(null); + + const registerSchema = useMemo( + () => + z + .object({ + name: z.string().min(2, tValidation('nameMinLength')), + email: z.string().email(tValidation('emailInvalid')), + password: z + .string() + .min(8, tValidation('passwordMinLength')) + .regex(/[A-Z]/, tValidation('passwordUppercase')) + .regex(/[0-9]/, tValidation('passwordNumber')), + confirmPassword: z.string(), + organizationName: z.string().min(2, tValidation('organizationNameMinLength')), + organizationEmail: z.string().email(tValidation('organizationEmailInvalid')), + organizationType: z.enum(['CLINIC', 'LAB'], { + message: tValidation('organizationTypeRequired'), + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: tValidation('passwordsDoNotMatch'), + path: ['confirmPassword'], + }), + [tValidation], + ); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + trigger, + setValue, + } = useForm({ + resolver: zodResolver(registerSchema), + mode: 'onChange', + }); + + const organizationType = watch('organizationType'); + + const handleNext = async () => { + const fieldsToValidate = + step === 1 + ? (['name', 'email', 'password', 'confirmPassword'] as const) + : (['organizationName', 'organizationEmail', 'organizationType'] as const); + + const isValid = await trigger([...fieldsToValidate]); + if (isValid) { + setStep(step + 1); + } + }; + + const onSubmit = async (data: RegisterForm) => { + try { + setError(null); + await registerTrial( + data.email, + data.password, + data.name, + data.organizationName, + data.organizationEmail, + data.organizationType, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('registrationFailed'); + setError(message || t('registrationFailed')); + } + }; + + return ( +
+
+ +
+ +
+ + {tCommon('appName')} + +

+ {t('registerTitle')} +

+

+ {t('registerPrompt')}{' '} + + {t('signInLink')} + +

+
+ +
+
+ +
+

{t('trialIncludes')}

+
    +
  • + {t('trialTeamMembers')} +
  • +
  • + {t('trialFullAccess')} +
  • +
  • + {t('trialNoCard')} +
  • +
+
+ +
+ {step === 1 && ( + <> + } + /> + } + /> + } + /> + } + /> + + + )} + + {step === 2 && ( + <> + + {error && ( +
+

{error}

+
+ )} +
+ + +
+ + )} + + +

+ By signing up, you agree to our{' '} + + {t('termsOfService')} + {' '} + and{' '} + + {t('privacyPolicy')} + +

+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/[locale]/(public)/select-organization/page.tsx similarity index 100% rename from frontend/src/app/(public)/select-organization/page.tsx rename to frontend/src/app/[locale]/(public)/select-organization/page.tsx diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..570d0ab --- /dev/null +++ b/frontend/src/app/[locale]/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, setRequestLocale } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { notFound } from 'next/navigation'; +import Script from 'next/script'; +import '@/styles/globals.css'; +import '@/styles/background-web.css'; +import { AuthProvider } from '@/lib/hooks/useAuth'; +import { THEME_STORAGE_KEY } from '@/lib/theme'; +import { routing, localeHtmlLang } from '@/i18n/routing'; +import { LocaleSync } from '@/components/i18n/LocaleSync'; + +export const metadata: Metadata = { + title: 'DyoLink - Dental Clinic & Lab Communication Hub', + description: 'Connect dental clinics and laboratories seamlessly', +}; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + + const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`; + + return ( + + + + + + + {children} + + + + + ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index eecf822..cd4a053 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,33 +1,7 @@ -// src/app/layout.tsx -import type { Metadata } from 'next'; -import Script from 'next/script'; -import '@/styles/globals.css'; -import '@/styles/background-web.css'; -import { AuthProvider } from '@/lib/hooks/useAuth'; -import { THEME_STORAGE_KEY } from '@/lib/theme'; - -export const metadata: Metadata = { - title: 'DyoLink - Dental Clinic & Lab Communication Hub', - description: 'Connect dental clinics and laboratories seamlessly', -}; - export default function RootLayout({ children, }: { children: React.ReactNode; }) { - const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`; - - return ( - - - - - {children} - - - - ); -} \ No newline at end of file + return children; +} diff --git a/frontend/src/components/i18n/LocaleSync.tsx b/frontend/src/components/i18n/LocaleSync.tsx new file mode 100644 index 0000000..cc59e66 --- /dev/null +++ b/frontend/src/components/i18n/LocaleSync.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useLocale } from 'next-intl'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import type { AppLocale } from '@/i18n/routing'; +import { isAppLocale } from '@/i18n/routing'; + +/** Redirect authenticated users to their saved profile language when it differs from the URL. */ +export function LocaleSync() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const { user, isAuthReady } = useAuth(); + + useEffect(() => { + if (!isAuthReady || !user?.language) return; + + const preferred = user.language; + if (!isAppLocale(preferred) || preferred === locale) return; + + router.replace(pathname, { locale: preferred as AppLocale }); + }, [isAuthReady, user?.language, locale, pathname, router]); + + return null; +} diff --git a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx index c9ee022..661d8a9 100644 --- a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx +++ b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx @@ -1,7 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { Settings, AlertTriangle, @@ -15,16 +16,21 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; import type { SubscriptionAlertData } from '@/types/subscription'; -function warningTooltip(data: SubscriptionAlertData | null): string { +function warningTooltip( + data: SubscriptionAlertData | null, + t: ReturnType>, +): string { if (!data?.showWarning) return ''; - if (data.noActiveSubscription) return 'No active subscription — review Subscriptions'; - if (data.trialExpired) return 'Trial ended — review Subscriptions'; - if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; - if (data.seatsLow) return 'Seats running low — review Subscriptions'; - return 'Review Subscriptions'; + if (data.noActiveSubscription) return t('noActiveSubscription'); + if (data.trialExpired) return t('trialEnded'); + if (data.trialEndingSoon) return t('trialEndingSoon'); + if (data.seatsLow) return t('seatsLow'); + return t('reviewSubscriptions'); } export function DashboardAccountMenu() { + const t = useTranslations('auth'); + const tAccount = useTranslations('accountMenu'); const { user, currentOrganization, logout } = useAuth(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -61,7 +67,7 @@ export function DashboardAccountMenu() { }, [isOwner, currentOrganization?.id]); const showWarning = Boolean(isOwner && alert?.showWarning); - const tooltip = useMemo(() => warningTooltip(alert), [alert]); + const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]); const handleLogout = useCallback(() => { setOpen(false); @@ -98,7 +104,7 @@ export function DashboardAccountMenu() { className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-[200] backdrop-blur-sm" >
-

Signed in

+

{t('signedIn')}

{user?.email}

{currentOrganization?.name} @@ -113,7 +119,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Switch organization + {t('switchOrganization')} {isOwner && ( @@ -124,7 +130,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Subscriptions + {t('subscriptions')} )} @@ -135,7 +141,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Account + {t('account')}

@@ -147,7 +153,7 @@ export function DashboardAccountMenu() { onClick={handleLogout} > - Log out + {t('signOut')}
diff --git a/frontend/src/components/ui/shared/LanguageToggle.tsx b/frontend/src/components/ui/shared/LanguageToggle.tsx new file mode 100644 index 0000000..db8a1a8 --- /dev/null +++ b/frontend/src/components/ui/shared/LanguageToggle.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Globe, Check } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import { locales, type AppLocale } from '@/i18n/routing'; + +const LOCALE_OPTIONS: AppLocale[] = [...locales]; + +export function LanguageToggle() { + const t = useTranslations('language'); + const locale = useLocale() as AppLocale; + const router = useRouter(); + const pathname = usePathname(); + const { user, setUserLanguage } = useAuth(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + const switchLocale = useCallback( + async (next: AppLocale) => { + if (next === locale) { + setOpen(false); + return; + } + + if (user) { + setUserLanguage(next); + try { + await authApi.updateLanguage(next); + } catch { + /* keep optimistic locale in client state */ + } + } + + router.replace(pathname, { locale: next }); + setOpen(false); + }, + [locale, pathname, router, setUserLanguage, user], + ); + + return ( +
+ + + {open && ( +
    + {LOCALE_OPTIONS.map((option) => { + const selected = option === locale; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 80bc7f8..ce64b41 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -1,8 +1,8 @@ 'use client'; -import Link from 'next/link'; import { memo, useMemo } from 'react'; -import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { Link, usePathname } from '@/i18n/navigation'; import { LayoutDashboard, Users, @@ -19,55 +19,46 @@ import { organizationTypeIcon, } from '@/components/shared/organizationTypeIcon'; -const menu = [ - { name: 'Dashboard', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, - { name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, - { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, - { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, - { name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const }, - { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, - { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, -]; - function Sidebar() { + const t = useTranslations('nav'); + const tCommon = useTranslations('common'); const pathname = usePathname(); const { currentOrganization } = useAuth(); - const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; - const organizationsTabIcon = organizationTypeIcon( - counterpartOrganizationType(currentOrganization?.type), + + const menu = useMemo( + () => [ + { name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, + { name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, + { + name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'), + path: '/organizations', + icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)), + read: 'TAB_ORGANIZATIONS_READ' as const, + }, + { name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, + { name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, + { name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const }, + { name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, + { name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, + ], + [currentOrganization?.type, t], ); const visibleMenu = useMemo( - () => { - const withCounterpartTab = [ - menu[0], - menu[1], - { - name: counterpartLabel, - path: '/organizations', - icon: organizationsTabIcon, - read: 'TAB_ORGANIZATIONS_READ' as const, - }, - menu[2], - menu[3], - menu[4], - menu[5], - menu[6], - ]; - return withCounterpartTab.filter((item) => { + () => + menu.filter((item) => { if (item.path === '/appointments') { return canAccessAppointmentsSection(currentOrganization); } return canViewTab(currentOrganization, item.read); - }); - }, - [counterpartLabel, organizationsTabIcon, currentOrganization], + }), + [currentOrganization, menu], ); return (