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/migrations/20260620120000_add_user_language/migration.sql b/backend/prisma/migrations/20260620120000_add_user_language/migration.sql new file mode 100644 index 0000000..7c369a4 --- /dev/null +++ b/backend/prisma/migrations/20260620120000_add_user_language/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8d3836b..ffe59bc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { googleId String? @unique facebookId String? @unique name String + language String @default("en") trialUsedAt DateTime? memberships Membership[] @@ -217,6 +218,7 @@ model Membership { permissions MembershipPermission[] invitations StaffInvitation[] + workingHoursSchedule StaffWorkingHoursSchedule? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -225,6 +227,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/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 1e68517..b490e9d 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -9,7 +9,8 @@ import { Res, HttpCode, HttpStatus, - Get + Get, + Patch, } from '@nestjs/common'; import type { Response } from 'express'; import { @@ -28,6 +29,7 @@ import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; +import { UpdateLanguageDto } from './dto/update-language.dto'; @ApiTags('auth') @Controller('auth') @@ -149,6 +151,14 @@ export class AuthController { return this.authService.getProfile(req.user.id); } + @Patch('profile/language') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update user language preference' }) + async updateLanguage(@Req() req, @Body() dto: UpdateLanguageDto) { + return this.authService.updateLanguage(req.user.id, dto); + } + @Get('subscription-alert') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 102e59c..17fcac4 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -15,6 +15,10 @@ import { PrismaService } from '../../../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; +import { + SUPPORTED_USER_LANGUAGES, + UpdateLanguageDto, +} from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ @@ -179,11 +183,7 @@ export class AuthService { data: { accessToken, refreshToken, - user: { - id: user.id, - email: user.email, - name: user.name, - }, + user: this.toPublicUser(user), organizations, }, }; @@ -343,11 +343,6 @@ export class AuthService { }; } - /** - * Get user profile with all memberships and permissions - * @param userId - User ID from JWT token - * @returns User profile with organizations and permissions - */ async getProfile(userId: string) { try { const user = await this.prisma.user.findUnique({ @@ -516,11 +511,7 @@ export class AuthService { success: true, data: { accessToken: newAccessToken, - user: { - id: session.user.id, - email: session.user.email, - name: session.user.name, - }, + user: this.toPublicUser(session.user), organizations, }, }; @@ -931,4 +922,44 @@ export class AuthService { }, }; } + + async updateLanguage(userId: string, dto: UpdateLanguageDto) { + const language = dto.language; + + if (!SUPPORTED_USER_LANGUAGES.includes(language)) { + throw new BadRequestException('Language must be one of: en, fa, nl'); + } + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { language }, + select: { + id: true, + email: true, + name: true, + language: true, + }, + }); + + return { + success: true, + data: { + user: this.toPublicUser(user), + }, + }; + } + + private toPublicUser(user: { + id: string; + email: string; + name: string; + language?: string | null; + }) { + return { + id: user.id, + email: user.email, + name: user.name, + language: user.language ?? 'en', + }; + } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/update-language.dto.ts b/backend/src/modules/auth/dto/update-language.dto.ts new file mode 100644 index 0000000..5bc91c1 --- /dev/null +++ b/backend/src/modules/auth/dto/update-language.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsString } from 'class-validator'; + +export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const; +export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number]; + +export class UpdateLanguageDto { + @ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' }) + @IsString() + @IsIn(SUPPORTED_USER_LANGUAGES, { + message: 'Language must be one of: en, fa, nl', + }) + language: SupportedUserLanguage; +} diff --git a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts new file mode 100644 index 0000000..68c508a --- /dev/null +++ b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts @@ -0,0 +1,44 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +export class WorkingHoursBlockDto { + @IsInt() + @Min(0) + @Max(6) + dayOfWeek: number; + + @IsInt() + @Min(0) + @Max(1439) + startMinute: number; + + @IsInt() + @Min(1) + @Max(1440) + endMinute: number; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class UpsertWorkingHoursDto { + @IsBoolean() + autoRepeatWeekly: boolean; + + @IsArray() + @ArrayMaxSize(42) + @ValidateNested({ each: true }) + @Type(() => WorkingHoursBlockDto) + blocks: WorkingHoursBlockDto[]; +} diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts new file mode 100644 index 0000000..d7568a8 --- /dev/null +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -0,0 +1,264 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { + appointmentWithinWorkingHours, + blocksForDay, + localDayOfWeekMondayZero, + validateWorkingHoursBlocks, + type WorkingHoursBlockInput, +} from '../../common/working-hours'; +import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; + +@Injectable() +export class StaffWorkingHoursService { + constructor(private readonly prisma: PrismaService) {} + + async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) { + await this.assertCanViewStaff(actorUserId, organizationId); + + const membership = await this.findMembership(membershipId, organizationId); + const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ + where: { membershipId: membership.id }, + include: { + blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, + }, + }); + + if (!schedule) { + return { + success: true, + data: { + autoRepeatWeekly: true, + blocks: [], + hasWorkingHours: false, + }, + }; + } + + return { + success: true, + data: { + autoRepeatWeekly: schedule.autoRepeatWeekly, + blocks: schedule.blocks.map((b) => ({ + dayOfWeek: b.dayOfWeek, + startMinute: b.startMinute, + endMinute: b.endMinute, + sortOrder: b.sortOrder, + })), + hasWorkingHours: schedule.blocks.length > 0, + }, + }; + } + + async upsertWorkingHours( + actorUserId: string, + organizationId: string, + membershipId: string, + dto: UpsertWorkingHoursDto, + ) { + await this.assertCanEditStaff(actorUserId, organizationId); + + const membership = await this.findMembership(membershipId, organizationId); + const validationError = validateWorkingHoursBlocks(dto.blocks); + if (validationError) { + throw new BadRequestException(validationError); + } + + const normalizedBlocks = this.normalizeBlocks(dto.blocks); + await this.assertNoConflictingAppointments( + organizationId, + membership.userId, + normalizedBlocks, + ); + + await this.prisma.$transaction(async (tx) => { + const schedule = await tx.staffWorkingHoursSchedule.upsert({ + where: { membershipId: membership.id }, + create: { + membershipId: membership.id, + autoRepeatWeekly: dto.autoRepeatWeekly, + }, + update: { + autoRepeatWeekly: dto.autoRepeatWeekly, + }, + }); + + await tx.staffWorkingHoursBlock.deleteMany({ where: { scheduleId: schedule.id } }); + + if (normalizedBlocks.length > 0) { + await tx.staffWorkingHoursBlock.createMany({ + data: normalizedBlocks.map((block, index) => ({ + scheduleId: schedule.id, + dayOfWeek: block.dayOfWeek, + startMinute: block.startMinute, + endMinute: block.endMinute, + sortOrder: block.sortOrder ?? index, + })), + }); + } + }); + + return { + success: true, + message: 'Working hours saved', + }; + } + + async loadScheduleBlocksByMembershipIds(membershipIds: string[]) { + if (membershipIds.length === 0) { + return new Map(); + } + + const schedules = await this.prisma.staffWorkingHoursSchedule.findMany({ + where: { membershipId: { in: membershipIds } }, + include: { + blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, + }, + }); + + const map = new Map(); + for (const schedule of schedules) { + map.set( + schedule.membershipId, + schedule.blocks.map((b) => ({ + dayOfWeek: b.dayOfWeek, + startMinute: b.startMinute, + endMinute: b.endMinute, + sortOrder: b.sortOrder, + })), + ); + } + return map; + } + + dayBlocksFromMembershipBlocks(blocks: WorkingHoursBlockInput[], dayOfWeekMondayZero: number) { + return blocksForDay(blocks, dayOfWeekMondayZero); + } + + private normalizeBlocks(blocks: UpsertWorkingHoursDto['blocks']): WorkingHoursBlockInput[] { + return blocks.map((block, index) => ({ + dayOfWeek: block.dayOfWeek, + startMinute: block.startMinute, + endMinute: block.endMinute, + sortOrder: block.sortOrder ?? index, + })); + } + + private async assertNoConflictingAppointments( + organizationId: string, + providerUserId: string, + blocks: WorkingHoursBlockInput[], + ) { + const now = new Date(); + const appointments = await this.prisma.appointment.findMany({ + where: { + organizationId, + providerUserId, + endAt: { gt: now }, + }, + include: { + patient: { select: { firstName: true, lastName: true } }, + }, + orderBy: { startAt: 'asc' }, + }); + + const conflicts = appointments.filter((appointment) => { + const startAt = new Date(appointment.startAt); + const endAt = new Date(appointment.endAt); + const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); + const dayBlocks = blocksForDay(blocks, dayOfWeek); + if (dayBlocks.length === 0) { + return true; + } + return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks); + }); + + if (conflicts.length === 0) { + return; + } + + const examples = conflicts.slice(0, 3).map((appointment) => { + const startAt = new Date(appointment.startAt); + const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`; + const when = startAt.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + return `${patientName} (${when})`; + }); + + const extra = + conflicts.length > examples.length + ? ` and ${conflicts.length - examples.length} more` + : ''; + + throw new BadRequestException( + `Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`, + ); + } + + private async findMembership(membershipId: string, organizationId: string) { + const membership = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + select: { id: true, isOwner: true, userId: true }, + }); + if (!membership) { + throw new NotFoundException('Member not found'); + } + if (membership.isOwner) { + throw new BadRequestException('Working hours cannot be set for the organization owner'); + } + return membership; + } + + private async assertCanViewStaff(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canViewStaff(actor)) { + throw new ForbiddenException('You do not have access to staff management'); + } + } + + private async assertCanEditStaff(userId: string, organizationId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot manage staff working hours'); + } + } + + private async getActorMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { userId, organizationId }, + include: { + permissions: { include: { permission: true } }, + organization: { select: { planId: true } }, + }, + }); + } + + private canViewStaff(m: { + isOwner: boolean; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return true; + return m.permissions.some( + (p) => p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT', + ); + } + + private canEditStaff(m: { + isOwner: boolean; + organization?: { planId: string | null }; + permissions: { permission: { name: string } }[]; + }): boolean { + if (m.isOwner) return Boolean(m.organization?.planId); + return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); + } +} diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index 8183f32..a6fc1a8 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, Query, Req, UseGuards, @@ -16,13 +17,18 @@ import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto'; import { InviteStaffDto } from './dto/invite-staff.dto'; import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto'; import { UpdateStaffMemberDto } from './dto/update-staff-member.dto'; +import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; import { StaffService } from './staff.service'; +import { StaffWorkingHoursService } from './staff-working-hours.service'; @ApiTags('staff') @ApiBearerAuth('JWT-auth') @Controller('staff') export class StaffController { - constructor(private readonly staffService: StaffService) {} + constructor( + private readonly staffService: StaffService, + private readonly staffWorkingHoursService: StaffWorkingHoursService, + ) {} @Get('invitations/preview') @ApiOperation({ summary: 'Preview invite info by token (public)' }) @@ -80,6 +86,38 @@ export class StaffController { return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto); } + @Get('members/:membershipId/working-hours') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get weekly working hours for a staff member' }) + getWorkingHours( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffWorkingHoursService.getWorkingHours( + req.user.id, + organizationId, + membershipId, + ); + } + + @Put('members/:membershipId/working-hours') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Save weekly working hours for a staff member' }) + upsertWorkingHours( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + @Body() dto: UpsertWorkingHoursDto, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffWorkingHoursService.upsertWorkingHours( + req.user.id, + organizationId, + membershipId, + dto, + ); + } + @Patch('members/:membershipId/enable') @UseGuards(JwtAuthGuard) @ApiOperation({ diff --git a/backend/src/modules/staff/staff.module.ts b/backend/src/modules/staff/staff.module.ts index c297ea0..0fefc9c 100644 --- a/backend/src/modules/staff/staff.module.ts +++ b/backend/src/modules/staff/staff.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { StaffController } from './staff.controller'; import { StaffService } from './staff.service'; +import { StaffWorkingHoursService } from './staff-working-hours.service'; @Module({ controllers: [StaffController], - providers: [StaffService, PrismaService], + providers: [StaffService, StaffWorkingHoursService, PrismaService], + exports: [StaffWorkingHoursService], }) export class StaffModule {} diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 0000000..f58cb41 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,566 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "Loading...", + "loadingApp": "Loading app...", + "loadingWorkspace": "Loading workspace...", + "continue": "Continue", + "back": "Back", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "next": "Next", + "dismiss": "Dismiss", + "or": "Or", + "and": "and", + "close": "Close", + "redirecting": "Redirecting…", + "readOnlyAccess": "Read-only access for this organization.", + "errorGeneric": "Something went wrong", + "loadingEllipsis": "Loading...", + "search": "Search", + "action": "Action", + "status": "Status", + "name": "Name", + "email": "Email", + "date": "Date", + "organization": "Organization", + "backToApp": "← Back to app", + "copied": "Copied", + "copyLink": "Copy link", + "none": "None", + "preview": "Preview" + }, + "language": { + "label": "Language", + "selectLanguage": "Select language", + "en": "English", + "fa": "Persian", + "nl": "Dutch" + }, + "theme": { + "switchToLight": "Switch to light mode", + "switchToDark": "Switch to dark mode", + "lightMode": "Light mode", + "darkMode": "Dark mode" + }, + "nav": { + "dashboard": "Dashboard", + "staff": "Staff", + "patients": "Patients", + "appointment": "Appointment", + "treatment": "Treatment", + "billing": "Billing", + "reports": "Reports", + "clinics": "Clinics", + "labs": "Labs" + }, + "auth": { + "login": "Login", + "signIn": "Sign in", + "signOut": "Log out", + "register": "Register", + "startTrial": "Start Trial", + "startFreeTrial": "Start Free Trial", + "dashboard": "Dashboard", + "signInTitle": "Sign in to your account", + "signInPrompt": "Or {link}", + "startTrialLink": "start your free trial", + "registerTitle": "Start your 30-day free trial", + "registerPrompt": "Already have an account?", + "signInLink": "Sign in", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "fullName": "Full name", + "rememberMe": "Remember me", + "forgotPassword": "Forgot your password?", + "invalidCredentials": "Invalid email or password", + "loginFailed": "Login failed", + "registrationFailed": "Registration failed. Please try again.", + "startMyFreeTrial": "Start my free trial", + "trialIncludes": "Your trial includes:", + "trialTeamMembers": "Up to 5 team members", + "trialFullAccess": "Full access to all features", + "trialNoCard": "30 days free, no credit card required", + "termsAgreement": "By signing up, you agree to our {terms} and {privacy}", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "signedIn": "Signed in", + "switchOrganization": "Switch organization", + "subscriptions": "Subscriptions", + "account": "Account", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "By signing up, you agree to our", + "errorRegistrationFailed": "Registration failed", + "errorLoginFailed": "Login failed", + "errorCreateOrganization": "Failed to create organization", + "acceptInviteTitle": "Accept invitation", + "loadingInvitation": "Loading invitation...", + "invalidInvitationLink": "Invalid invitation link", + "invitationAlreadyAccepted": "This invitation is already accepted. You can log in now.", + "errorLoadInvitation": "Could not load invitation", + "organizationLabel": "Organization:", + "emailLabel": "Email:", + "nameRequired": "Name is required", + "passwordMinLength8": "Password must be at least 8 characters", + "passwordsDoNotMatch": "Passwords do not match", + "labelName": "Name", + "labelCreatePassword": "Create password", + "labelConfirmPassword": "Confirm password", + "activateAccount": "Activate account", + "invitationAcceptedRedirect": "Invitation Accepted. Redirecting to login...", + "errorAcceptInvitation": "Could not accept invitation", + "alreadyHaveAccess": "Already have access?", + "goToLogin": "Go to login", + "acceptOrganizationTitle": "Accept organization invitation", + "alreadyHaveAccount": "Already have an account?", + "invitedBy": "Invited by:", + "ownerEmail": "Owner email", + "activateOrganization": "Activate organization", + "organizationAcceptedRedirect": "Invitation accepted. Redirecting to login...", + "stepAccount": "Account", + "stepOrganization": "Organization", + "organizationName": "Organization name", + "organizationNamePlaceholder": "Sunshine Dental Clinic", + "organizationEmail": "Organization email", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "Organization type", + "dentalClinic": "Dental Clinic", + "dentalLab": "Dental Lab" + }, + "landing": { + "heroTitle": "Connect Dental Clinics & Labs", + "heroHighlight": "Seamlessly", + "heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.", + "featureClinicsTitle": "For Clinics", + "featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.", + "featureLabsTitle": "For Labs", + "featureLabsDescription": "Receive cases, track progress, and communicate with clinics.", + "featureTeamTitle": "Team Management", + "featureTeamDescription": "Add up to 5 team members during trial. Scale as you grow.", + "featureTrialTitle": "30-Day Trial", + "featureTrialDescription": "Full access to all features. No credit card required.", + "featureRealtimeTitle": "Real-time Updates", + "featureRealtimeDescription": "Get instant notifications on case status changes.", + "featureSecurityTitle": "Secure & Compliant", + "featureSecurityDescription": "HIPAA-compliant with enterprise-grade security.", + "footerCopyright": "© 2026 DyoLink. All rights reserved.", + "termsAndConditions": "Terms & Conditions" + }, + "accountMenu": { + "noActiveSubscription": "No active subscription — review Subscriptions", + "trialEnded": "Trial ended — review Subscriptions", + "trialEndingSoon": "Trial ending soon — review Subscriptions", + "seatsLow": "Seats running low — review Subscriptions", + "reviewSubscriptions": "Review Subscriptions" + }, + "validation": { + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "nameMinLength": "Name must be at least 2 characters", + "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must contain at least one uppercase letter", + "passwordNumber": "Password must contain at least one number", + "organizationNameMinLength": "Organization name must be at least 2 characters", + "organizationEmailInvalid": "Please enter a valid organization email", + "organizationTypeRequired": "Please select organization type", + "passwordsDoNotMatch": "Passwords don't match" + }, + "today": { + "welcomeBack": "Welcome back!!", + "noSubscriptionNotice": "This organization does not have an active subscription yet.", + "choosePlanLink": "Choose a plan", + "noSubscriptionCta": "to start the purchase process.", + "cardTodaysAppointments": "Today's Appointments", + "cardActivePatients": "Active Patients", + "cardNewLabCase": "New Lab Case", + "cardTodayInvoices": "Today invoices" + }, + "staff": { + "redirecting": "Redirecting…", + "title": "Staff Management", + "subtitle": "Invite teammates, set tab access, and stay within your plan seat limit.", + "inviteMember": "Invite member", + "seatsLabel": "Seats:", + "unlimitedPlan": "(unlimited plan)", + "seatLimitReached": "Plan seat limit reached for this organization.", + "noActivePlan": "No active plan selected for this organization. Choose a subscription plan to invite members.", + "invitedPending": "Invitation is pending until they open the link, set a password, and log in.", + "invitedAccepted": "Invitation was accepted immediately.", + "inviteLinkHeading": "Invite link", + "shareLinkHint": "Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.", + "loadingTeam": "Loading team…", + "tableName": "Name", + "tableEmail": "Email", + "tableRole": "Role", + "tableStatus": "Status", + "tableAccess": "Access", + "tableAction": "Action", + "roleOwner": "Owner", + "roleStaff": "Staff", + "statusActive": "Active", + "statusPending": "Pending", + "statusDisabled": "Disabled", + "statusExpired": "Expired", + "allFeatures": "All features", + "inviteModalTitle": "Invite team member", + "stepOf": "Step {step} of 2", + "permissionView": "View", + "permissionEdit": "Edit", + "labelEmail": "Email", + "labelDisplayName": "Display name", + "tabAccess": "Tab access", + "sendInvite": "Send invite", + "skipForNow": "Skip for now", + "enableModalTitle": "Enable team member", + "enableConfirm": "Enable {name} ({email})?", + "enableBullet1": "They can sign in to this organization again with their existing account.", + "enableBullet2": "No new invitation is sent and no data was removed while they were disabled.", + "enableBullet3": "Enabling uses one seat on your plan.", + "noSeatsAvailable": "No seats are available. Disable another member or upgrade your plan before enabling this person.", + "enableMemberButton": "Enable member", + "disableModalTitle": "Disable team member", + "disableConfirm": "Disable {name} ({email})?", + "disableBullet1": "They will not be able to sign in to this organization.", + "disableBullet2": "No data will be removed.", + "disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.", + "disableMemberButton": "Disable member", + "editModalTitle": "Edit member", + "loadingWorkingHours": "Loading working hours…", + "errorLoadStaff": "Failed to load staff.", + "errorCopyInvite": "Could not copy invitation link.", + "errorSendInvite": "Failed to send invitation.", + "errorLoadWorkingHours": "Failed to load working hours.", + "successMemberUpdated": "Member updated.", + "errorUpdateMember": "Failed to update member.", + "errorDeleteNotImplemented": "Delete is not implemented yet.", + "successMemberDisabled": "{name} was disabled. A seat is now available.", + "errorDisableMember": "Failed to disable member.", + "successMemberEnabled": "{name} was enabled and can sign in again.", + "errorEnableMember": "Failed to enable member.", + "successInvited": "{name} ({email}) was invited.", + "copyInviteLink": "Copy invitation link", + "copyInviteLinkTitle": "Copy invitation link (generates a new link if needed)", + "enableMemberAria": "Enable member", + "enableMemberTitle": "Enable member (uses a seat)", + "disableMemberAria": "Disable member", + "disableMemberTitle": "Disable member (frees a seat)", + "editMemberAria": "Edit member", + "deleteMemberAria": "Delete member", + "deleteMemberTitle": "Delete member (not implemented)", + "features": { + "featureToday": "Today", + "featureStaff": "Staff", + "featureOrganizations": "Organizations", + "featureClinics": "Clinics", + "featureLabs": "Labs", + "featurePatients": "Patients", + "featureAppointment": "Appointment", + "featureTreatment": "Treatment", + "featureBilling": "Billing", + "featureReports": "Reports", + "noTabAccess": "No tab access", + "readOnlySuffix": "(Read only)" + }, + "workingHours": { + "recommendedTitle": "Working hours recommended", + "recommendedBody": "Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.", + "intro": "Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.", + "workingDay": "Working day", + "start": "Start", + "end": "End", + "removeShift": "Remove shift", + "addShift": "Add shift", + "autoRepeatWeekly": "Repeat these hours at the start of each week (copy forward on Monday)", + "weekdayMon": "Mon", + "weekdayTue": "Tue", + "weekdayWed": "Wed", + "weekdayThu": "Thu", + "weekdayFri": "Fri", + "weekdaySat": "Sat", + "weekdaySun": "Sun", + "validationNeedsShift": "{day} needs at least one shift or should be marked off.", + "validationEndAfterStart": "{day} shift end time must be after start time.", + "validationOverlap": "{day} shifts cannot overlap." + } + }, + "patients": { + "title": "Patients", + "newPatient": "New Patient", + "errorLoadPatients": "Failed to load patients.", + "successPatientSaved": "Patient {firstName} {lastName} was saved successfully.", + "errorSavePatient": "Failed to save patient.", + "firstName": "First name", + "lastName": "Last name", + "phone": "Phone", + "savePatient": "Save Patient", + "dialogTitle": "New patient", + "searchPlaceholder": "Search patients by name, phone, email", + "loadingPatients": "Loading patients...", + "noResults": "No patients found for this search.", + "noContact": "No contact", + "selectPatient": "Select a patient to view details.", + "phoneLabel": "Phone:", + "emailLabel": "Email:", + "statusLabel": "Status:", + "statusActive": "Active", + "statusInactive": "Inactive", + "emptyValue": "-" + }, + "appointments": { + "title": "Appointments", + "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", + "loadingSchedule": "Loading schedule…", + "infoPastViewOnly": "Past appointments are view-only.", + "infoSelectPatient": "Select a patient before booking.", + "errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.", + "successUpdated": "Appointment updated.", + "successSaved": "Appointment saved.", + "errorUpdate": "Could not update appointment.", + "errorSave": "Could not save appointment.", + "confirmRemove": "Remove this appointment?", + "successRemoved": "Appointment removed.", + "errorDelete": "Could not delete appointment.", + "errorLoadSchedule": "Failed to load schedule.", + "successPatientSaved": "Patient {firstName} {lastName} was saved.", + "searchPlaceholder": "Search existing patients", + "searching": "Searching…", + "searchHint": "Type to search patients by name, phone, or email.", + "noPermissionAdd": "You do not have permission to add patients.", + "editTitle": "Edit appointment", + "newTitle": "New appointment", + "providerLabel": "Provider:", + "patientLabel": "Patient", + "startLabel": "Start", + "endLabel": "End", + "purposeLabel": "Purpose", + "errorSelectPatient": "Select a patient first.", + "errorEndAfterStart": "End time must be after start time.", + "errorPastSchedule": "Cannot schedule in the past.", + "errorPastViewOnly": "Past appointments are view-only.", + "errorMissingDetails": "Missing appointment details.", + "noProviders": "No providers available. Add staff with treatment edit access to see columns here.", + "noWorkingHours": "No working hours are configured for this day. Set provider working hours in Staff management.", + "noHoursSet": "No hours set", + "offToday": "Off today", + "slotOffToday": "Provider is off today", + "slotHoursNotConfigured": "Working hours not configured", + "slotOutsideHours": "Outside working hours", + "slotCannotCreate": "You cannot create appointments", + "slotBookAt": "Book {time}", + "outsideHoursBlocked": "Outside working hours — editing blocked", + "overlappingChoose": "{count} overlapping — click to choose", + "overlapping": "{count} overlapping", + "legend": "Legend", + "overlappingTitle": "Overlapping appointments ({count})", + "purposeConsultation": "Consultation", + "purposeFilling": "Filling", + "purposeEndo": "Endo", + "purposeVisit": "Visit", + "purposeHygiene": "Hygiene" + }, + "treatment": { + "loading": "Loading…", + "noPermissionTitle": "Treatment workspace", + "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", + "title": "Treatment", + "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", + "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", + "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", + "selectedPatient": "Selected patient", + "purposeLabel": "Purpose:", + "loadingAppointments": "Loading appointments…", + "selectDayWithAppointment": "Select a day with at least one appointment.", + "confirmDiscard": "You have unsaved changes. Discard them and continue?", + "successDraftSaved": "Treatment draft saved.", + "errorChooseOrg": "Choose at least one active organization to send this case.", + "successCaseSent": "Case sent to selected organizations.", + "successFilesUploaded": "{count} file(s) uploaded successfully.", + "errorLoadAppointments": "Failed to load appointments.", + "errorLoadOrgs": "Failed to load linked organizations.", + "errorLoadHistory": "Failed to load treatment history.", + "errorLoadDraft": "Failed to load treatment draft.", + "errorUpload": "Failed to upload attachments.", + "errorSaveDraft": "Failed to save treatment draft.", + "errorSendCase": "Failed to send case.", + "errorCaseMustSave": "Case must be saved before sending.", + "draftTitle": "Draft · {patientName}", + "hiddenMessage": "Appointments are hidden.", + "showAppointments": "Show appointments", + "appointmentsTitle": "My appointments", + "hideAppointments": "Hide appointments", + "emptyDay": "No appointments assigned to you on this day.", + "casesTitle": "Treatment cases", + "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", + "addCase": "Add case", + "caseLabel": "Case {n}", + "comments": "Comments", + "commentsPlaceholder": "Write clinical notes for this case…", + "treatmentType": "Treatment type", + "typeConsultation": "consultation", + "typeFilling": "filling", + "typeEndo": "endo", + "typeVisit": "visit", + "typeHygiene": "hygiene", + "attachments": "Attachments", + "attachFiles": "Attach files for this treatment case", + "chooseFiles": "Choose files", + "sendToOrgs": "Send this case to linked organizations", + "searchOrgsPlaceholder": "Search active organizations...", + "recent": "Recent:", + "noOrgMatch": "No active organization matches your search.", + "sendThisCase": "Send this case", + "saveDraft": "Save treatment draft", + "unsavedChanges": "Unsaved changes", + "draftSaved": "Draft saved", + "sendSavesFirst": "Sending is per case and saves first automatically.", + "historyTitle": "Previous treatments", + "historySubtitle": "Completed treatments for this patient. Each case is listed separately.", + "loadingHistory": "Loading history…", + "historyEmpty": "No prior treatments for this patient.", + "statusLabel": "Status:", + "historyCaseLabel": "Case {n} · {type}", + "teethLabel": "Teeth:", + "teethNone": "None selected", + "reviewDetails": "Review details", + "previewTitle": "Treatment preview", + "previewDraft": "Preview current draft", + "selectAppointment": "Select an appointment to preview its draft.", + "caseCount": "{n} case(s)", + "attachmentCount": "{n} attachment(s)", + "caseSummary": "Case {n}: {type}", + "teethPrefix": "· Teeth", + "moreCases": "+ {n} more case(s)", + "previewDialogTitle": "Treatment preview", + "previewDialogSubtitle": "Review cases, attachments, and send destinations.", + "noCases": "No cases in this treatment.", + "typeLabel": "Type:", + "commentsLabel": "Comments:", + "commentsEmpty": "Comments: —", + "attachFilesShort": "Attach files", + "sendCase": "Send this case", + "sendToLinkedOrgs": "Send to linked organizations", + "noActiveOrgs": "No active linked organizations.", + "confirmSend": "Confirm send", + "toothChartTitle": "FDI tooth chart", + "toothChartHint": "Tap teeth to multi-select. Applies to the active case.", + "selectedLabel": "Selected:", + "selectedEmpty": "—", + "upperArch": "Upper arch", + "lowerArch": "Lower arch", + "toothAria": "FDI tooth {fdi}", + "toothSelectedSuffix": ", selected", + "sentToAt": "Sent to {orgName} at {datetime}", + "fallbackOrgName": "organization" + }, + "organizations": { + "loadingOrganization": "Loading organization...", + "subtitle": "Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.", + "invitationHistory": "Invitation History", + "searchPlaceholder": "Search {counterpart} by name, email, or phone...", + "backToList": "Back to list", + "tableOrganization": "Organization", + "tableOwnerEmail": "Owner email", + "tableDate": "Date", + "tableStatus": "Status", + "tableAction": "Action", + "emptyConnections": "No connections yet. Search to send a connection request or an invitation link.", + "statusInvitationPending": "Invitation pending", + "statusConnectionPending": "Connection request pending", + "statusConnected": "Connected", + "statusDeclined": "Connection request declined", + "statusFound": "Found", + "statusToday": "Today", + "acceptRequest": "Accept connection request", + "declineRequest": "Decline connection request", + "removeConnection": "Remove connection", + "sendRequest": "Send connection request", + "noDirectoryResults": "No organization found in directory search.", + "hideInvitationFields": "Hide invitation fields", + "sendInvitationLink": "Send invitation link", + "counterpartNameLabel": "{counterpart} name", + "ownerEmailLabel": "Owner email", + "sendInvitation": "Send invitation", + "successConnectionSent": "{counterpart} connection request sent.", + "successInviteCreated": "Invitation link created for {email}", + "successLinkCopied": "Invitation link copied to clipboard.", + "successAccepted": "Connection request accepted.", + "successDeclined": "Connection request declined.", + "successRemoved": "Connection removed.", + "historyTitle": "Invitation History", + "loadingHistory": "Loading invitation history...", + "historyEmpty": "No invitations yet.", + "tableInvitationLink": "Invitation link", + "statusPending": "Invitation pending", + "statusAccepted": "Invitation accepted", + "statusRejected": "Invitation rejected", + "statusExpired": "Invitation expired", + "copyInvitationLink": "Copy invitation link", + "copyInvitationLinkTitle": "Copy invitation link (generates a new link if needed)", + "selectorTitle": "Organizations", + "selectorSubtitleWithCreate": "Select an organization to continue, or create a new one.", + "selectorSubtitleSelectOnly": "Select an organization to continue.", + "createOrganization": "Create Organization", + "createAndContinue": "Create and Continue", + "emptyCanCreate": "No organizations found. Create your first one to continue.", + "emptyAskOwner": "No organizations found. Ask an organization owner to invite you.", + "continueArrow": "Continue →", + "planLabel": "Plan: {name} • {maxUsers} users", + "counterpartClinic": "Clinic", + "counterpartLab": "Lab" + }, + "settings": { + "accountTitle": "Account", + "accountSubtitle": "Profile and security settings for your login.", + "accountPlaceholder": "Password change and profile editing will be wired here next (e.g. invite flow, reset password).", + "subscriptionsTitle": "Subscriptions", + "subscriptionsSubtitle": "Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.", + "noSubscriptionNotice": "This organization has no active subscription. Select a plan below to start the purchase process.", + "currentPlan": "Current plan", + "planPrice": "Plan price", + "seatsUsed": "Seats used", + "seatsRemaining": "Seats remaining", + "daysRemaining": "Days remaining", + "unlimited": "Unlimited", + "unlimitedSeats": "Unlimited seats", + "seatsCount": "{n} seats", + "pricePerMonth": "${price} / month", + "noActiveSubscription": "No active subscription for this organization.", + "trialEnded": "Trial period has ended. Choose a plan when checkout is available.", + "trialEndsIn": "Trial ends in {days} day(s).", + "seatsLow": "Seat usage is high for this organization.", + "choosePlanIntro": "Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.", + "planSolo": "Solo", + "planSmall": "Small", + "planMedium": "Medium", + "planLarge": "Large", + "planEnterprise": "Enterprise", + "startPurchase": "Start purchase process", + "purchaseNotice": "Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup." + }, + "schedule": { + "defaultLabel": "Schedule date", + "previousDay": "Previous day", + "nextDay": "Next day", + "chooseDate": "Choose schedule date", + "year": "Year", + "month": "Month", + "day": "Day", + "monthJanuary": "January", + "monthFebruary": "February", + "monthMarch": "March", + "monthApril": "April", + "monthMay": "May", + "monthJune": "June", + "monthJuly": "July", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "October", + "monthNovember": "November", + "monthDecember": "December" + } +} diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json new file mode 100644 index 0000000..e87d189 --- /dev/null +++ b/frontend/messages/fa.json @@ -0,0 +1,566 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "در حال بارگذاری...", + "loadingApp": "در حال بارگذاری برنامه...", + "loadingWorkspace": "در حال بارگذاری فضای کاری...", + "continue": "ادامه", + "back": "بازگشت", + "save": "ذخیره", + "cancel": "لغو", + "delete": "حذف", + "edit": "ویرایش", + "next": "بعدی", + "dismiss": "رد کردن", + "or": "یا", + "and": "و", + "close": "بستن", + "redirecting": "در حال انتقال...", + "readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.", + "errorGeneric": "خطایی رخ داده است", + "loadingEllipsis": "در حال بارگذاری...", + "search": "جستجو", + "action": "عملیات", + "status": "وضعیت", + "name": "نام", + "email": "ایمیل", + "date": "تاریخ", + "organization": "سازمان", + "backToApp": "← بازگشت به برنامه", + "copied": "کپی شد", + "copyLink": "کپی لینک", + "none": "هیچکدام", + "preview": "پیش‌نمایش" + }, + "language": { + "label": "زبان", + "selectLanguage": "انتخاب زبان", + "en": "انگلیسی", + "fa": "فارسی", + "nl": "هلندی" + }, + "theme": { + "switchToLight": "تغییر به حالت روشن", + "switchToDark": "تغییر به حالت تیره", + "lightMode": "حالت روشن", + "darkMode": "حالت تیره" + }, + "nav": { + "dashboard": "داشبورد", + "staff": "کارکنان", + "patients": "بیماران", + "appointment": "نوبت‌ها", + "treatment": "درمان", + "billing": "صورتحساب", + "reports": "گزارش‌ها", + "clinics": "کلینیک‌ها", + "labs": "لابراتوارها" + }, + "auth": { + "login": "ورود", + "signIn": "ورود به حساب", + "signOut": "خروج", + "register": "ثبت‌نام", + "startTrial": "شروع دوره آزمایشی", + "startFreeTrial": "شروع دوره آزمایشی رایگان", + "dashboard": "داشبورد", + "signInTitle": "به حساب کاربری خود وارد شوید", + "signInPrompt": "یا {link}", + "startTrialLink": "دوره آزمایشی رایگان خود را شروع کنید", + "registerTitle": "دوره آزمایشی رایگان ۳۰ روزه خود را آغاز کنید", + "registerPrompt": "از قبل حساب کاربری دارید؟", + "signInLink": "وارد شوید", + "email": "آدرس ایمیل", + "password": "رمز عبور", + "confirmPassword": "تأیید رمز عبور", + "fullName": "نام و نام خانوادگی", + "rememberMe": "مرا به خاطر بسپار", + "forgotPassword": "رمز عبور خود را فراموش کرده‌اید؟", + "invalidCredentials": "ایمیل یا رمز عبور نامعتبر است", + "loginFailed": "ورود ناموفق بود", + "registrationFailed": "ثبت‌نام ناموفق بود. لطفاً دوباره تلاش کنید.", + "startMyFreeTrial": "دوره آزمایشی رایگان من را شروع کن", + "trialIncludes": "دوره آزمایشی شما شامل موارد زیر است:", + "trialTeamMembers": "تا ۵ عضو تیم", + "trialFullAccess": "دسترسی کامل به تمام امکانات", + "trialNoCard": "۳۰ روز رایگان، بدون نیاز به کارت اعتباری", + "termsAgreement": "با ثبت‌نام، شما با {terms} و {privacy} ما موافقت می‌کنید", + "termsOfService": "شرایط استفاده از خدمات", + "privacyPolicy": "سیاست حفظ حریم خصوصی", + "signedIn": "وارد شده‌اید", + "switchOrganization": "تغییر سازمان", + "subscriptions": "اشتراک‌ها", + "account": "حساب کاربری", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "با ثبت‌نام، شما با", + "errorRegistrationFailed": "ثبت‌نام ناموفق بود", + "errorLoginFailed": "ورود ناموفق بود", + "errorCreateOrganization": "ایجاد سازمان ناموفق بود", + "acceptInviteTitle": "پذیرش دعوتنامه", + "loadingInvitation": "در حال بارگذاری دعوتنامه...", + "invalidInvitationLink": "لینک دعوتنامه نامعتبر است", + "invitationAlreadyAccepted": "این دعوتنامه قبلاً پذیرفته شده است. اکنون می‌توانید وارد شوید.", + "errorLoadInvitation": "بارگذاری دعوتنامه امکان‌پذیر نبود", + "organizationLabel": "سازمان:", + "emailLabel": "ایمیل:", + "nameRequired": "نام الزامی است", + "passwordMinLength8": "رمز عبور باید حداقل ۸ کاراکتر باشد", + "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند", + "labelName": "نام", + "labelCreatePassword": "ایجاد رمز عبور", + "labelConfirmPassword": "تأیید رمز عبور", + "activateAccount": "فعال‌سازی حساب", + "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", + "alreadyHaveAccess": "از قبل دسترسی دارید؟", + "goToLogin": "رفتن به ورود", + "acceptOrganizationTitle": "پذیرش دعوتنامه سازمان", + "alreadyHaveAccount": "از قبل حساب کاربری دارید؟", + "invitedBy": "دعوت‌کننده:", + "ownerEmail": "ایمیل مالک", + "activateOrganization": "فعال‌سازی سازمان", + "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "stepAccount": "حساب", + "stepOrganization": "سازمان", + "organizationName": "نام سازمان", + "organizationNamePlaceholder": "کلینیک دندانپزشکی خورشید", + "organizationEmail": "ایمیل سازمان", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "نوع سازمان", + "dentalClinic": "کلینیک دندانپزشکی", + "dentalLab": "لابراتوار دندانپزشکی" + }, + "landing": { + "heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی", + "heroHighlight": "به‌صورت یکپارچه", + "heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.", + "featureClinicsTitle": "برای کلینیک‌ها", + "featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.", + "featureLabsTitle": "برای لابراتوارها", + "featureLabsDescription": "پرونده‌ها را دریافت کنید، روند پیشرفت را پیگیری کنید و با کلینیک‌ها در ارتباط باشید.", + "featureTeamTitle": "مدیریت تیم", + "featureTeamDescription": "در طول دوره آزمایشی تا ۵ عضو تیم اضافه کنید. همگام با رشد خود مقیاس‌پذیر باشید.", + "featureTrialTitle": "دوره آزمایشی ۳۰ روزه", + "featureTrialDescription": "دسترسی کامل به تمامی امکانات. بدون نیاز به کارت اعتباری.", + "featureRealtimeTitle": "به‌روزرسانی‌های لحظه‌ای", + "featureRealtimeDescription": "اعلان‌های فوری درباره تغییر وضعیت پرونده‌ها دریافت کنید.", + "featureSecurityTitle": "امن و منطبق با استانداردها", + "featureSecurityDescription": "منطبق با HIPAA و دارای امنیت در سطح سازمانی.", + "footerCopyright": "© ۲۰۲۶ DyoLink. تمامی حقوق محفوظ است.", + "termsAndConditions": "شرایط و ضوابط" + }, + "accountMenu": { + "noActiveSubscription": "اشتراک فعالی وجود ندارد — اشتراک‌ها را بررسی کنید", + "trialEnded": "دوره آزمایشی به پایان رسیده است — اشتراک‌ها را بررسی کنید", + "trialEndingSoon": "دوره آزمایشی به‌زودی به پایان می‌رسد — اشتراک‌ها را بررسی کنید", + "seatsLow": "ظرفیت کاربران رو به اتمام است — اشتراک‌ها را بررسی کنید", + "reviewSubscriptions": "بررسی اشتراک‌ها" + }, + "validation": { + "emailInvalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید", + "passwordRequired": "رمز عبور الزامی است", + "nameMinLength": "نام باید حداقل ۲ کاراکتر باشد", + "passwordMinLength": "رمز عبور باید حداقل ۸ کاراکتر باشد", + "passwordUppercase": "رمز عبور باید حداقل شامل یک حرف بزرگ باشد", + "passwordNumber": "رمز عبور باید حداقل شامل یک عدد باشد", + "organizationNameMinLength": "نام سازمان باید حداقل ۲ کاراکتر باشد", + "organizationEmailInvalid": "لطفاً یک ایمیل سازمانی معتبر وارد کنید", + "organizationTypeRequired": "لطفاً نوع سازمان را انتخاب کنید", + "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند" + }, + "today": { + "welcomeBack": "خوش آمدید!!", + "noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.", + "choosePlanLink": "انتخاب طرح", + "noSubscriptionCta": "برای شروع فرآیند خرید.", + "cardTodaysAppointments": "نوبت‌های امروز", + "cardActivePatients": "بیماران فعال", + "cardNewLabCase": "پرونده جدید لابراتوار", + "cardTodayInvoices": "صورتحساب‌های امروز" + }, + "staff": { + "redirecting": "در حال انتقال...", + "title": "مدیریت کارکنان", + "subtitle": "از همکاران خود دعوت کنید، دسترسی به برگه‌ها را تنظیم کنید و در محدوده طرح خود باقی بمانید.", + "inviteMember": "دعوت از عضو", + "seatsLabel": "مجوزها:", + "unlimitedPlan": "(طرح نامحدود)", + "seatLimitReached": "ظرفیت مجوزهای طرح برای این سازمان تکمیل شده است.", + "noActivePlan": "هیچ طرح فعالی برای این سازمان انتخاب نشده است. برای دعوت از اعضا، یک طرح اشتراک انتخاب کنید.", + "invitedPending": "دعوتنامه تا زمانی که لینک را باز کنند، رمز عبور تنظیم کنند و وارد شوند، در حالت انتظار است.", + "invitedAccepted": "دعوتنامه بلافاصله پذیرفته شد.", + "inviteLinkHeading": "لینک دعوت", + "shareLinkHint": "این لینک را به صورت دستی از طریق پیامک یا ایمیل به اشتراک بگذارید. در صورت منقضی شدن یا گم شدن لینک قبلی، لینک جدیدی تولید می‌شود.", + "loadingTeam": "در حال بارگذاری تیم...", + "tableName": "نام", + "tableEmail": "ایمیل", + "tableRole": "نقش", + "tableStatus": "وضعیت", + "tableAccess": "دسترسی", + "tableAction": "عملیات", + "roleOwner": "مالک", + "roleStaff": "کارمند", + "statusActive": "فعال", + "statusPending": "در انتظار", + "statusDisabled": "غیرفعال", + "statusExpired": "منقضی شده", + "allFeatures": "همه امکانات", + "inviteModalTitle": "دعوت از عضو تیم", + "stepOf": "مرحله {step} از ۲", + "permissionView": "نمایش", + "permissionEdit": "ویرایش", + "labelEmail": "ایمیل", + "labelDisplayName": "نام نمایشی", + "tabAccess": "دسترسی به برگه‌ها", + "sendInvite": "ارسال دعوتنامه", + "skipForNow": "فعلاً رد کردن", + "enableModalTitle": "فعال‌سازی عضو تیم", + "enableConfirm": "{name} ({email}) فعال شود؟", + "enableBullet1": "آنها می‌توانند دوباره با حساب موجود خود وارد این سازمان شوند.", + "enableBullet2": "هیچ دعوتنامه جدیدی ارسال نمی‌شود و در زمان غیرفعال بودن هیچ داده‌ای حذف نشده است.", + "enableBullet3": "فعال‌سازی از یک مجوز در طرح شما استفاده می‌کند.", + "noSeatsAvailable": "هیچ مجوزی در دسترس نیست. قبل از فعال‌سازی این شخص، عضو دیگری را غیرفعال کنید یا طرح خود را ارتقا دهید.", + "enableMemberButton": "فعال‌سازی عضو", + "disableModalTitle": "غیرفعال‌سازی عضو تیم", + "disableConfirm": "{name} ({email}) غیرفعال شود؟", + "disableBullet1": "آنها نمی‌توانند وارد این سازمان شوند.", + "disableBullet2": "هیچ داده‌ای حذف نمی‌شود.", + "disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.", + "disableMemberButton": "غیرفعال‌سازی عضو", + "editModalTitle": "ویرایش عضو", + "loadingWorkingHours": "در حال بارگذاری ساعات کاری...", + "errorLoadStaff": "بارگذاری کارکنان ناموفق بود.", + "errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.", + "errorSendInvite": "ارسال دعوتنامه ناموفق بود.", + "errorLoadWorkingHours": "بارگذاری ساعات کاری ناموفق بود.", + "successMemberUpdated": "عضو به‌روزرسانی شد.", + "errorUpdateMember": "به‌روزرسانی عضو ناموفق بود.", + "errorDeleteNotImplemented": "حذف هنوز پیاده‌سازی نشده است.", + "successMemberDisabled": "{name} غیرفعال شد. یک مجوز در دسترس قرار گرفت.", + "errorDisableMember": "غیرفعال‌سازی عضو ناموفق بود.", + "successMemberEnabled": "{name} فعال شد و می‌تواند دوباره وارد شود.", + "errorEnableMember": "فعال‌سازی عضو ناموفق بود.", + "successInvited": "{name} ({email}) دعوت شد.", + "copyInviteLink": "کپی لینک دعوتنامه", + "copyInviteLinkTitle": "کپی لینک دعوتنامه (در صورت نیاز لینک جدیدی تولید می‌کند)", + "enableMemberAria": "فعال‌سازی عضو", + "enableMemberTitle": "فعال‌سازی عضو (از یک مجوز استفاده می‌کند)", + "disableMemberAria": "غیرفعال‌سازی عضو", + "disableMemberTitle": "غیرفعال‌سازی عضو (یک مجوز را آزاد می‌کند)", + "editMemberAria": "ویرایش عضو", + "deleteMemberAria": "حذف عضو", + "deleteMemberTitle": "حذف عضو (پیاده‌سازی نشده)", + "features": { + "featureToday": "امروز", + "featureStaff": "کارکنان", + "featureOrganizations": "سازمان‌ها", + "featureClinics": "کلینیک‌ها", + "featureLabs": "لابراتوارها", + "featurePatients": "بیماران", + "featureAppointment": "نوبت‌ها", + "featureTreatment": "درمان", + "featureBilling": "صورتحساب", + "featureReports": "گزارش‌ها", + "noTabAccess": "دسترسی به برگه‌ها وجود ندارد", + "readOnlySuffix": "(فقط خواندنی)" + }, + "workingHours": { + "recommendedTitle": "ساعات کاری توصیه می‌شود", + "recommendedBody": "کارکنانی که دسترسی ویرایش درمان دارند، به عنوان ستون‌های ارائه‌دهنده در نوبت‌ها ظاهر می‌شوند. ساعات هفتگی آنها را تنظیم کنید تا جدول زمان‌بندی زمان‌های قابل رزرو صحیح را نشان دهد.", + "intro": "ساعات کاری هفتگی این ارائه‌دهنده را تنظیم کنید. جدول نوبت‌ها از این ساعات برای نمایش زمان‌های قابل رزرو استفاده می‌کند.", + "workingDay": "روز کاری", + "start": "شروع", + "end": "پایان", + "removeShift": "حذف شیفت", + "addShift": "افزودن شیفت", + "autoRepeatWeekly": "این ساعات را در ابتدای هر هفته تکرار کنید (کپی به جلو در روز دوشنبه)", + "weekdayMon": "دوشنبه", + "weekdayTue": "سه‌شنبه", + "weekdayWed": "چهارشنبه", + "weekdayThu": "پنج‌شنبه", + "weekdayFri": "جمعه", + "weekdaySat": "شنبه", + "weekdaySun": "یک‌شنبه", + "validationNeedsShift": "{day} حداقل به یک شیفت نیاز دارد یا باید به عنوان تعطیل علامت‌گذاری شود.", + "validationEndAfterStart": "زمان پایان شیفت {day} باید بعد از زمان شروع باشد.", + "validationOverlap": "شیفت‌های {day} نمی‌توانند همپوشانی داشته باشند." + } + }, + "patients": { + "title": "بیماران", + "newPatient": "بیمار جدید", + "errorLoadPatients": "بارگذاری بیماران ناموفق بود.", + "successPatientSaved": "بیمار {firstName} {lastName} با موفقیت ذخیره شد.", + "errorSavePatient": "ذخیره بیمار ناموفق بود.", + "firstName": "نام", + "lastName": "نام خانوادگی", + "phone": "تلفن", + "savePatient": "ذخیره بیمار", + "dialogTitle": "بیمار جدید", + "searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", + "loadingPatients": "در حال بارگذاری بیماران...", + "noResults": "هیچ بیماری برای این جستجو یافت نشد.", + "noContact": "بدون اطلاعات تماس", + "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", + "phoneLabel": "تلفن:", + "emailLabel": "ایمیل:", + "statusLabel": "وضعیت:", + "statusActive": "فعال", + "statusInactive": "غیرفعال", + "emptyValue": "-" + }, + "appointments": { + "title": "نوبت‌ها", + "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", + "loadingSchedule": "در حال بارگذاری برنامه...", + "infoPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", + "infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.", + "errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائه‌دهنده است و قابل ویرایش نیست.", + "successUpdated": "نوبت به‌روزرسانی شد.", + "successSaved": "نوبت ذخیره شد.", + "errorUpdate": "به‌روزرسانی نوبت امکان‌پذیر نبود.", + "errorSave": "ذخیره نوبت امکان‌پذیر نبود.", + "confirmRemove": "این نوبت حذف شود؟", + "successRemoved": "نوبت حذف شد.", + "errorDelete": "حذف نوبت امکان‌پذیر نبود.", + "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", + "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", + "searchPlaceholder": "جستجوی بیماران موجود", + "searching": "در حال جستجو...", + "searchHint": "برای جستجوی بیماران بر اساس نام، تلفن یا ایمیل، تایپ کنید.", + "noPermissionAdd": "شما مجوز اضافه کردن بیمار را ندارید.", + "editTitle": "ویرایش نوبت", + "newTitle": "نوبت جدید", + "providerLabel": "ارائه‌دهنده:", + "patientLabel": "بیمار", + "startLabel": "شروع", + "endLabel": "پایان", + "purposeLabel": "هدف", + "errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.", + "errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.", + "errorPastSchedule": "نمی‌توان در گذشته زمان‌بندی کرد.", + "errorPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", + "errorMissingDetails": "جزئیات نوبت وجود ندارد.", + "noProviders": "هیچ ارائه‌دهنده‌ای در دسترس نیست. برای مشاهده ستون‌ها در اینجا، کارکنانی با دسترسی ویرایش درمان اضافه کنید.", + "noWorkingHours": "ساعات کاری برای این روز پیکربندی نشده است. ساعات کاری ارائه‌دهنده را در مدیریت کارکنان تنظیم کنید.", + "noHoursSet": "ساعتی تنظیم نشده", + "offToday": "امروز تعطیل است", + "slotOffToday": "ارائه‌دهنده امروز تعطیل است", + "slotHoursNotConfigured": "ساعات کاری پیکربندی نشده است", + "slotOutsideHours": "خارج از ساعات کاری", + "slotCannotCreate": "شما نمی‌توانید نوبت ایجاد کنید", + "slotBookAt": "رزرو {time}", + "outsideHoursBlocked": "خارج از ساعات کاری — ویرایش مسدود شده است", + "overlappingChoose": "{count} همپوشانی — برای انتخاب کلیک کنید", + "overlapping": "{count} همپوشانی", + "legend": "راهنما", + "overlappingTitle": "نوبت‌های همپوشانی ({count})", + "purposeConsultation": "مشاوره", + "purposeFilling": "پر کردن", + "purposeEndo": "درمان ریشه", + "purposeVisit": "ویزیت", + "purposeHygiene": "بهداشت" + }, + "treatment": { + "loading": "در حال بارگذاری...", + "noPermissionTitle": "فضای کاری درمان", + "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", + "title": "درمان", + "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", + "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", + "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", + "selectedPatient": "بیمار انتخاب شده", + "purposeLabel": "هدف:", + "loadingAppointments": "در حال بارگذاری نوبت‌ها...", + "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", + "successDraftSaved": "پیش‌نویس درمان ذخیره شد.", + "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", + "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", + "successFilesUploaded": "{count} فایل با موفقیت بارگذاری شد.", + "errorLoadAppointments": "بارگذاری نوبت‌ها ناموفق بود.", + "errorLoadOrgs": "بارگذاری سازمان‌های مرتبط ناموفق بود.", + "errorLoadHistory": "بارگذاری تاریخچه درمان ناموفق بود.", + "errorLoadDraft": "بارگذاری پیش‌نویس درمان ناموفق بود.", + "errorUpload": "بارگذاری پیوست‌ها ناموفق بود.", + "errorSaveDraft": "ذخیره پیش‌نویس درمان ناموفق بود.", + "errorSendCase": "ارسال پرونده ناموفق بود.", + "errorCaseMustSave": "پرونده باید قبل از ارسال ذخیره شود.", + "draftTitle": "پیش‌نویس · {patientName}", + "hiddenMessage": "نوبت‌ها پنهان هستند.", + "showAppointments": "نمایش نوبت‌ها", + "appointmentsTitle": "نوبت‌های من", + "hideAppointments": "پنهان کردن نوبت‌ها", + "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", + "casesTitle": "پرونده‌های درمانی", + "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", + "addCase": "افزودن پرونده", + "caseLabel": "پرونده {n}", + "comments": "نظرات", + "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", + "treatmentType": "نوع درمان", + "typeConsultation": "مشاوره", + "typeFilling": "پر کردن", + "typeEndo": "درمان ریشه", + "typeVisit": "ویزیت", + "typeHygiene": "بهداشت", + "attachments": "پیوست‌ها", + "attachFiles": "فایل‌ها را برای این پرونده درمانی پیوست کنید", + "chooseFiles": "انتخاب فایل‌ها", + "sendToOrgs": "ارسال این پرونده به سازمان‌های مرتبط", + "searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...", + "recent": "اخیر:", + "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", + "sendThisCase": "ارسال این پرونده", + "saveDraft": "ذخیره پیش‌نویس درمان", + "unsavedChanges": "تغییرات ذخیره‌نشده", + "draftSaved": "پیش‌نویس ذخیره شد", + "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", + "historyTitle": "درمان‌های قبلی", + "historySubtitle": "درمان‌های تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.", + "loadingHistory": "در حال بارگذاری تاریخچه...", + "historyEmpty": "هیچ درمان قبلی برای این بیمار وجود ندارد.", + "statusLabel": "وضعیت:", + "historyCaseLabel": "پرونده {n} · {type}", + "teethLabel": "دندان‌ها:", + "teethNone": "هیچکدام انتخاب نشده", + "reviewDetails": "بررسی جزئیات", + "previewTitle": "پیش‌نمایش درمان", + "previewDraft": "پیش‌نمایش پیش‌نویس فعلی", + "selectAppointment": "یک نوبت را برای پیش‌نمایش پیش‌نویس آن انتخاب کنید.", + "caseCount": "{n} پرونده", + "attachmentCount": "{n} پیوست", + "caseSummary": "پرونده {n}: {type}", + "teethPrefix": "· دندان‌ها", + "moreCases": "+ {n} پرونده دیگر", + "previewDialogTitle": "پیش‌نمایش درمان", + "previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.", + "noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.", + "typeLabel": "نوع:", + "commentsLabel": "نظرات:", + "commentsEmpty": "نظرات: —", + "attachFilesShort": "پیوست فایل‌ها", + "sendCase": "ارسال این پرونده", + "sendToLinkedOrgs": "ارسال به سازمان‌های مرتبط", + "noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.", + "confirmSend": "تأیید ارسال", + "toothChartTitle": "نمودار دندان‌ها FDI", + "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای پرونده فعال اعمال می‌شود.", + "selectedLabel": "انتخاب شده:", + "selectedEmpty": "—", + "upperArch": "قوس بالا", + "lowerArch": "قوس پایین", + "toothAria": "دندان FDI {fdi}", + "toothSelectedSuffix": "، انتخاب شده", + "sentToAt": "ارسال به {orgName} در {datetime}", + "fallbackOrgName": "سازمان" + }, + "organizations": { + "loadingOrganization": "در حال بارگذاری سازمان...", + "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در DyoLink نیستند، لینک دعوت ارسال کنید.", + "invitationHistory": "تاریخچه دعوت‌نامه‌ها", + "searchPlaceholder": "جستجوی {counterpart} بر اساس نام، ایمیل یا تلفن...", + "backToList": "بازگشت به لیست", + "tableOrganization": "سازمان", + "tableOwnerEmail": "ایمیل مالک", + "tableDate": "تاریخ", + "tableStatus": "وضعیت", + "tableAction": "عملیات", + "emptyConnections": "هنوز هیچ اتصالی وجود ندارد. برای ارسال درخواست اتصال یا لینک دعوت، جستجو کنید.", + "statusInvitationPending": "دعوتنامه در انتظار", + "statusConnectionPending": "درخواست اتصال در انتظار", + "statusConnected": "متصل", + "statusDeclined": "درخواست اتصال رد شد", + "statusFound": "یافت شد", + "statusToday": "امروز", + "acceptRequest": "پذیرش درخواست اتصال", + "declineRequest": "رد درخواست اتصال", + "removeConnection": "قطع اتصال", + "sendRequest": "ارسال درخواست اتصال", + "noDirectoryResults": "هیچ سازمانی در جستجوی دایرکتوری یافت نشد.", + "hideInvitationFields": "پنهان کردن فیلدهای دعوت", + "sendInvitationLink": "ارسال لینک دعوت", + "counterpartNameLabel": "نام {counterpart}", + "ownerEmailLabel": "ایمیل مالک", + "sendInvitation": "ارسال دعوتنامه", + "successConnectionSent": "درخواست اتصال {counterpart} ارسال شد.", + "successInviteCreated": "لینک دعوت برای {email} ایجاد شد", + "successLinkCopied": "لینک دعوت در کلیپ‌بورد کپی شد.", + "successAccepted": "درخواست اتصال پذیرفته شد.", + "successDeclined": "درخواست اتصال رد شد.", + "successRemoved": "اتصال قطع شد.", + "historyTitle": "تاریخچه دعوت‌نامه‌ها", + "loadingHistory": "در حال بارگذاری تاریخچه دعوت‌نامه‌ها...", + "historyEmpty": "هنوز هیچ دعوتنامه‌ای وجود ندارد.", + "tableInvitationLink": "لینک دعوت", + "statusPending": "دعوتنامه در انتظار", + "statusAccepted": "دعوتنامه پذیرفته شد", + "statusRejected": "دعوتنامه رد شد", + "statusExpired": "دعوتنامه منقضی شد", + "copyInvitationLink": "کپی لینک دعوتنامه", + "copyInvitationLinkTitle": "کپی لینک دعوتنامه (در صورت نیاز لینک جدیدی تولید می‌کند)", + "selectorTitle": "سازمان‌ها", + "selectorSubtitleWithCreate": "یک سازمان را برای ادامه انتخاب کنید، یا یک سازمان جدید ایجاد کنید.", + "selectorSubtitleSelectOnly": "یک سازمان را برای ادامه انتخاب کنید.", + "createOrganization": "ایجاد سازمان", + "createAndContinue": "ایجاد و ادامه", + "emptyCanCreate": "هیچ سازمانی یافت نشد. اولین سازمان خود را ایجاد کنید تا ادامه دهید.", + "emptyAskOwner": "هیچ سازمانی یافت نشد. از مالک سازمان بخواهید تا شما را دعوت کند.", + "continueArrow": "ادامه →", + "planLabel": "طرح: {name} • {maxUsers} کاربر", + "counterpartClinic": "کلینیک", + "counterpartLab": "لابراتوار" + }, + "settings": { + "accountTitle": "حساب کاربری", + "accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.", + "accountPlaceholder": "تغییر رمز عبور و ویرایش پروفایل در مرحله بعدی در اینجا قرار می‌گیرند (مثلاً فرآیند دعوت، بازنشانی رمز عبور).", + "subscriptionsTitle": "اشتراک‌ها", + "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری DyoLink شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", + "noSubscriptionNotice": "این سازمان اشتراک فعالی ندارد. برای شروع فرآیند خرید، یک طرح زیر را انتخاب کنید.", + "currentPlan": "طرح فعلی", + "planPrice": "قیمت طرح", + "seatsUsed": "مجوزهای استفاده شده", + "seatsRemaining": "مجوزهای باقی‌مانده", + "daysRemaining": "روزهای باقی‌مانده", + "unlimited": "نامحدود", + "unlimitedSeats": "مجوزهای نامحدود", + "seatsCount": "{n} مجوز", + "pricePerMonth": "${price} / ماه", + "noActiveSubscription": "هیچ اشتراک فعالی برای این سازمان وجود ندارد.", + "trialEnded": "دوره آزمایشی به پایان رسیده است. زمانی که تسویه حساب در دسترس قرار گرفت، یک طرح انتخاب کنید.", + "trialEndsIn": "دوره آزمایشی در {days} روز به پایان می‌رسد.", + "seatsLow": "استفاده از مجوزها برای این سازمان بالا است.", + "choosePlanIntro": "یک طرح را برای ادامه انتخاب کنید. یکپارچه‌سازی خرید هنوز فعال نیست، بنابراین این مرحله فقط مرحله انتخاب را آماده می‌کند.", + "planSolo": "انفرادی", + "planSmall": "کوچک", + "planMedium": "متوسط", + "planLarge": "بزرگ", + "planEnterprise": "سازمانی", + "startPurchase": "شروع فرآیند خرید", + "purchaseNotice": "جریان خرید به زودی فعال می‌شود. {plan} انتخاب شده و برای راه‌اندازی تسویه حساب آماده است." + }, + "schedule": { + "defaultLabel": "تاریخ برنامه", + "previousDay": "روز قبل", + "nextDay": "روز بعد", + "chooseDate": "انتخاب تاریخ برنامه", + "year": "سال", + "month": "ماه", + "day": "روز", + "monthJanuary": "ژانویه", + "monthFebruary": "فوریه", + "monthMarch": "مارس", + "monthApril": "آوریل", + "monthMay": "مه", + "monthJune": "ژوئن", + "monthJuly": "ژوئیه", + "monthAugust": "اوت", + "monthSeptember": "سپتامبر", + "monthOctober": "اکتبر", + "monthNovember": "نوامبر", + "monthDecember": "دسامبر" + } +} \ No newline at end of file diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json new file mode 100644 index 0000000..517a742 --- /dev/null +++ b/frontend/messages/nl.json @@ -0,0 +1,566 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "Laden...", + "loadingApp": "Applicatie laden...", + "loadingWorkspace": "Werkruimte laden...", + "continue": "Doorgaan", + "back": "Terug", + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "next": "Volgende", + "dismiss": "Afwijzen", + "or": "Of", + "and": "en", + "close": "Sluiten", + "redirecting": "Bezig met doorsturen...", + "readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.", + "errorGeneric": "Er is iets misgegaan", + "loadingEllipsis": "Laden...", + "search": "Zoeken", + "action": "Actie", + "status": "Status", + "name": "Naam", + "email": "E-mail", + "date": "Datum", + "organization": "Organisatie", + "backToApp": "← Terug naar app", + "copied": "Gekopieerd", + "copyLink": "Link kopiëren", + "none": "Geen", + "preview": "Voorbeeld" + }, + "language": { + "label": "Taal", + "selectLanguage": "Selecteer taal", + "en": "Engels", + "fa": "Perzisch", + "nl": "Nederlands" + }, + "theme": { + "switchToLight": "Overschakelen naar lichte modus", + "switchToDark": "Overschakelen naar donkere modus", + "lightMode": "Lichte modus", + "darkMode": "Donkere modus" + }, + "nav": { + "dashboard": "Dashboard", + "staff": "Medewerkers", + "patients": "Patiënten", + "appointment": "Afspraak", + "treatment": "Behandeling", + "billing": "Facturatie", + "reports": "Rapporten", + "clinics": "Klinieken", + "labs": "Laboratoria" + }, + "auth": { + "login": "Inloggen", + "signIn": "Aanmelden", + "signOut": "Uitloggen", + "register": "Registreren", + "startTrial": "Proefperiode starten", + "startFreeTrial": "Gratis proefperiode starten", + "dashboard": "Dashboard", + "signInTitle": "Meld u aan bij uw account", + "signInPrompt": "Of {link}", + "startTrialLink": "start uw gratis proefperiode", + "registerTitle": "Start uw gratis proefperiode van 30 dagen", + "registerPrompt": "Heeft u al een account?", + "signInLink": "Aanmelden", + "email": "E-mailadres", + "password": "Wachtwoord", + "confirmPassword": "Bevestig wachtwoord", + "fullName": "Volledige naam", + "rememberMe": "Onthoud mij", + "forgotPassword": "Wachtwoord vergeten?", + "invalidCredentials": "Ongeldig e-mailadres of wachtwoord", + "loginFailed": "Inloggen mislukt", + "registrationFailed": "Registratie mislukt. Probeer het opnieuw.", + "startMyFreeTrial": "Start mijn gratis proefperiode", + "trialIncludes": "Uw proefperiode omvat:", + "trialTeamMembers": "Tot 5 teamleden", + "trialFullAccess": "Volledige toegang tot alle functies", + "trialNoCard": "30 dagen gratis, geen creditcard vereist", + "termsAgreement": "Door u aan te melden gaat u akkoord met onze {terms} en {privacy}", + "termsOfService": "Servicevoorwaarden", + "privacyPolicy": "Privacybeleid", + "signedIn": "Aangemeld", + "switchOrganization": "Organisatie wisselen", + "subscriptions": "Abonnementen", + "account": "Account", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "Door u te registreren gaat u akkoord met onze", + "errorRegistrationFailed": "Registratie mislukt", + "errorLoginFailed": "Inloggen mislukt", + "errorCreateOrganization": "Organisatie aanmaken mislukt", + "acceptInviteTitle": "Uitnodiging accepteren", + "loadingInvitation": "Uitnodiging laden...", + "invalidInvitationLink": "Ongeldige uitnodigingslink", + "invitationAlreadyAccepted": "Deze uitnodiging is al geaccepteerd. U kunt nu inloggen.", + "errorLoadInvitation": "Kon uitnodiging niet laden", + "organizationLabel": "Organisatie:", + "emailLabel": "E-mail:", + "nameRequired": "Naam is verplicht", + "passwordMinLength8": "Wachtwoord moet minimaal 8 tekens bevatten", + "passwordsDoNotMatch": "Wachtwoorden komen niet overeen", + "labelName": "Naam", + "labelCreatePassword": "Wachtwoord aanmaken", + "labelConfirmPassword": "Bevestig wachtwoord", + "activateAccount": "Account activeren", + "invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "errorAcceptInvitation": "Kon uitnodiging niet accepteren", + "alreadyHaveAccess": "Heeft u al toegang?", + "goToLogin": "Ga naar inloggen", + "acceptOrganizationTitle": "Organisatie-uitnodiging accepteren", + "alreadyHaveAccount": "Heeft u al een account?", + "invitedBy": "Uitgenodigd door:", + "ownerEmail": "E-mail eigenaar", + "activateOrganization": "Organisatie activeren", + "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "stepAccount": "Account", + "stepOrganization": "Organisatie", + "organizationName": "Organisatienaam", + "organizationNamePlaceholder": "Sunshine Tandartspraktijk", + "organizationEmail": "Organisatie e-mail", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "Organisatietype", + "dentalClinic": "Tandartspraktijk", + "dentalLab": "Tandtechnisch Laboratorium" + }, + "landing": { + "heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria", + "heroHighlight": "Naadloos", + "heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.", + "featureClinicsTitle": "Voor Klinieken", + "featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.", + "featureLabsTitle": "Voor Laboratoria", + "featureLabsDescription": "Ontvang casussen, volg de voortgang en communiceer met klinieken.", + "featureTeamTitle": "Teambeheer", + "featureTeamDescription": "Voeg tijdens de proefperiode maximaal 5 teamleden toe. Schaal mee met uw groei.", + "featureTrialTitle": "Proefperiode van 30 dagen", + "featureTrialDescription": "Volledige toegang tot alle functies. Geen creditcard vereist.", + "featureRealtimeTitle": "Realtime Updates", + "featureRealtimeDescription": "Ontvang direct meldingen bij statuswijzigingen van casussen.", + "featureSecurityTitle": "Veilig & Conform", + "featureSecurityDescription": "HIPAA-conform met beveiliging op ondernemingsniveau.", + "footerCopyright": "© 2026 DyoLink. Alle rechten voorbehouden.", + "termsAndConditions": "Algemene Voorwaarden" + }, + "accountMenu": { + "noActiveSubscription": "Geen actief abonnement — bekijk Abonnementen", + "trialEnded": "Proefperiode beëindigd — bekijk Abonnementen", + "trialEndingSoon": "Proefperiode eindigt binnenkort — bekijk Abonnementen", + "seatsLow": "Aantal beschikbare plaatsen raakt op — bekijk Abonnementen", + "reviewSubscriptions": "Abonnementen bekijken" + }, + "validation": { + "emailInvalid": "Voer een geldig e-mailadres in", + "passwordRequired": "Wachtwoord is verplicht", + "nameMinLength": "Naam moet minimaal 2 tekens bevatten", + "passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten", + "passwordUppercase": "Wachtwoord moet minimaal één hoofdletter bevatten", + "passwordNumber": "Wachtwoord moet minimaal één cijfer bevatten", + "organizationNameMinLength": "Organisatienaam moet minimaal 2 tekens bevatten", + "organizationEmailInvalid": "Voer een geldig organisatie-e-mailadres in", + "organizationTypeRequired": "Selecteer een organisatietype", + "passwordsDoNotMatch": "Wachtwoorden komen niet overeen" + }, + "today": { + "welcomeBack": "Welkom terug!!", + "noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.", + "choosePlanLink": "Kies een abonnement", + "noSubscriptionCta": "om het aankoopproces te starten.", + "cardTodaysAppointments": "Afspraken van vandaag", + "cardActivePatients": "Actieve patiënten", + "cardNewLabCase": "Nieuwe laboratoriumcase", + "cardTodayInvoices": "Facturen van vandaag" + }, + "staff": { + "redirecting": "Bezig met doorsturen...", + "title": "Medewerkersbeheer", + "subtitle": "Nodig teamleden uit, stel tabbladtoegang in en blijf binnen het limiet van uw abonnementsplaatsen.", + "inviteMember": "Lid uitnodigen", + "seatsLabel": "Plaatsen:", + "unlimitedPlan": "(onbeperkt abonnement)", + "seatLimitReached": "Plaatslimiet van het abonnement voor deze organisatie is bereikt.", + "noActivePlan": "Geen actief abonnement geselecteerd voor deze organisatie. Kies een abonnementsplan om leden uit te nodigen.", + "invitedPending": "Uitnodiging is in afwachting totdat ze de link openen, een wachtwoord instellen en inloggen.", + "invitedAccepted": "Uitnodiging is onmiddellijk geaccepteerd.", + "inviteLinkHeading": "Uitnodigingslink", + "shareLinkHint": "Deel deze link handmatig via SMS of e-mail. Er wordt een nieuwe link gegenereerd als de vorige is verlopen of verloren is gegaan.", + "loadingTeam": "Team laden...", + "tableName": "Naam", + "tableEmail": "E-mail", + "tableRole": "Rol", + "tableStatus": "Status", + "tableAccess": "Toegang", + "tableAction": "Actie", + "roleOwner": "Eigenaar", + "roleStaff": "Medewerker", + "statusActive": "Actief", + "statusPending": "In afwachting", + "statusDisabled": "Uitgeschakeld", + "statusExpired": "Verlopen", + "allFeatures": "Alle functies", + "inviteModalTitle": "Teamlid uitnodigen", + "stepOf": "Stap {step} van 2", + "permissionView": "Bekijken", + "permissionEdit": "Bewerken", + "labelEmail": "E-mail", + "labelDisplayName": "Weergavenaam", + "tabAccess": "Tabbladtoegang", + "sendInvite": "Uitnodiging verzenden", + "skipForNow": "Nu overslaan", + "enableModalTitle": "Teamlid inschakelen", + "enableConfirm": "{name} ({email}) inschakelen?", + "enableBullet1": "Ze kunnen opnieuw inloggen op deze organisatie met hun bestaande account.", + "enableBullet2": "Er wordt geen nieuwe uitnodiging verzonden en er zijn geen gegevens verwijderd terwijl ze waren uitgeschakeld.", + "enableBullet3": "Inschakelen gebruikt één plaats in uw abonnement.", + "noSeatsAvailable": "Geen plaatsen beschikbaar. Schakel een ander lid uit of upgrade uw abonnement voordat u deze persoon inschakelt.", + "enableMemberButton": "Lid inschakelen", + "disableModalTitle": "Teamlid uitschakelen", + "disableConfirm": "{name} ({email}) uitschakelen?", + "disableBullet1": "Ze kunnen niet inloggen op deze organisatie.", + "disableBullet2": "Er worden geen gegevens verwijderd.", + "disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.", + "disableMemberButton": "Lid uitschakelen", + "editModalTitle": "Lid bewerken", + "loadingWorkingHours": "Werktijden laden...", + "errorLoadStaff": "Medewerkers laden mislukt.", + "errorCopyInvite": "Kon uitnodigingslink niet kopiëren.", + "errorSendInvite": "Uitnodiging verzenden mislukt.", + "errorLoadWorkingHours": "Werktijden laden mislukt.", + "successMemberUpdated": "Lid bijgewerkt.", + "errorUpdateMember": "Lid bijwerken mislukt.", + "errorDeleteNotImplemented": "Verwijderen is nog niet geïmplementeerd.", + "successMemberDisabled": "{name} is uitgeschakeld. Er is nu een plaats beschikbaar.", + "errorDisableMember": "Lid uitschakelen mislukt.", + "successMemberEnabled": "{name} is ingeschakeld en kan opnieuw inloggen.", + "errorEnableMember": "Lid inschakelen mislukt.", + "successInvited": "{name} ({email}) is uitgenodigd.", + "copyInviteLink": "Uitnodigingslink kopiëren", + "copyInviteLinkTitle": "Uitnodigingslink kopiëren (genereert indien nodig een nieuwe link)", + "enableMemberAria": "Lid inschakelen", + "enableMemberTitle": "Lid inschakelen (gebruikt een plaats)", + "disableMemberAria": "Lid uitschakelen", + "disableMemberTitle": "Lid uitschakelen (maakt een plaats vrij)", + "editMemberAria": "Lid bewerken", + "deleteMemberAria": "Lid verwijderen", + "deleteMemberTitle": "Lid verwijderen (niet geïmplementeerd)", + "features": { + "featureToday": "Vandaag", + "featureStaff": "Medewerkers", + "featureOrganizations": "Organisaties", + "featureClinics": "Klinieken", + "featureLabs": "Laboratoria", + "featurePatients": "Patiënten", + "featureAppointment": "Afspraak", + "featureTreatment": "Behandeling", + "featureBilling": "Facturatie", + "featureReports": "Rapporten", + "noTabAccess": "Geen tabbladtoegang", + "readOnlySuffix": "(Alleen lezen)" + }, + "workingHours": { + "recommendedTitle": "Werktijden aanbevolen", + "recommendedBody": "Medewerkers met bewerkingsrechten voor behandelingen verschijnen als aanbiederkolommen in Afspraken. Stel hun wekelijkse uren in, zodat het rooster de juiste boekbare tijden weergeeft.", + "intro": "Stel wekelijkse werktijden in voor deze aanbieder. Het afsprakenrooster gebruikt deze uren om boekbare tijdsloten weer te geven.", + "workingDay": "Werkdag", + "start": "Start", + "end": "Einde", + "removeShift": "Dienst verwijderen", + "addShift": "Dienst toevoegen", + "autoRepeatWeekly": "Deze uren wekelijks herhalen aan het begin van elke week (kopieer door op maandag)", + "weekdayMon": "Ma", + "weekdayTue": "Di", + "weekdayWed": "Wo", + "weekdayThu": "Do", + "weekdayFri": "Vr", + "weekdaySat": "Za", + "weekdaySun": "Zo", + "validationNeedsShift": "{day} heeft minstens één dienst nodig of moet worden gemarkeerd als vrij.", + "validationEndAfterStart": "Eindtijd van dienst op {day} moet na de starttijd liggen.", + "validationOverlap": "Diensten op {day} mogen niet overlappen." + } + }, + "patients": { + "title": "Patiënten", + "newPatient": "Nieuwe patiënt", + "errorLoadPatients": "Patiënten laden mislukt.", + "successPatientSaved": "Patiënt {firstName} {lastName} is succesvol opgeslagen.", + "errorSavePatient": "Patiënt opslaan mislukt.", + "firstName": "Voornaam", + "lastName": "Achternaam", + "phone": "Telefoon", + "savePatient": "Patiënt opslaan", + "dialogTitle": "Nieuwe patiënt", + "searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail", + "loadingPatients": "Patiënten laden...", + "noResults": "Geen patiënten gevonden voor deze zoekopdracht.", + "noContact": "Geen contact", + "selectPatient": "Selecteer een patiënt om details te bekijken.", + "phoneLabel": "Telefoon:", + "emailLabel": "E-mail:", + "statusLabel": "Status:", + "statusActive": "Actief", + "statusInactive": "Inactief", + "emptyValue": "-" + }, + "appointments": { + "title": "Afspraken", + "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", + "loadingSchedule": "Rooster laden...", + "infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", + "infoSelectPatient": "Selecteer een patiënt voor het boeken.", + "errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.", + "successUpdated": "Afspraak bijgewerkt.", + "successSaved": "Afspraak opgeslagen.", + "errorUpdate": "Kon afspraak niet bijwerken.", + "errorSave": "Kon afspraak niet opslaan.", + "confirmRemove": "Deze afspraak verwijderen?", + "successRemoved": "Afspraak verwijderd.", + "errorDelete": "Kon afspraak niet verwijderen.", + "errorLoadSchedule": "Rooster laden mislukt.", + "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", + "searchPlaceholder": "Bestaande patiënten zoeken", + "searching": "Zoeken...", + "searchHint": "Typ om te zoeken naar patiënten op naam, telefoon of e-mail.", + "noPermissionAdd": "U heeft geen toestemming om patiënten toe te voegen.", + "editTitle": "Afspraak bewerken", + "newTitle": "Nieuwe afspraak", + "providerLabel": "Aanbieder:", + "patientLabel": "Patiënt", + "startLabel": "Start", + "endLabel": "Einde", + "purposeLabel": "Doel", + "errorSelectPatient": "Selecteer eerst een patiënt.", + "errorEndAfterStart": "Eindtijd moet na de starttijd liggen.", + "errorPastSchedule": "Kan niet in het verleden plannen.", + "errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", + "errorMissingDetails": "Ontbrekende afspraakgegevens.", + "noProviders": "Geen aanbieders beschikbaar. Voeg medewerkers met bewerkingsrechten voor behandelingen toe om hier kolommen te zien.", + "noWorkingHours": "Er zijn geen werktijden geconfigureerd voor deze dag. Stel werktijden van de aanbieder in bij Medewerkersbeheer.", + "noHoursSet": "Geen uren ingesteld", + "offToday": "Vandaag vrij", + "slotOffToday": "Aanbieder is vandaag vrij", + "slotHoursNotConfigured": "Werktijden niet geconfigureerd", + "slotOutsideHours": "Buiten werktijden", + "slotCannotCreate": "U kunt geen afspraken maken", + "slotBookAt": "Boeken om {time}", + "outsideHoursBlocked": "Buiten werktijden — bewerken geblokkeerd", + "overlappingChoose": "{count} overlappend — klik om te kiezen", + "overlapping": "{count} overlappend", + "legend": "Legenda", + "overlappingTitle": "Overlappende afspraken ({count})", + "purposeConsultation": "Consult", + "purposeFilling": "Vulling", + "purposeEndo": "Endo", + "purposeVisit": "Bezoek", + "purposeHygiene": "Hygiëne" + }, + "treatment": { + "loading": "Laden...", + "noPermissionTitle": "Behandelwerkruimte", + "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", + "title": "Behandeling", + "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", + "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", + "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", + "selectedPatient": "Geselecteerde patiënt", + "purposeLabel": "Doel:", + "loadingAppointments": "Afspraken laden...", + "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", + "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", + "successDraftSaved": "Behandelconcept opgeslagen.", + "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", + "successCaseSent": "Case verzonden naar geselecteerde organisaties.", + "successFilesUploaded": "{count} bestand(en) succesvol geüpload.", + "errorLoadAppointments": "Afspraken laden mislukt.", + "errorLoadOrgs": "Gekoppelde organisaties laden mislukt.", + "errorLoadHistory": "Behandelgeschiedenis laden mislukt.", + "errorLoadDraft": "Behandelconcept laden mislukt.", + "errorUpload": "Bijlagen uploaden mislukt.", + "errorSaveDraft": "Behandelconcept opslaan mislukt.", + "errorSendCase": "Case verzenden mislukt.", + "errorCaseMustSave": "Case moet worden opgeslagen voor verzending.", + "draftTitle": "Concept · {patientName}", + "hiddenMessage": "Afspraken zijn verborgen.", + "showAppointments": "Afspraken tonen", + "appointmentsTitle": "Mijn afspraken", + "hideAppointments": "Afspraken verbergen", + "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", + "casesTitle": "Behandelcasussen", + "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", + "addCase": "Case toevoegen", + "caseLabel": "Case {n}", + "comments": "Opmerkingen", + "commentsPlaceholder": "Schrijf klinische notities voor deze case...", + "treatmentType": "Behandeltype", + "typeConsultation": "consult", + "typeFilling": "vulling", + "typeEndo": "endo", + "typeVisit": "bezoek", + "typeHygiene": "hygiëne", + "attachments": "Bijlagen", + "attachFiles": "Voeg bestanden toe voor deze behandelscase", + "chooseFiles": "Kies bestanden", + "sendToOrgs": "Verzend deze case naar gekoppelde organisaties", + "searchOrgsPlaceholder": "Zoek actieve organisaties...", + "recent": "Recent:", + "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", + "sendThisCase": "Verzend deze case", + "saveDraft": "Behandelconcept opslaan", + "unsavedChanges": "Niet-opgeslagen wijzigingen", + "draftSaved": "Concept opgeslagen", + "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", + "historyTitle": "Eerdere behandelingen", + "historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.", + "loadingHistory": "Geschiedenis laden...", + "historyEmpty": "Geen eerdere behandelingen voor deze patiënt.", + "statusLabel": "Status:", + "historyCaseLabel": "Case {n} · {type}", + "teethLabel": "Tanden:", + "teethNone": "Geen geselecteerd", + "reviewDetails": "Details bekijken", + "previewTitle": "Behandelvoorbeeld", + "previewDraft": "Bekijk huidig concept", + "selectAppointment": "Selecteer een afspraak om het concept te bekijken.", + "caseCount": "{n} case(s)", + "attachmentCount": "{n} bijlage(n)", + "caseSummary": "Case {n}: {type}", + "teethPrefix": "· Tanden", + "moreCases": "+ {n} meer case(s)", + "previewDialogTitle": "Behandelvoorbeeld", + "previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.", + "noCases": "Geen casussen in deze behandeling.", + "typeLabel": "Type:", + "commentsLabel": "Opmerkingen:", + "commentsEmpty": "Opmerkingen: —", + "attachFilesShort": "Bestanden bijvoegen", + "sendCase": "Verzend deze case", + "sendToLinkedOrgs": "Verzenden naar gekoppelde organisaties", + "noActiveOrgs": "Geen actieve gekoppelde organisaties.", + "confirmSend": "Bevestig verzending", + "toothChartTitle": "FDI-tanddiagram", + "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.", + "selectedLabel": "Geselecteerd:", + "selectedEmpty": "—", + "upperArch": "Bovenboog", + "lowerArch": "Onderboog", + "toothAria": "FDI-tand {fdi}", + "toothSelectedSuffix": ", geselecteerd", + "sentToAt": "Verzonden naar {orgName} op {datetime}", + "fallbackOrgName": "organisatie" + }, + "organizations": { + "loadingOrganization": "Organisatie laden...", + "subtitle": "Zoek organisaties, stuur verbindingsverzoeken naar bestaande accounts, of uitnodigingslinks wanneer ze nog niet op DyoLink staan.", + "invitationHistory": "Uitnodigingsgeschiedenis", + "searchPlaceholder": "Zoek {counterpart} op naam, e-mail of telefoon...", + "backToList": "Terug naar lijst", + "tableOrganization": "Organisatie", + "tableOwnerEmail": "E-mail eigenaar", + "tableDate": "Datum", + "tableStatus": "Status", + "tableAction": "Actie", + "emptyConnections": "Nog geen verbindingen. Zoek om een verbindingsverzoek of een uitnodigingslink te verzenden.", + "statusInvitationPending": "Uitnodiging in afwachting", + "statusConnectionPending": "Verbindingsverzoek in afwachting", + "statusConnected": "Verbonden", + "statusDeclined": "Verbindingsverzoek afgewezen", + "statusFound": "Gevonden", + "statusToday": "Vandaag", + "acceptRequest": "Verbindingsverzoek accepteren", + "declineRequest": "Verbindingsverzoek afwijzen", + "removeConnection": "Verbinding verwijderen", + "sendRequest": "Verbindingsverzoek verzenden", + "noDirectoryResults": "Geen organisatie gevonden in directoryzoekopdracht.", + "hideInvitationFields": "Uitnodigingsvelden verbergen", + "sendInvitationLink": "Uitnodigingslink verzenden", + "counterpartNameLabel": "Naam {counterpart}", + "ownerEmailLabel": "E-mail eigenaar", + "sendInvitation": "Uitnodiging verzenden", + "successConnectionSent": "Verbindingsverzoek voor {counterpart} verzonden.", + "successInviteCreated": "Uitnodigingslink aangemaakt voor {email}", + "successLinkCopied": "Uitnodigingslink gekopieerd naar klembord.", + "successAccepted": "Verbindingsverzoek geaccepteerd.", + "successDeclined": "Verbindingsverzoek afgewezen.", + "successRemoved": "Verbinding verwijderd.", + "historyTitle": "Uitnodigingsgeschiedenis", + "loadingHistory": "Uitnodigingsgeschiedenis laden...", + "historyEmpty": "Nog geen uitnodigingen.", + "tableInvitationLink": "Uitnodigingslink", + "statusPending": "Uitnodiging in afwachting", + "statusAccepted": "Uitnodiging geaccepteerd", + "statusRejected": "Uitnodiging afgewezen", + "statusExpired": "Uitnodiging verlopen", + "copyInvitationLink": "Uitnodigingslink kopiëren", + "copyInvitationLinkTitle": "Uitnodigingslink kopiëren (genereert indien nodig een nieuwe link)", + "selectorTitle": "Organisaties", + "selectorSubtitleWithCreate": "Selecteer een organisatie om door te gaan, of maak een nieuwe aan.", + "selectorSubtitleSelectOnly": "Selecteer een organisatie om door te gaan.", + "createOrganization": "Organisatie aanmaken", + "createAndContinue": "Aanmaken en doorgaan", + "emptyCanCreate": "Geen organisaties gevonden. Maak uw eerste organisatie aan om door te gaan.", + "emptyAskOwner": "Geen organisaties gevonden. Vraag een organisatie-eigenaar om u uit te nodigen.", + "continueArrow": "Doorgaan →", + "planLabel": "Plan: {name} • {maxUsers} gebruikers", + "counterpartClinic": "Kliniek", + "counterpartLab": "Laboratorium" + }, + "settings": { + "accountTitle": "Account", + "accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.", + "accountPlaceholder": "Wachtwoordwijziging en profielbewerking worden hierna hier aangesloten (bijv. uitnodigingsflow, wachtwoord herstellen).", + "subscriptionsTitle": "Abonnementen", + "subscriptionsSubtitle": "Uw DyoLink-werkruimteplan en plaatsen voor {orgName}. Kliniek- en laboratoriuminkomsten blijven onder het tabblad Facturatie in de zijbalk.", + "noSubscriptionNotice": "Deze organisatie heeft geen actief abonnement. Selecteer hieronder een abonnement om het aankoopproces te starten.", + "currentPlan": "Huidig abonnement", + "planPrice": "Abonnementsprijs", + "seatsUsed": "Plaatsen gebruikt", + "seatsRemaining": "Plaatsen over", + "daysRemaining": "Dagen over", + "unlimited": "Onbeperkt", + "unlimitedSeats": "Onbeperkte plaatsen", + "seatsCount": "{n} plaatsen", + "pricePerMonth": "${price} / maand", + "noActiveSubscription": "Geen actief abonnement voor deze organisatie.", + "trialEnded": "Proefperiode is beëindigd. Kies een abonnement wanneer afrekenen beschikbaar is.", + "trialEndsIn": "Proefperiode eindigt over {days} dag(en).", + "seatsLow": "Plaatsgebruik is hoog voor deze organisatie.", + "choosePlanIntro": "Kies een abonnement om door te gaan. Aankoopintegratie is nog niet actief, dus dit bereidt momenteel alleen de selectiestap voor.", + "planSolo": "Solo", + "planSmall": "Klein", + "planMedium": "Middel", + "planLarge": "Groot", + "planEnterprise": "Onderneming", + "startPurchase": "Aankoopproces starten", + "purchaseNotice": "Aankoopstroom wordt binnenkort ingeschakeld. {plan} is geselecteerd en klaar voor afrekenconfiguratie." + }, + "schedule": { + "defaultLabel": "Roosterdatum", + "previousDay": "Vorige dag", + "nextDay": "Volgende dag", + "chooseDate": "Kies roosterdatum", + "year": "Jaar", + "month": "Maand", + "day": "Dag", + "monthJanuary": "Januari", + "monthFebruary": "Februari", + "monthMarch": "Maart", + "monthApril": "April", + "monthMay": "Mei", + "monthJune": "Juni", + "monthJuly": "Juli", + "monthAugust": "Augustus", + "monthSeptember": "September", + "monthOctober": "Oktober", + "monthNovember": "November", + "monthDecember": "December" + } +} \ No newline at end of file diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f18b314..dc7e89f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,4 +1,7 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); function publicAppHostname(): string | null { const url = process.env.NEXT_PUBLIC_APP_URL; @@ -12,41 +15,29 @@ function publicAppHostname(): string | null { const appHost = publicAppHostname(); -/** @type {import('next').NextConfig} */ -const nextConfig = { - // Enable React strict mode +const nextConfig: NextConfig = { reactStrictMode: true, - - // Disable x-powered-by header for security poweredByHeader: false, - - // Configure allowed remote image sources (hostname derived from NEXT_PUBLIC_APP_URL at build time) images: { remotePatterns: [ - { protocol: "http", hostname: "localhost" }, + { protocol: 'http', hostname: 'localhost' }, ...(appHost ? [ - { protocol: "http" as const, hostname: appHost }, - { protocol: "https" as const, hostname: appHost }, + { protocol: 'http' as const, hostname: appHost }, + { protocol: 'https' as const, hostname: appHost }, ] : []), - { protocol: "https", hostname: "dyolink.com" }, - { protocol: "https", hostname: "www.dyolink.com" }, + { protocol: 'https', hostname: 'dyolink.com' }, + { protocol: 'https', hostname: 'www.dyolink.com' }, ], }, - - // Environment variables that will be available at build time env: { NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, }, - - // Output configuration - output: 'standalone', // Reduces Docker image size - - // Compress with gzip + output: 'standalone', compress: true, -} +}; -module.exports = nextConfig +export default withNextIntl(nextConfig); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08070aa..01aeb15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.577.0", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", @@ -462,6 +463,36 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz", + "integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.11", + "resolved": "https://registry.npmmirror.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz", + "integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.10" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.10", + "resolved": "https://registry.npmmirror.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz", + "integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.10", + "resolved": "https://registry.npmmirror.com/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz", + "integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -1247,6 +1278,313 @@ "node": ">=12.4.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1254,12 +1592,216 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmmirror.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1269,6 +1811,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmmirror.com/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz", @@ -2894,7 +3445,6 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4057,6 +4607,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/icu-minify": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz", + "integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -4109,6 +4674,16 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.8", + "resolved": "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-11.2.8.tgz", + "integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6", + "@formatjs/icu-messageformat-parser": "3.5.11" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4271,7 +4846,6 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4317,7 +4891,6 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5153,6 +5726,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmmirror.com/next/-/next-16.1.6.tgz", @@ -5206,6 +5788,94 @@ } } }, + "node_modules/next-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/next-intl/-/next-intl-4.13.0.tgz", + "integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.13.0", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.13.0", + "po-parser": "^2.1.1", + "use-intl": "^4.13.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz", + "integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -5234,6 +5904,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5510,6 +6186,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6622,6 +7304,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/use-intl/-/use-intl-4.13.0.tgz", + "integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.13.0", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f07681e..647a797 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.577.0", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", @@ -32,4 +33,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/frontend/scripts/build-messages.mjs b/frontend/scripts/build-messages.mjs new file mode 100644 index 0000000..d832eee --- /dev/null +++ b/frontend/scripts/build-messages.mjs @@ -0,0 +1,619 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const messagesDir = path.join(__dirname, '..', 'messages'); + +const en = { + common: { + appName: 'DyoLink', + loading: 'Loading...', + loadingApp: 'Loading app...', + loadingWorkspace: 'Loading workspace...', + continue: 'Continue', + back: 'Back', + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + edit: 'Edit', + next: 'Next', + dismiss: 'Dismiss', + or: 'Or', + and: 'and', + close: 'Close', + redirecting: 'Redirecting…', + readOnlyAccess: 'Read-only access for this organization.', + errorGeneric: 'Something went wrong', + loadingEllipsis: 'Loading...', + search: 'Search', + action: 'Action', + status: 'Status', + name: 'Name', + email: 'Email', + date: 'Date', + organization: 'Organization', + backToApp: '← Back to app', + copied: 'Copied', + copyLink: 'Copy link', + none: 'None', + preview: 'Preview', + }, + language: { + label: 'Language', + selectLanguage: 'Select language', + en: 'English', + fa: 'Persian', + nl: 'Dutch', + }, + theme: { + switchToLight: 'Switch to light mode', + switchToDark: 'Switch to dark mode', + lightMode: 'Light mode', + darkMode: 'Dark mode', + }, + nav: { + dashboard: 'Dashboard', + staff: 'Staff', + patients: 'Patients', + appointment: 'Appointment', + treatment: 'Treatment', + billing: 'Billing', + reports: 'Reports', + clinics: 'Clinics', + labs: 'Labs', + }, + auth: { + login: 'Login', + signIn: 'Sign in', + signOut: 'Log out', + register: 'Register', + startTrial: 'Start Trial', + startFreeTrial: 'Start Free Trial', + dashboard: 'Dashboard', + signInTitle: 'Sign in to your account', + signInPrompt: 'Or {link}', + startTrialLink: 'start your free trial', + registerTitle: 'Start your 30-day free trial', + registerPrompt: 'Already have an account?', + signInLink: 'Sign in', + email: 'Email address', + password: 'Password', + confirmPassword: 'Confirm password', + fullName: 'Full name', + rememberMe: 'Remember me', + forgotPassword: 'Forgot your password?', + invalidCredentials: 'Invalid email or password', + loginFailed: 'Login failed', + registrationFailed: 'Registration failed. Please try again.', + startMyFreeTrial: 'Start my free trial', + trialIncludes: 'Your trial includes:', + trialTeamMembers: 'Up to 5 team members', + trialFullAccess: 'Full access to all features', + trialNoCard: '30 days free, no credit card required', + termsAgreement: 'By signing up, you agree to our {terms} and {privacy}', + termsOfService: 'Terms of Service', + privacyPolicy: 'Privacy Policy', + signedIn: 'Signed in', + switchOrganization: 'Switch organization', + subscriptions: 'Subscriptions', + account: 'Account', + emailPlaceholder: 'you@example.com', + passwordPlaceholder: '••••••••', + namePlaceholder: 'John Doe', + termsIntro: 'By signing up, you agree to our', + errorRegistrationFailed: 'Registration failed', + errorLoginFailed: 'Login failed', + errorCreateOrganization: 'Failed to create organization', + acceptInviteTitle: 'Accept invitation', + loadingInvitation: 'Loading invitation...', + invalidInvitationLink: 'Invalid invitation link', + invitationAlreadyAccepted: 'This invitation is already accepted. You can log in now.', + errorLoadInvitation: 'Could not load invitation', + organizationLabel: 'Organization:', + emailLabel: 'Email:', + nameRequired: 'Name is required', + passwordMinLength8: 'Password must be at least 8 characters', + passwordsDoNotMatch: 'Passwords do not match', + labelName: 'Name', + labelCreatePassword: 'Create password', + labelConfirmPassword: 'Confirm password', + activateAccount: 'Activate account', + invitationAcceptedRedirect: 'Invitation Accepted. Redirecting to login...', + errorAcceptInvitation: 'Could not accept invitation', + alreadyHaveAccess: 'Already have access?', + goToLogin: 'Go to login', + acceptOrganizationTitle: 'Accept organization invitation', + alreadyHaveAccount: 'Already have an account?', + invitedBy: 'Invited by:', + ownerEmail: 'Owner email', + activateOrganization: 'Activate organization', + organizationAcceptedRedirect: 'Invitation accepted. Redirecting to login...', + stepAccount: 'Account', + stepOrganization: 'Organization', + organizationName: 'Organization name', + organizationNamePlaceholder: 'Sunshine Dental Clinic', + organizationEmail: 'Organization email', + organizationEmailPlaceholder: 'contact@sunshineclinic.com', + organizationType: 'Organization type', + dentalClinic: 'Dental Clinic', + dentalLab: 'Dental Lab', + }, + landing: { + heroTitle: 'Connect Dental Clinics & Labs', + heroHighlight: 'Seamlessly', + heroSubtitle: + 'Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.', + featureClinicsTitle: 'For Clinics', + featureClinicsDescription: + 'Manage patients, appointments, and send cases to labs instantly.', + featureLabsTitle: 'For Labs', + featureLabsDescription: 'Receive cases, track progress, and communicate with clinics.', + featureTeamTitle: 'Team Management', + featureTeamDescription: 'Add up to 5 team members during trial. Scale as you grow.', + featureTrialTitle: '30-Day Trial', + featureTrialDescription: 'Full access to all features. No credit card required.', + featureRealtimeTitle: 'Real-time Updates', + featureRealtimeDescription: 'Get instant notifications on case status changes.', + featureSecurityTitle: 'Secure & Compliant', + featureSecurityDescription: 'HIPAA-compliant with enterprise-grade security.', + footerCopyright: '© 2026 DyoLink. All rights reserved.', + termsAndConditions: 'Terms & Conditions', + }, + accountMenu: { + noActiveSubscription: 'No active subscription — review Subscriptions', + trialEnded: 'Trial ended — review Subscriptions', + trialEndingSoon: 'Trial ending soon — review Subscriptions', + seatsLow: 'Seats running low — review Subscriptions', + reviewSubscriptions: 'Review Subscriptions', + }, + validation: { + emailInvalid: 'Please enter a valid email address', + passwordRequired: 'Password is required', + nameMinLength: 'Name must be at least 2 characters', + passwordMinLength: 'Password must be at least 8 characters', + passwordUppercase: 'Password must contain at least one uppercase letter', + passwordNumber: 'Password must contain at least one number', + organizationNameMinLength: 'Organization name must be at least 2 characters', + organizationEmailInvalid: 'Please enter a valid organization email', + organizationTypeRequired: 'Please select organization type', + passwordsDoNotMatch: "Passwords don't match", + }, + today: { + welcomeBack: 'Welcome back!!', + noSubscriptionNotice: 'This organization does not have an active subscription yet.', + choosePlanLink: 'Choose a plan', + noSubscriptionCta: 'to start the purchase process.', + cardTodaysAppointments: "Today's Appointments", + cardActivePatients: 'Active Patients', + cardNewLabCase: 'New Lab Case', + cardTodayInvoices: 'Today invoices', + }, + staff: { + redirecting: 'Redirecting…', + title: 'Staff Management', + subtitle: 'Invite teammates, set tab access, and stay within your plan seat limit.', + inviteMember: 'Invite member', + seatsLabel: 'Seats:', + unlimitedPlan: '(unlimited plan)', + seatLimitReached: 'Plan seat limit reached for this organization.', + noActivePlan: + 'No active plan selected for this organization. Choose a subscription plan to invite members.', + invitedPending: + 'Invitation is pending until they open the link, set a password, and log in.', + invitedAccepted: 'Invitation was accepted immediately.', + inviteLinkHeading: 'Invite link', + shareLinkHint: + 'Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.', + loadingTeam: 'Loading team…', + tableName: 'Name', + tableEmail: 'Email', + tableRole: 'Role', + tableStatus: 'Status', + tableAccess: 'Access', + tableAction: 'Action', + roleOwner: 'Owner', + roleStaff: 'Staff', + statusActive: 'Active', + statusPending: 'Pending', + statusDisabled: 'Disabled', + statusExpired: 'Expired', + allFeatures: 'All features', + inviteModalTitle: 'Invite team member', + stepOf: 'Step {step} of 2', + permissionView: 'View', + permissionEdit: 'Edit', + labelEmail: 'Email', + labelDisplayName: 'Display name', + tabAccess: 'Tab access', + sendInvite: 'Send invite', + skipForNow: 'Skip for now', + enableModalTitle: 'Enable team member', + enableConfirm: 'Enable {name} ({email})?', + enableBullet1: 'They can sign in to this organization again with their existing account.', + enableBullet2: 'No new invitation is sent and no data was removed while they were disabled.', + enableBullet3: 'Enabling uses one seat on your plan.', + noSeatsAvailable: + 'No seats are available. Disable another member or upgrade your plan before enabling this person.', + enableMemberButton: 'Enable member', + disableModalTitle: 'Disable team member', + disableConfirm: 'Disable {name} ({email})?', + disableBullet1: 'They will not be able to sign in to this organization.', + disableBullet2: 'No data will be removed.', + disableBullet3: 'Disabling frees one seat on your plan so you can invite someone else.', + disableMemberButton: 'Disable member', + editModalTitle: 'Edit member', + loadingWorkingHours: 'Loading working hours…', + errorLoadStaff: 'Failed to load staff.', + errorCopyInvite: 'Could not copy invitation link.', + errorSendInvite: 'Failed to send invitation.', + errorLoadWorkingHours: 'Failed to load working hours.', + successMemberUpdated: 'Member updated.', + errorUpdateMember: 'Failed to update member.', + errorDeleteNotImplemented: 'Delete is not implemented yet.', + successMemberDisabled: '{name} was disabled. A seat is now available.', + errorDisableMember: 'Failed to disable member.', + successMemberEnabled: '{name} was enabled and can sign in again.', + errorEnableMember: 'Failed to enable member.', + successInvited: '{name} ({email}) was invited.', + copyInviteLink: 'Copy invitation link', + copyInviteLinkTitle: 'Copy invitation link (generates a new link if needed)', + enableMemberAria: 'Enable member', + enableMemberTitle: 'Enable member (uses a seat)', + disableMemberAria: 'Disable member', + disableMemberTitle: 'Disable member (frees a seat)', + editMemberAria: 'Edit member', + deleteMemberAria: 'Delete member', + deleteMemberTitle: 'Delete member (not implemented)', + features: { + featureToday: 'Today', + featureStaff: 'Staff', + featureOrganizations: 'Organizations', + featureClinics: 'Clinics', + featureLabs: 'Labs', + featurePatients: 'Patients', + featureAppointment: 'Appointment', + featureTreatment: 'Treatment', + featureBilling: 'Billing', + featureReports: 'Reports', + noTabAccess: 'No tab access', + readOnlySuffix: '(Read only)', + }, + workingHours: { + recommendedTitle: 'Working hours recommended', + recommendedBody: + 'Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.', + intro: + 'Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.', + workingDay: 'Working day', + start: 'Start', + end: 'End', + removeShift: 'Remove shift', + addShift: 'Add shift', + autoRepeatWeekly: 'Repeat these hours at the start of each week (copy forward on Monday)', + weekdayMon: 'Mon', + weekdayTue: 'Tue', + weekdayWed: 'Wed', + weekdayThu: 'Thu', + weekdayFri: 'Fri', + weekdaySat: 'Sat', + weekdaySun: 'Sun', + validationNeedsShift: '{day} needs at least one shift or should be marked off.', + validationEndAfterStart: '{day} shift end time must be after start time.', + validationOverlap: '{day} shifts cannot overlap.', + }, + }, + patients: { + title: 'Patients', + newPatient: 'New Patient', + errorLoadPatients: 'Failed to load patients.', + successPatientSaved: 'Patient {firstName} {lastName} was saved successfully.', + errorSavePatient: 'Failed to save patient.', + firstName: 'First name', + lastName: 'Last name', + phone: 'Phone', + savePatient: 'Save Patient', + dialogTitle: 'New patient', + searchPlaceholder: 'Search patients by name, phone, email', + loadingPatients: 'Loading patients...', + noResults: 'No patients found for this search.', + noContact: 'No contact', + selectPatient: 'Select a patient to view details.', + phoneLabel: 'Phone:', + emailLabel: 'Email:', + statusLabel: 'Status:', + statusActive: 'Active', + statusInactive: 'Inactive', + emptyValue: '-', + }, + appointments: { + title: 'Appointments', + subtitle: 'Search a patient, pick a date, then click a time slot under a provider to book.', + loadingSchedule: 'Loading schedule…', + infoPastViewOnly: 'Past appointments are view-only.', + infoSelectPatient: 'Select a patient before booking.', + errorOutsideHours: + "This appointment falls outside the provider's current working hours and cannot be edited.", + successUpdated: 'Appointment updated.', + successSaved: 'Appointment saved.', + errorUpdate: 'Could not update appointment.', + errorSave: 'Could not save appointment.', + confirmRemove: 'Remove this appointment?', + successRemoved: 'Appointment removed.', + errorDelete: 'Could not delete appointment.', + errorLoadSchedule: 'Failed to load schedule.', + successPatientSaved: 'Patient {firstName} {lastName} was saved.', + searchPlaceholder: 'Search existing patients', + searching: 'Searching…', + searchHint: 'Type to search patients by name, phone, or email.', + noPermissionAdd: 'You do not have permission to add patients.', + editTitle: 'Edit appointment', + newTitle: 'New appointment', + providerLabel: 'Provider:', + patientLabel: 'Patient', + startLabel: 'Start', + endLabel: 'End', + purposeLabel: 'Purpose', + errorSelectPatient: 'Select a patient first.', + errorEndAfterStart: 'End time must be after start time.', + errorPastSchedule: 'Cannot schedule in the past.', + errorPastViewOnly: 'Past appointments are view-only.', + errorMissingDetails: 'Missing appointment details.', + noProviders: + 'No providers available. Add staff with treatment edit access to see columns here.', + noWorkingHours: + 'No working hours are configured for this day. Set provider working hours in Staff management.', + noHoursSet: 'No hours set', + offToday: 'Off today', + slotOffToday: 'Provider is off today', + slotHoursNotConfigured: 'Working hours not configured', + slotOutsideHours: 'Outside working hours', + slotCannotCreate: 'You cannot create appointments', + slotBookAt: 'Book {time}', + outsideHoursBlocked: 'Outside working hours — editing blocked', + overlappingChoose: '{count} overlapping — click to choose', + overlapping: '{count} overlapping', + legend: 'Legend', + overlappingTitle: 'Overlapping appointments ({count})', + purposeConsultation: 'Consultation', + purposeFilling: 'Filling', + purposeEndo: 'Endo', + purposeVisit: 'Visit', + purposeHygiene: 'Hygiene', + }, + treatment: { + loading: 'Loading…', + noPermissionTitle: 'Treatment workspace', + noPermissionBody: 'You do not have permission to view the Treatment tab for this organization.', + title: 'Treatment', + subtitleEdit: + 'Document cases for your appointments, save drafts, and send work to linked organizations.', + subtitleReadOnly: + 'View-only access — you can review appointments and treatment history but cannot edit.', + pastDayNotice: + 'Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.', + selectedPatient: 'Selected patient', + purposeLabel: 'Purpose:', + loadingAppointments: 'Loading appointments…', + selectDayWithAppointment: 'Select a day with at least one appointment.', + confirmDiscard: 'You have unsaved changes. Discard them and continue?', + successDraftSaved: 'Treatment draft saved.', + errorChooseOrg: 'Choose at least one active organization to send this case.', + successCaseSent: 'Case sent to selected organizations.', + successFilesUploaded: '{count} file(s) uploaded successfully.', + errorLoadAppointments: 'Failed to load appointments.', + errorLoadOrgs: 'Failed to load linked organizations.', + errorLoadHistory: 'Failed to load treatment history.', + errorLoadDraft: 'Failed to load treatment draft.', + errorUpload: 'Failed to upload attachments.', + errorSaveDraft: 'Failed to save treatment draft.', + errorSendCase: 'Failed to send case.', + errorCaseMustSave: 'Case must be saved before sending.', + draftTitle: 'Draft · {patientName}', + hiddenMessage: 'Appointments are hidden.', + showAppointments: 'Show appointments', + appointmentsTitle: 'My appointments', + hideAppointments: 'Hide appointments', + emptyDay: 'No appointments assigned to you on this day.', + casesTitle: 'Treatment cases', + casesSubtitle: 'Each case has its own teeth, notes, attachments, and destinations for send.', + addCase: 'Add case', + caseLabel: 'Case {n}', + comments: 'Comments', + commentsPlaceholder: 'Write clinical notes for this case…', + treatmentType: 'Treatment type', + typeConsultation: 'consultation', + typeFilling: 'filling', + typeEndo: 'endo', + typeVisit: 'visit', + typeHygiene: 'hygiene', + attachments: 'Attachments', + attachFiles: 'Attach files for this treatment case', + chooseFiles: 'Choose files', + sendToOrgs: 'Send this case to linked organizations', + searchOrgsPlaceholder: 'Search active organizations...', + recent: 'Recent:', + noOrgMatch: 'No active organization matches your search.', + sendThisCase: 'Send this case', + saveDraft: 'Save treatment draft', + unsavedChanges: 'Unsaved changes', + draftSaved: 'Draft saved', + sendSavesFirst: 'Sending is per case and saves first automatically.', + historyTitle: 'Previous treatments', + historySubtitle: 'Completed treatments for this patient. Each case is listed separately.', + loadingHistory: 'Loading history…', + historyEmpty: 'No prior treatments for this patient.', + statusLabel: 'Status:', + historyCaseLabel: 'Case {n} · {type}', + teethLabel: 'Teeth:', + teethNone: 'None selected', + reviewDetails: 'Review details', + previewTitle: 'Treatment preview', + previewDraft: 'Preview current draft', + selectAppointment: 'Select an appointment to preview its draft.', + caseCount: '{n} case(s)', + attachmentCount: '{n} attachment(s)', + caseSummary: 'Case {n}: {type}', + teethPrefix: '· Teeth', + moreCases: '+ {n} more case(s)', + previewDialogTitle: 'Treatment preview', + previewDialogSubtitle: 'Review cases, attachments, and send destinations.', + noCases: 'No cases in this treatment.', + typeLabel: 'Type:', + commentsLabel: 'Comments:', + commentsEmpty: 'Comments: —', + attachFilesShort: 'Attach files', + sendCase: 'Send this case', + sendToLinkedOrgs: 'Send to linked organizations', + noActiveOrgs: 'No active linked organizations.', + confirmSend: 'Confirm send', + toothChartTitle: 'FDI tooth chart', + toothChartHint: 'Tap teeth to multi-select. Applies to the active case.', + selectedLabel: 'Selected:', + selectedEmpty: '—', + upperArch: 'Upper arch', + lowerArch: 'Lower arch', + toothAria: 'FDI tooth {fdi}', + toothSelectedSuffix: ', selected', + sentToAt: 'Sent to {orgName} at {datetime}', + fallbackOrgName: 'organization', + }, + organizations: { + loadingOrganization: 'Loading organization...', + subtitle: + 'Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.', + invitationHistory: 'Invitation History', + searchPlaceholder: 'Search {counterpart} by name, email, or phone...', + backToList: 'Back to list', + tableOrganization: 'Organization', + tableOwnerEmail: 'Owner email', + tableDate: 'Date', + tableStatus: 'Status', + tableAction: 'Action', + emptyConnections: 'No connections yet. Search to send a connection request or an invitation link.', + statusInvitationPending: 'Invitation pending', + statusConnectionPending: 'Connection request pending', + statusConnected: 'Connected', + statusDeclined: 'Connection request declined', + statusFound: 'Found', + statusToday: 'Today', + acceptRequest: 'Accept connection request', + declineRequest: 'Decline connection request', + removeConnection: 'Remove connection', + sendRequest: 'Send connection request', + noDirectoryResults: 'No organization found in directory search.', + hideInvitationFields: 'Hide invitation fields', + sendInvitationLink: 'Send invitation link', + counterpartNameLabel: '{counterpart} name', + ownerEmailLabel: 'Owner email', + sendInvitation: 'Send invitation', + successConnectionSent: '{counterpart} connection request sent.', + successInviteCreated: 'Invitation link created for {email}', + successLinkCopied: 'Invitation link copied to clipboard.', + successAccepted: 'Connection request accepted.', + successDeclined: 'Connection request declined.', + successRemoved: 'Connection removed.', + historyTitle: 'Invitation History', + loadingHistory: 'Loading invitation history...', + historyEmpty: 'No invitations yet.', + tableInvitationLink: 'Invitation link', + statusPending: 'Invitation pending', + statusAccepted: 'Invitation accepted', + statusRejected: 'Invitation rejected', + statusExpired: 'Invitation expired', + copyInvitationLink: 'Copy invitation link', + copyInvitationLinkTitle: 'Copy invitation link (generates a new link if needed)', + selectorTitle: 'Organizations', + selectorSubtitleWithCreate: 'Select an organization to continue, or create a new one.', + selectorSubtitleSelectOnly: 'Select an organization to continue.', + createOrganization: 'Create Organization', + createAndContinue: 'Create and Continue', + emptyCanCreate: 'No organizations found. Create your first one to continue.', + emptyAskOwner: 'No organizations found. Ask an organization owner to invite you.', + continueArrow: 'Continue →', + planLabel: 'Plan: {name} • {maxUsers} users', + }, + settings: { + accountTitle: 'Account', + accountSubtitle: 'Profile and security settings for your login.', + accountPlaceholder: + 'Password change and profile editing will be wired here next (e.g. invite flow, reset password).', + subscriptionsTitle: 'Subscriptions', + subscriptionsSubtitle: + 'Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.', + noSubscriptionNotice: + 'This organization has no active subscription. Select a plan below to start the purchase process.', + currentPlan: 'Current plan', + planPrice: 'Plan price', + seatsUsed: 'Seats used', + seatsRemaining: 'Seats remaining', + daysRemaining: 'Days remaining', + unlimited: 'Unlimited', + unlimitedSeats: 'Unlimited seats', + seatsCount: '{n} seats', + pricePerMonth: '${price} / month', + noActiveSubscription: 'No active subscription for this organization.', + trialEnded: 'Trial period has ended. Choose a plan when checkout is available.', + trialEndsIn: 'Trial ends in {days} day(s).', + seatsLow: 'Seat usage is high for this organization.', + choosePlanIntro: + 'Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.', + planSolo: 'Solo', + planSmall: 'Small', + planMedium: 'Medium', + planLarge: 'Large', + planEnterprise: 'Enterprise', + startPurchase: 'Start purchase process', + purchaseNotice: + 'Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup.', + }, + schedule: { + defaultLabel: 'Schedule date', + previousDay: 'Previous day', + nextDay: 'Next day', + chooseDate: 'Choose schedule date', + year: 'Year', + month: 'Month', + day: 'Day', + monthJanuary: 'January', + monthFebruary: 'February', + monthMarch: 'March', + monthApril: 'April', + monthMay: 'May', + monthJune: 'June', + monthJuly: 'July', + monthAugust: 'August', + monthSeptember: 'September', + monthOctober: 'October', + monthNovember: 'November', + monthDecember: 'December', + }, +}; + +function deepMerge(base, overlay) { + const result = { ...base }; + for (const key of Object.keys(base)) { + const baseVal = base[key]; + const overlayVal = overlay?.[key]; + if (baseVal && typeof baseVal === 'object' && !Array.isArray(baseVal)) { + result[key] = deepMerge(baseVal, overlayVal ?? {}); + } else if (overlayVal !== undefined) { + result[key] = overlayVal; + } + } + return result; +} + +function writeJson(file, data) { + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} + +writeJson(path.join(messagesDir, 'en.json'), en); + +for (const locale of ['fa', 'nl']) { + const file = path.join(messagesDir, `${locale}.json`); + const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + writeJson(file, deepMerge(en, existing)); +} + +console.log('Messages built: en.json updated; fa.json and nl.json merged with existing translations.'); diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx deleted file mode 100644 index 744c193..0000000 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ /dev/null @@ -1,145 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -import { CreatePatientInput, Patient } from '@/types/patient'; -import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect'; -import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - phone: '', - email: '', -}; - -export default function PatientsPage() { - const { currentOrganization } = useAuth(); - const toast = useToast(); - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to load patients.')); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - toast.showSuccess( - `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, - ); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to save patient.')); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

Patients

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx deleted file mode 100644 index a0c21ee..0000000 --- a/frontend/src/app/(public)/login/page.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// src/app/login/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 } from 'lucide-react'; -// import { useAuth } from '@/lib/hooks/useAuth'; -// import { Button } from '@/components/ui/Button'; -// import { Input } from '@/components/ui/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 } = useAuth(); -// const [error, setError] = useState(null); -// 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'); -// } -// }; - -// return ( -//
-//
-// -// DyoLink -// -//

-// Sign in to your account -//

-//

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

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

{error}

-//
-// )} -// -//
-//
-//
-//
-// ); -// } -'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 85% rename from frontend/src/app/(dashboard)/appointments/page.tsx rename to frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index ef0152d..10207e0 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -28,6 +29,8 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { }; export default function AppointmentsPage() { + const t = useTranslations('appointments'); + const tPatients = useTranslations('patients'); const { currentOrganization } = useAuth(); const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); @@ -46,7 +49,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 +90,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) { @@ -99,13 +102,13 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - toast.showError(formatApiErrorMessage(err, 'Failed to load schedule.')); + toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule'))); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); } } - }, [currentOrganization?.id, scheduleDate]); + }, [currentOrganization?.id, scheduleDate, t]); useEffect(() => { void loadSchedule(); @@ -149,28 +152,33 @@ export default function AppointmentsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - toast.showSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) - : 'Failed to save patient.'; + : tPatients('errorSavePatient'); toast.showError(message); } finally { setSavingPatient(false); } } - 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.'); + toast.showInfo(t('infoPastViewOnly')); return; } if (!selectedPatient) { - toast.showInfo('Select a patient before booking.'); + toast.showInfo(t('infoSelectPatient')); return; } - setBookingHour(hour); + setBookingStartMinute(startMinute); setBookingProviderId(providerUserId); setBookingProviderName(providerName); setEditingAppointmentId(null); @@ -179,17 +187,22 @@ export default function AppointmentsPage() { function handleAppointmentClick(appointment: AppointmentRecord) { if (isViewingPastDay) { - toast.showInfo('Past appointments are view-only.'); + toast.showInfo(t('infoPastViewOnly')); 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(t('errorOutsideHours')); + } + async function handleSaveAppointment(payload: { patientId: string; providerUserId: string; @@ -207,15 +220,15 @@ export default function AppointmentsPage() { } setBookingOpen(false); setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : activeEditingAppointment - ? 'Could not update appointment.' - : 'Could not save appointment.'; + ? t('errorUpdate') + : t('errorSave'); toast.showError(message); } finally { setSavingAppointment(false); @@ -226,7 +239,7 @@ export default function AppointmentsPage() { if (!activeEditingAppointment) { return; } - if (!window.confirm('Remove this appointment?')) { + if (!window.confirm(t('confirmRemove'))) { return; } setDeletingAppointment(true); @@ -235,13 +248,13 @@ export default function AppointmentsPage() { await appointmentsApi.remove(activeEditingAppointment.id); setBookingOpen(false); setEditingAppointmentId(null); - toast.showSuccess('Appointment removed.'); + toast.showSuccess(t('successRemoved')); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) - : 'Could not delete appointment.'; + : t('errorDelete'); toast.showError(message); } finally { setDeletingAppointment(false); @@ -251,10 +264,8 @@ export default function AppointmentsPage() { return (
-

Appointments

-

- Search a patient, pick a date, then click a time slot under a provider to book. -

+

{t('title')}

+

{t('subtitle')}

@@ -289,7 +300,7 @@ export default function AppointmentsPage() { onChange={(d) => setScheduleDate(startOfLocalDay(d))} /> {loadingSchedule && ( -

Loading schedule…

+

{t('loadingSchedule')}

)}
@@ -298,8 +309,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 +322,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)/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 84% rename from frontend/src/app/(dashboard)/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index df889a0..c0ffcfc 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -29,32 +30,6 @@ function formatOrganizationStatusLabel(status: string): string { return lower.charAt(0).toUpperCase() + lower.slice(1); } -function formatConnectionStatusLabel( - row: CounterpartItemDto, - currentOrganizationId: string, -): string { - if (row.status === 'PENDING') { - if ( - row.pendingInvitationId && - row.requestedByOrganizationId === currentOrganizationId - ) { - return 'Invitation pending'; - } - return 'Connection request pending'; - } - if (row.status === 'ACTIVE') return 'Connected'; - if (row.status === 'REJECTED') return 'Connection request declined'; - return formatOrganizationStatusLabel(row.status); -} - -function formatApiMessage(err: unknown): string { - if (!err || typeof err !== 'object') return 'Something went wrong'; - const m = (err as ApiError).message; - if (Array.isArray(m)) return m.join(', '); - if (typeof m === 'string') return m; - return 'Something went wrong'; -} - function formatTableDate(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return '\u2014'; @@ -64,10 +39,42 @@ function formatTableDate(value: string): string { type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { + const t = useTranslations('organizations'); + const tNav = useTranslations('nav'); + const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); const toast = useToast(); + const formatApiMessage = useCallback( + (err: unknown): string => { + if (!err || typeof err !== 'object') return tCommon('errorGeneric'); + const m = (err as ApiError).message; + if (Array.isArray(m)) return m.join(', '); + if (typeof m === 'string') return m; + return tCommon('errorGeneric'); + }, + [tCommon], + ); + + const formatConnectionStatusLabel = useCallback( + (row: CounterpartItemDto, currentOrganizationId: string): string => { + if (row.status === 'PENDING') { + if ( + row.pendingInvitationId && + row.requestedByOrganizationId === currentOrganizationId + ) { + return t('statusInvitationPending'); + } + return t('statusConnectionPending'); + } + if (row.status === 'ACTIVE') return t('statusConnected'); + if (row.status === 'REJECTED') return t('statusDeclined'); + return formatOrganizationStatusLabel(row.status); + }, + [t], + ); + const [query, setQuery] = useState(''); const [mode, setMode] = useState('existing'); const [searching, setSearching] = useState(false); @@ -92,8 +99,9 @@ export default function OrganizationsPage() { pruneAcceptedLinks, } = useOrganizationInviteLinkCopy(currentOrganization?.id); - const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; - const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; + const counterpart = + currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab'); + const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs'); const existingRows = items; @@ -143,7 +151,7 @@ export default function OrganizationsPage() { toast.setError(''); try { await organizationApi.createConnectionRequest(targetOrganizationId); - toast.showSuccess(`${counterpartLabel} connection request sent.`); + toast.showSuccess(t('successConnectionSent', { counterpart })); setSearchResults([]); setQuery(''); setMode('existing'); @@ -164,7 +172,7 @@ export default function OrganizationsPage() { ownerEmail: manualOwnerEmail.trim(), }); storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); - toast.showSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); + toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() })); setManualOrganizationName(''); setManualOwnerEmail(''); setShowInviteForm(false); @@ -207,7 +215,7 @@ export default function OrganizationsPage() { await loadInvitationHistory(); }, }); - toast.showSuccess('Invitation link copied to clipboard.'); + toast.showSuccess(t('successLinkCopied')); } catch (e) { toast.showError(formatApiMessage(e)); } @@ -233,7 +241,7 @@ export default function OrganizationsPage() { }, }, ); - toast.showSuccess('Invitation link copied to clipboard.'); + toast.showSuccess(t('successLinkCopied')); } catch (e) { toast.showError(formatApiMessage(e)); } @@ -245,7 +253,7 @@ export default function OrganizationsPage() { try { await organizationApi.respondToConnectionRequest(connectionId, action); toast.showSuccess( - action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.', + action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), ); notifyPendingConnectionsChanged(); await loadList(); @@ -261,7 +269,7 @@ export default function OrganizationsPage() { toast.setError(''); try { await organizationApi.deleteConnection(connectionId); - toast.showSuccess('Connection removed.'); + toast.showSuccess(t('successRemoved')); await loadList(); } catch (e) { toast.showError(formatApiMessage(e)); @@ -278,7 +286,7 @@ export default function OrganizationsPage() { } if (!currentOrganization) { - return

Loading organization...

; + return

{t('loadingOrganization')}

; } return ( @@ -286,13 +294,10 @@ export default function OrganizationsPage() {

{tabLabel}

-

- Search organizations, send connection requests to existing accounts, or invitation - links when they are not on DyoLink yet. -

+

{t('subtitle')}

@@ -302,7 +307,7 @@ export default function OrganizationsPage() { value={query} onChange={setQuery} onSubmit={() => void runSearch()} - placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`} + placeholder={t('searchPlaceholder', { counterpart: counterpart.toLowerCase() })} actions={ <> {mode === 'search' && ( )} @@ -330,19 +335,19 @@ export default function OrganizationsPage() { headers={ - Organization + {t('tableOrganization')} - Owner email + {t('tableOwnerEmail')} - Date + {t('tableDate')} - Status + {t('tableStatus')} - Action + {t('tableAction')} } @@ -351,14 +356,14 @@ export default function OrganizationsPage() { {loading ? ( - Loading... + {tCommon('loadingEllipsis')} ) : mode === 'existing' ? ( existingRows.length === 0 ? ( - No connections yet. Search to send a connection request or an invitation link. + {t('emptyConnections')} ) : ( @@ -403,8 +408,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50" disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')} - aria-label="Accept connection request" - title="Accept connection request" + aria-label={t('acceptRequest')} + title={t('acceptRequest')} > @@ -413,8 +418,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} onClick={() => void respondToPendingConnection(row.id, 'REJECT')} - aria-label="Decline connection request" - title="Decline connection request" + aria-label={t('declineRequest')} + title={t('declineRequest')} > @@ -426,8 +431,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id} onClick={() => void deleteConnection(row.id)} - aria-label="Remove connection" - title="Remove connection" + aria-label={t('removeConnection')} + title={t('removeConnection')} > @@ -443,9 +448,9 @@ export default function OrganizationsPage() { {r.name} {r.owner.email} - Today + {t('statusToday')} - Found + {t('statusFound')} @@ -468,22 +473,22 @@ export default function OrganizationsPage() {

- No organization found in directory search. + {t('noDirectoryResults')}

{showInviteForm && (
setManualOrganizationName(e.target.value)} /> setManualOwnerEmail(e.target.value)} @@ -496,7 +501,7 @@ export default function OrganizationsPage() { onClick={() => void sendInvite()} className="w-full" > - Send invitation + {t('sendInvitation')}
diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx new file mode 100644 index 0000000..61a5f21 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; +import { CreatePatientInput, Patient } from '@/types/patient'; +import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + phone: '', + email: '', +}; + +export default function PatientsPage() { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const toast = useToast(); + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [loadingPatients, setLoadingPatients] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + async function loadPatients(q: string) { + setLoadingPatients(true); + toast.setError(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + return ( +
+
+

{t('title')}

+ +
+ + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )} + +
+
+ +
+ +
+ +
+
+
+ ); +} 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 50% rename from frontend/src/app/(dashboard)/settings/account/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 357f4f9..e52411a 100644 --- a/frontend/src/app/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,8 +1,12 @@ 'use client'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; export default function AccountSettingsPage() { + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); + return (
@@ -10,19 +14,14 @@ export default function AccountSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')} -

Account

-

- Profile and security settings for your login. -

+

{t('accountTitle')}

+

{t('accountSubtitle')}

-

- Password change and profile editing will be wired here next (e.g. invite - flow, reset password). -

+

{t('accountPlaceholder')}

); diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx similarity index 72% rename from frontend/src/app/(dashboard)/settings/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx index ccbdb3b..7ad39d8 100644 --- a/frontend/src/app/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx @@ -1,9 +1,12 @@ 'use client'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { + const tCommon = useTranslations('common'); + return (
@@ -11,7 +14,7 @@ export default function DashboardOrganizationsSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')}
diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx similarity index 71% rename from frontend/src/app/(dashboard)/settings/subscriptions/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 7116c8c..01836c4 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +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'; @@ -10,14 +10,16 @@ import { Toast } from '@/components/ui/shared/Toast'; import type { SubscriptionAlertData } from '@/types/subscription'; const PLAN_OPTIONS = [ - { id: 'solo', name: 'Solo', maxUsers: 1, price: 19 }, - { id: 'small', name: 'Small', maxUsers: 5, price: 49 }, - { id: 'medium', name: 'Medium', maxUsers: 10, price: 89 }, - { id: 'large', name: 'Large', maxUsers: 15, price: 129 }, - { id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 }, + { id: 'solo', nameKey: 'planSolo' as const, maxUsers: 1, price: 19 }, + { id: 'small', nameKey: 'planSmall' as const, maxUsers: 5, price: 49 }, + { id: 'medium', nameKey: 'planMedium' as const, maxUsers: 10, price: 89 }, + { id: 'large', nameKey: 'planLarge' as const, maxUsers: 15, price: 129 }, + { id: 'enterprise', nameKey: 'planEnterprise' as const, maxUsers: null, price: 199 }, ] as const; export default function SubscriptionsSettingsPage() { + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const router = useRouter(); const [alert, setAlert] = useState(null); @@ -39,13 +41,13 @@ export default function SubscriptionsSettingsPage() { if (!currentOrganization) { return ( -

Loading...

+

{tCommon('loadingEllipsis')}

); } if (!currentOrganization.isOwner) { return ( -

Redirecting...

+

{tCommon('redirecting')}

); } @@ -76,55 +78,51 @@ export default function SubscriptionsSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')} -

Subscriptions

+

{t('subscriptionsTitle')}

- Your DyoLink workspace plan and seats for{' '} - {currentOrganization.name}. - Clinic and lab income tracking stays under the sidebar{' '} - Billing tab. + {t('subscriptionsSubtitle', { orgName: currentOrganization.name })}

{!hasActiveSubscription && (
-

- This organization has no active subscription. Select a plan below to start - the purchase process. -

+

{t('noSubscriptionNotice')}

)}
-

Current plan

+

{t('currentPlan')}

{plan?.name ?? '—'}

-

Plan price

+

{t('planPrice')}

{typeof plan?.price === 'number' ? `$${plan.price}` : '—'}

-

Seats used

+

{t('seatsUsed')}

{typeof seatsUsed === 'number' ? seatsUsed : '—'} - {typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''} + {typeof maxUsers === 'number' + ? ` / ${isUnlimited ? t('unlimited') : maxUsers}` + : ''}

-

Seats remaining

+

{t('seatsRemaining')}

- {isUnlimited ? 'Unlimited' : seatsRemaining ?? '—'} + {isUnlimited ? t('unlimited') : seatsRemaining ?? '—'}

-

Days remaining

+

{t('daysRemaining')}

{daysUntilPlanEnd ?? '—'}

@@ -134,27 +132,24 @@ export default function SubscriptionsSettingsPage() { {alert?.showWarning && (
{alert.noActiveSubscription && ( -

No active subscription for this organization.

+

{t('noActiveSubscription')}

)} {alert.trialExpired && ( -

Trial period has ended. Choose a plan when checkout is available.

+

{t('trialEnded')}

)} {!alert.trialExpired && alert.trialEndingSoon && (

- Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s). + {t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })}

)} {!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && ( -

Seat usage is high for this organization.

+

{t('seatsLow')}

)}
)}
-

- Choose a plan to continue. Purchase integration is not active yet, so this - currently prepares the selection step only. -

+

{t('choosePlanIntro')}

{PLAN_OPTIONS.map((option) => { const selected = selectedPlanId === option.id; @@ -169,11 +164,15 @@ export default function SubscriptionsSettingsPage() { : 'border-border hover:border-border-strong' }`} > -

{option.name}

+

{t(option.nameKey)}

- {option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`} + {option.maxUsers == null + ? t('unlimitedSeats') + : t('seatsCount', { n: option.maxUsers })} +

+

+ {t('pricePerMonth', { price: option.price })}

-

${option.price} / month

); })} @@ -182,13 +181,13 @@ export default function SubscriptionsSettingsPage() { type="button" variant="primary" onClick={() => { - const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan'; - setPurchaseNotice( - `Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`, - ); + const selectedPlanLabel = selectedPlan + ? t(selectedPlan.nameKey) + : t('planSolo'); + setPurchaseNotice(t('purchaseNotice', { plan: selectedPlanLabel })); }} > - Start purchase process + {t('startPurchase')}
diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx similarity index 64% rename from frontend/src/app/(dashboard)/staff/page.tsx rename to frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 0f8eb87..73a0171 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; import { firstAccessibleDashboardPath, canEditStaff, @@ -12,10 +13,18 @@ import { permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, + featureStateHasTreatmentEdit, resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, -} from '../../../components/staff/staff-permission-form'; +} from '@/components/staff/staff-permission-form'; +import { + StaffWorkingHoursStep, + createDefaultWorkingHoursState, + workingHoursPayloadFromState, + workingHoursStateFromApi, +} from '@/components/staff/StaffWorkingHoursStep'; +import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours'; import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -82,6 +91,9 @@ function PermissionGrid({ disabled?: boolean; organizationType?: 'CLINIC' | 'LAB'; }) { + const t = useTranslations('staff'); + const tFeatures = useTranslations('staff.features'); + const setRead = (editKey: string, read: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ @@ -108,19 +120,19 @@ function PermissionGrid({ className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3" > - {resolveStaffFeatureLabel(g, organizationType)} + {resolveStaffFeatureLabel(g, organizationType, tFeatures)}
setRead(g.edit, v)} /> setEdit(g.edit, v)} />
@@ -133,6 +145,10 @@ function PermissionGrid({ export default function StaffPage() { const router = useRouter(); + const t = useTranslations('staff'); + const tCommon = useTranslations('common'); + const tFeatures = useTranslations('staff.features'); + const tWorkingHours = useTranslations('staff.workingHours'); const { currentOrganization, user } = useAuth(); const [members, setMembers] = useState([]); const [seats, setSeats] = useState<{ @@ -144,9 +160,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 +182,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 +198,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; @@ -190,11 +224,11 @@ export default function StaffPage() { setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to load staff.')); + toast.showError(formatApiErrorMessage(e, t('errorLoadStaff'))); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { if (!currentOrganization?.id) return; @@ -265,25 +299,66 @@ export default function StaffPage() { await load(); } } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); + toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } } - 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, tWorkingHours); + 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, tWorkingHours); + 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,47 +379,79 @@ 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.')); + toast.showError(formatApiErrorMessage(e, t('errorSendInvite'))); } finally { setInviteLoading(false); } } - 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, t('errorLoadWorkingHours'))); + } finally { + setEditLoadingWorkingHours(false); + } } async function submitEdit() { if (!editing) return; + if (editHasTreatmentEdit) { + const validationError = validateEditorDays(editWorkingHoursDays, tWorkingHours); + if (validationError) { + toast.showError(validationError); + return; + } + } + setEditLoading(true); toast.setError(''); try { + if (editHasTreatmentEdit) { + await staffApi.upsertWorkingHours( + editing.id, + workingHoursPayloadFromState({ + days: editWorkingHoursDays, + autoRepeatWeekly: editAutoRepeatWeekly, + }), + ); + } + await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); - toast.showSuccess('Member updated.'); + + toast.showSuccess(t('successMemberUpdated')); setEditing(null); + setEditStep(1); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); + toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); } finally { setEditLoading(false); } } function handleDeleteMember() { - toast.showError('Delete is not implemented yet.'); + toast.showError(t('errorDeleteNotImplemented')); } async function confirmDisableMember() { @@ -354,11 +461,11 @@ export default function StaffPage() { toast.setError(''); try { await staffApi.disableMember(disableTarget.id); - toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`); + toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); setDisableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to disable member.')); + toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); } finally { setDisablingMembershipId(null); } @@ -371,11 +478,11 @@ export default function StaffPage() { toast.setError(''); try { await staffApi.enableMember(enableTarget.id); - toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`); + toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name })); setEnableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to enable member.')); + toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); } finally { setEnablingMembershipId(null); } @@ -383,7 +490,7 @@ export default function StaffPage() { if (!currentOrganization || !canViewStaff(currentOrganization)) { return ( -

Redirecting…

+

{t('redirecting')}

); } @@ -391,23 +498,22 @@ export default function StaffPage() {
-

Staff Management

-

- Invite teammates, set tab access, and stay within your plan seat limit. -

+

{t('title')}

+

{t('subtitle')}

@@ -415,16 +521,14 @@ export default function StaffPage() { {seats && (

- Seats:{' '} + {t('seatsLabel')}{' '} {seats.used} - {seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`} + {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} {!seats.unlimited && atSeatLimit && ( - {hasActivePlan - ? 'Plan seat limit reached for this organization.' - : 'No active plan selected for this organization. Choose a subscription plan to invite members.'} + {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} )}

@@ -435,7 +539,7 @@ export default function StaffPage() {

- {lastInviteInfo.name} ({lastInviteInfo.email}) was invited. + {t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} {lastInviteInfo.invitationStatus === 'PENDING' - ? ' Invitation is pending until they open the link, set a password, and log in.' - : ' Invitation was accepted immediately.'} + ? ` ${t('invitedPending')}` + : ` ${t('invitedAccepted')}`}

{lastInviteInfo.invitationStatus === 'PENDING' && (

- Invite link + {t('inviteLinkHeading')}

{lastInviteInfo.invitationUrl && ( @@ -491,36 +595,36 @@ export default function StaffPage() { setCopiedInviteMembershipId(lastInviteInfo.membershipId); setTimeout(() => setCopiedInviteMembershipId(null), 1500); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); + toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } })(); }} > - {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} + {copiedInviteMembershipId === lastInviteInfo.membershipId + ? tCommon('copied') + : tCommon('copyLink')} -

- Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost. -

+

{t('shareLinkHint')}

)}
)} {loading ? ( -

Loading team…

+

{t('loadingTeam')}

) : ( - - - - - + + + + + } @@ -532,28 +636,28 @@ export default function StaffPage() { @@ -566,8 +670,8 @@ export default function StaffPage() { className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" disabled={copyingInviteMembershipId === m.id} onClick={() => void copyStaffInviteLink(m)} - aria-label="Copy invitation link" - title="Copy invitation link (generates a new link if needed)" + aria-label={t('copyInviteLink')} + title={t('copyInviteLinkTitle')} > {copiedInviteMembershipId === m.id ? ( @@ -584,9 +688,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-primary' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Enable member" + aria-label={t('enableMemberAria')} disabled={!canEdit || enablingMembershipId === m.id} - title="Enable member (uses a seat)" + title={t('enableMemberTitle')} onClick={() => { if (!canEdit) return; setEnableTarget(m); @@ -603,9 +707,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Disable member" + aria-label={t('disableMemberAria')} disabled={!canEdit || disablingMembershipId === m.id} - title="Disable member (frees a seat)" + title={t('disableMemberTitle')} onClick={() => { if (!canEdit) return; setDisableTarget(m); @@ -621,7 +725,7 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Edit member" + aria-label={t('editMemberAria')} disabled={!canEdit} onClick={() => { if (!canEdit) return; @@ -637,9 +741,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Delete member" + aria-label={t('deleteMemberAria')} disabled={!canEdit} - title="Delete member (not implemented)" + title={t('deleteMemberTitle')} onClick={() => { if (!canEdit) return; handleDeleteMember(); @@ -666,43 +770,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

- +

+ {t('inviteModalTitle')} +

+ {inviteHasTreatmentEdit && ( +

{t('stepOf', { step: inviteStep })}

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

{t('tabAccess')}

+ +
+ + ) : ( + + )} +
- + {inviteStep === 1 ? ( + inviteHasTreatmentEdit ? ( + + ) : ( + + ) + ) : ( + <> + + + + )}
@@ -718,7 +889,7 @@ export default function StaffPage() { >

- Enable team member + {t('enableModalTitle')}

{ @@ -728,22 +899,15 @@ export default function StaffPage() { />

- Enable {enableTarget.name} ( - {enableTarget.email})? + {t('enableConfirm', { name: enableTarget.name, email: enableTarget.email })}

    -
  • They can sign in to this organization again with their existing account.
  • -
  • No new invitation is sent and no data was removed while they were disabled.
  • -
  • - Enabling uses one seat on your - plan. -
  • +
  • {t('enableBullet1')}
  • +
  • {t('enableBullet2')}
  • +
  • {t('enableBullet3')}
{!hasAvailableSeat && ( -

- No seats are available. Disable another member or upgrade your plan before enabling - this person. -

+

{t('noSeatsAvailable')}

)}
@@ -778,7 +942,7 @@ export default function StaffPage() { >

- Disable team member + {t('disableModalTitle')}

{ @@ -788,16 +952,12 @@ export default function StaffPage() { />

- Disable {disableTarget.name} ( - {disableTarget.email})? + {t('disableConfirm', { name: disableTarget.name, email: disableTarget.email })}

    -
  • They will not be able to sign in to this organization.
  • -
  • No data will be removed.
  • -
  • - Disabling frees one seat on your - plan so you can invite someone else. -
  • +
  • {t('disableBullet1')}
  • +
  • {t('disableBullet2')}
  • +
  • {t('disableBullet3')}
@@ -830,26 +990,85 @@ export default function StaffPage() { aria-modal="true" >
-

Edit member

- setEditing(null)} /> -
-

{editing.email}

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

Tab access

- +

{t('editModalTitle')}

+ {editHasTreatmentEdit && ( +

{t('stepOf', { step: editStep })}

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

{editing.email}

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

{t('tabAccess')}

+ +
+ + ) : editLoadingWorkingHours ? ( +

{t('loadingWorkingHours')}

+ ) : ( + + )} +
- - + {editStep === 1 ? ( + editHasTreatmentEdit ? ( + + ) : ( + + ) + ) : ( + + )}
diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx similarity index 70% rename from frontend/src/app/(dashboard)/today/page.tsx rename to frontend/src/app/[locale]/(dashboard)/today/page.tsx index 9e7ecf7..107a1c4 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,10 +1,12 @@ 'use client'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Card } from '@/components/ui/shared/Card'; export default function TodayPage() { + const t = useTranslations('today'); const { currentOrganization } = useAuth(); const showNoSubscriptionNotice = Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; @@ -12,42 +14,42 @@ export default function TodayPage() { return (

- Welcome back!! + {t('welcomeBack')}

{showNoSubscriptionNotice && (

- This organization does not have an active subscription yet.{' '} + {t('noSubscriptionNotice')}{' '} - Choose a plan + {t('choosePlanLink')} {' '} - to start the purchase process. + {t('noSubscriptionCta')}

)}
-

Today's Appointments

+

{t('cardTodaysAppointments')}

12

Monday 2/5/2026

-

Active Patients

+

{t('cardActivePatients')}

675

-

New Lab Case

+

{t('cardNewLabCase')}

5

35 ↑

-

Today invoices

+

{t('cardTodayInvoices')}

1200$

21,300 $

); -} \ No newline at end of file +} diff --git a/frontend/src/app/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx similarity index 72% rename from frontend/src/app/(dashboard)/treatment/page.tsx rename to frontend/src/app/[locale]/(dashboard)/treatment/page.tsx index 1014b41..6b45904 100644 --- a/frontend/src/app/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx @@ -1,14 +1,16 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace'; import { useAuth } from '@/lib/hooks/useAuth'; export default function TreatmentPage() { + const t = useTranslations('treatment'); const { user, currentOrganization, isAuthReady } = useAuth(); if (!isAuthReady || !user) { return ( -
Loading…
+
{t('loading')}
); } diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx similarity index 66% rename from frontend/src/app/(public)/accept-invite/page.tsx rename to frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 67f6fbb..5cce4df 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -2,13 +2,15 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +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'; function AcceptInviteContent() { + const t = useTranslations('auth'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); @@ -32,7 +34,7 @@ function AcceptInviteContent() { useEffect(() => { if (!token) { setLoading(false); - setError('Invalid invitation link'); + setError(t('invalidInvitationLink')); return; } @@ -44,30 +46,31 @@ function AcceptInviteContent() { setInviteInfo(res.data); setName(res.data.name || ''); if (res.data.status === 'ACCEPTED') { - setSuccess('This invitation is already accepted. You can log in now.'); + setSuccess(t('invitationAlreadyAccepted')); } - } catch (e: any) { - setError(e?.message || 'Could not load invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || t('errorLoadInvitation')); } finally { setLoading(false); } })(); - }, [token]); + }, [token, t]); async function onAccept() { if (!token) return; setError(''); setSuccess(''); if (!name.trim()) { - setError('Name is required'); + setError(t('nameRequired')); return; } if (password.length < 8) { - setError('Password must be at least 8 characters'); + setError(t('passwordMinLength8')); return; } if (password !== confirmPassword) { - setError('Passwords do not match'); + setError(t('passwordsDoNotMatch')); return; } @@ -78,12 +81,13 @@ function AcceptInviteContent() { name: name.trim(), password, }); - setSuccess('Invitation Accepted. Redirecting to login...'); + setSuccess(t('invitationAcceptedRedirect')); setTimeout(() => { router.replace('/login'); }, 1000); - } catch (e: any) { - setError(e?.message || 'Could not accept invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || t('errorAcceptInvitation')); } finally { setSubmitting(false); } @@ -92,19 +96,21 @@ function AcceptInviteContent() { return (
-

Accept invitation

+

{t('acceptInviteTitle')}

{loading ? ( -

Loading invitation...

+

{t('loadingInvitation')}

) : ( <> {inviteInfo && (

- Organization: {inviteInfo.organizationName} + {t('organizationLabel')}{' '} + {inviteInfo.organizationName}

- Email: {inviteInfo.email} + {t('emailLabel')}{' '} + {inviteInfo.email}

)} @@ -122,27 +128,28 @@ function AcceptInviteContent() { {inviteInfo?.status !== 'ACCEPTED' && (
- setName(e.target.value)} /> + setName(e.target.value)} /> setPassword(e.target.value)} /> setConfirmPassword(e.target.value)} />
)}

- Already have access? Go to login + {t('alreadyHaveAccess')}{' '} + {t('goToLogin')}

)} @@ -151,15 +158,18 @@ function AcceptInviteContent() { ); } +function AcceptInviteFallback() { + const t = useTranslations('auth'); + return ( +
+

{t('loadingInvitation')}

+
+ ); +} + export default function AcceptInvitePage() { return ( - -

Loading invitation...

-
- } - > + }> ); 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 73% 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..a6434d5 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,9 @@ 'use client'; import { Suspense, useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +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'; @@ -14,33 +15,47 @@ import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDeta import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { organizationApi } from '@/lib/api/organization'; -const acceptOrganizationInviteSchema = z - .object({ - ownerName: z.string().min(2, 'Name must be at least 2 characters'), - 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 AcceptOrganizationInviteForm = z.infer; +type AcceptOrganizationInviteForm = { + ownerName: string; + password: string; + confirmPassword: string; + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; function AcceptOrganizationInviteContent() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); + const acceptOrganizationInviteSchema = useMemo( + () => + z + .object({ + ownerName: z.string().min(2, tValidation('nameMinLength')), + 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 [step, setStep] = useState(1); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -77,7 +92,7 @@ function AcceptOrganizationInviteContent() { useEffect(() => { if (!token) { setLoading(false); - setError('Invalid invitation link'); + setError(t('invalidInvitationLink')); return; } @@ -96,16 +111,16 @@ function AcceptOrganizationInviteContent() { organizationType: res.data.organizationType, }); if (res.data.status === 'ACCEPTED') { - setSuccess('This invitation is already accepted. You can log in now.'); + setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || 'Could not load invitation'); + setError(message || t('errorLoadInvitation')); } finally { setLoading(false); } })(); - }, [token, reset]); + }, [token, reset, t]); const handleNext = async () => { const isValid = await trigger(['ownerName', 'password', 'confirmPassword']); @@ -129,11 +144,11 @@ function AcceptOrganizationInviteContent() { organizationEmail: data.organizationEmail.trim(), organizationType: data.organizationType, }); - setSuccess('Invitation accepted. Redirecting to login...'); + setSuccess(t('organizationAcceptedRedirect')); setTimeout(() => router.replace('/login'), 1000); } catch (e: unknown) { const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || 'Could not accept invitation'); + setError(message || t('errorAcceptInvitation')); } finally { setSubmitting(false); } @@ -143,15 +158,15 @@ function AcceptOrganizationInviteContent() {
- DyoLink + {tCommon('appName')}

- Accept organization invitation + {t('acceptOrganizationTitle')}

- Already have an account?{' '} + {t('alreadyHaveAccount')}{' '} - Sign in + {t('signInLink')}

@@ -159,13 +174,13 @@ function AcceptOrganizationInviteContent() {
{loading ? ( -

Loading invitation...

+

{t('loadingInvitation')}

) : ( <> {inviteInfo && (

- Invited by:{' '} + {t('invitedBy')}{' '} {inviteInfo.inviterOrganizationName}

@@ -191,37 +206,37 @@ function AcceptOrganizationInviteContent() { {step === 1 && ( <> } /> } /> } /> } /> )} @@ -236,10 +251,10 @@ function AcceptOrganizationInviteContent() { />
@@ -254,15 +269,18 @@ function AcceptOrganizationInviteContent() { ); } +function AcceptOrganizationInviteFallback() { + const t = useTranslations('auth'); + return ( +
+

{t('loadingInvitation')}

+
+ ); +} + export default function AcceptOrganizationInvitePage() { return ( - -

Loading invitation...

-
- } - > + }> ); 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..fc2f8ff --- /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')} +

+

+ {tCommon('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..6523dd2 --- /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}

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

+ {t('termsIntro')}{' '} + + {t('termsOfService')} + {' '} + {tCommon('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/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/staff/StaffWorkingHoursStep.tsx new file mode 100644 index 0000000..e972af7 --- /dev/null +++ b/frontend/src/components/staff/StaffWorkingHoursStep.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +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) { + const t = useTranslations('staff.workingHours'); + + useEffect(() => { + onValidationChange?.(validateEditorDays(days, t)); + }, [days, onValidationChange, t]); + + return ( +
+
+

{t('recommendedTitle')}

+

{t('recommendedBody')}

+
+ +
+ ); +} + +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..0629225 --- /dev/null +++ b/frontend/src/components/staff/WorkingHoursEditor.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { Plus, Trash2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { + MINUTES_PER_DAY, + WEEKDAY_KEYS, + 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) { + const t = useTranslations('staff.workingHours'); + + 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 ( +
+

{t('intro')}

+ +
+ {days.map((day) => ( +
+
+ + {t(WEEKDAY_KEYS[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..a14ff02 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -4,27 +4,30 @@ */ export const STAFF_FEATURE_GROUPS = [ - { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, - { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, - { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, - { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, - { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, + { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, + { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, + { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, + { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, + { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, + { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, + { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, + { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, ] as const; export type FeaturePermState = Record; export type OrgType = 'CLINIC' | 'LAB' | null | undefined; +type StaffFeaturesTranslate = (key: string) => string; + export function resolveStaffFeatureLabel( group: (typeof STAFF_FEATURE_GROUPS)[number], organizationType: OrgType, + t: StaffFeaturesTranslate, ): string { if (group.read === 'TAB_ORGANIZATIONS_READ') { - return organizationType === 'LAB' ? 'Clinics' : 'Labs'; + return organizationType === 'LAB' ? t('featureClinics') : t('featureLabs'); } - return group.label; + return t(group.labelKey); } export function emptyFeaturePermissionState(): FeaturePermState { @@ -57,20 +60,25 @@ 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, - organizationType?: OrgType, + organizationType: OrgType, + t: StaffFeaturesTranslate, ): string { - if (!permissionNames?.length) return 'No tab access'; + if (!permissionNames?.length) return t('noTabAccess'); const set = new Set(permissionNames); const parts: string[] = []; for (const g of STAFF_FEATURE_GROUPS) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; if (!hasRead) continue; - const label = resolveStaffFeatureLabel(g, organizationType); - parts.push(hasEdit ? label : `${label} (Read only)`); + const label = resolveStaffFeatureLabel(g, organizationType, t); + parts.push(hasEdit ? label : `${label} ${t('readOnlySuffix')}`); } - return parts.length ? parts.join(' · ') : 'No tab access'; + return parts.length ? parts.join(' · ') : t('noTabAccess'); } diff --git a/frontend/src/components/staff/workingHours.ts b/frontend/src/components/staff/workingHours.ts new file mode 100644 index 0000000..f4f646c --- /dev/null +++ b/frontend/src/components/staff/workingHours.ts @@ -0,0 +1,227 @@ +export const MINUTES_PER_DAY = 24 * 60; +export const SCHEDULE_SLOT_MINUTES = 15; + +export const WEEKDAY_KEYS = [ + 'weekdayMon', + 'weekdayTue', + 'weekdayWed', + 'weekdayThu', + 'weekdayFri', + 'weekdaySat', + 'weekdaySun', +] as const; + +type WorkingHoursTranslate = (key: string, values?: { day: string }) => string; + +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_KEYS.map((_, dayOfWeek) => ({ + dayOfWeek, + isWorking: false, + shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }], + })); +} + +export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursEditorDay[] { + const byDay = new Map(); + for (const block of blocks) { + const list = byDay.get(block.dayOfWeek) ?? []; + list.push({ startMinute: block.startMinute, endMinute: block.endMinute }); + byDay.set(block.dayOfWeek, list); + } + + return WEEKDAY_KEYS.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[], + t: WorkingHoursTranslate, +): string | null { + for (const day of days) { + if (!day.isWorking) continue; + const dayLabel = t(WEEKDAY_KEYS[day.dayOfWeek]); + if (day.shifts.length === 0) { + return t('validationNeedsShift', { day: dayLabel }); + } + const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute); + for (const shift of sorted) { + if (shift.endMinute <= shift.startMinute) { + return t('validationEndAfterStart', { day: dayLabel }); + } + } + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].startMinute < sorted[i - 1].endMinute) { + return t('validationOverlap', { day: dayLabel }); + } + } + } + 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/treatment/caseSendLabel.ts b/frontend/src/components/treatment/caseSendLabel.ts index 029dc3e..c2856c9 100644 --- a/frontend/src/components/treatment/caseSendLabel.ts +++ b/frontend/src/components/treatment/caseSendLabel.ts @@ -1,17 +1,23 @@ import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment'; +export type CaseSendLabelT = ( + key: 'sentToAt' | 'fallbackOrgName', + values?: { orgName: string; datetime: string }, +) => string; + export function formatCaseSentLines( sends: TreatmentCaseSendInfo[] | undefined, - fallback?: { + fallback: { organizationIds: string[]; sentAt: string | null; orgs?: LinkedOrganizationOption[]; - }, + } | undefined, + t: CaseSendLabelT, ): string[] { if (sends?.length) { return sends.map((s) => { const at = new Date(s.sentAt).toLocaleString(); - return `Sent to ${s.organizationName} at ${at}`; + return t('sentToAt', { orgName: s.organizationName, datetime: at }); }); } @@ -19,8 +25,8 @@ export function formatCaseSentLines( const at = new Date(fallback.sentAt).toLocaleString(); const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []); return fallback.organizationIds.map((id) => { - const name = nameById.get(id) ?? 'organization'; - return `Sent to ${name} at ${at}`; + const name = nameById.get(id) ?? t('fallbackOrgName'); + return t('sentToAt', { orgName: name, datetime: at }); }); } @@ -29,12 +35,13 @@ export function formatCaseSentLines( export function formatCaseSentSummary( sends: TreatmentCaseSendInfo[] | undefined, - fallback?: { + fallback: { organizationIds: string[]; sentAt: string | null; orgs?: LinkedOrganizationOption[]; - }, + } | undefined, + t: CaseSendLabelT, ): string | null { - const lines = formatCaseSentLines(sends, fallback); + const lines = formatCaseSentLines(sends, fallback, t); return lines.length > 0 ? lines.join(' · ') : null; } diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 17921df..0d38723 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,11 +1,13 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { APPOINTMENT_PURPOSES } from '@/types/appointment'; +import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, @@ -20,7 +22,7 @@ interface AppointmentBookingModalProps { patient: Patient | undefined; providerUserId: string | null; providerName: string; - initialHour: number; + initialStartMinute: number; onClose: () => void; onSubmit: (payload: { patientId: string; @@ -36,13 +38,21 @@ interface AppointmentBookingModalProps { deleting?: boolean; } +const PURPOSE_OPTION_COLORS: Record = { + consultation: '#ddd6fe', + filling: '#fed7aa', + endo: '#fecaca', + visit: '#bae6fd', + hygiene: '#d9f99d', +}; + export function AppointmentBookingModal({ open, scheduleDate, patient, providerUserId, providerName, - initialHour, + initialStartMinute, onClose, onSubmit, editingAppointment = null, @@ -51,20 +61,15 @@ export function AppointmentBookingModal({ onDelete, deleting = false, }: AppointmentBookingModalProps) { + const t = useTranslations('appointments'); + const tCommon = useTranslations('common'); + const tPatients = useTranslations('patients'); + const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); const [purpose, setPurpose] = useState('consultation'); const [error, setError] = useState(''); - const purposeTextColor = - purpose === 'consultation' - ? '#ddd6fe' - : purpose === 'filling' - ? '#fed7aa' - : purpose === 'endo' - ? '#fecaca' - : purpose === 'visit' - ? '#bae6fd' - : '#d9f99d'; + const purposeTextColor = PURPOSE_OPTION_COLORS[purpose]; useEffect(() => { if (!open) { @@ -81,17 +86,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 +106,7 @@ export function AppointmentBookingModal({ setPurpose('consultation'); } setError(''); - }, [open, scheduleDate, initialHour, editingAppointment]); + }, [open, scheduleDate, initialStartMinute, editingAppointment]); if (!open || !providerUserId) { return null; @@ -115,7 +121,7 @@ export function AppointmentBookingModal({ return; } if (!editingAppointment && !patient) { - setError('Select a patient first.'); + setError(t('errorSelectPatient')); return; } @@ -123,26 +129,26 @@ export function AppointmentBookingModal({ const endAt = combineLocalDateAndTime(scheduleDate, endTime); if (endAt <= startAt) { - setError('End time must be after start time.'); + setError(t('errorEndAfterStart')); return; } const now = new Date(); if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { - setError('Cannot schedule in the past.'); + setError(t('errorPastSchedule')); return; } const today = new Date(); if (compareLocalDayStart(scheduleDate, today) < 0) { - setError('Past appointments are view-only.'); + setError(t('errorPastViewOnly')); return; } const effectivePatientId = editingAppointment?.patientId ?? patient?.id; const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId; if (!effectivePatientId || !effectiveProviderId) { - setError('Missing appointment details.'); + setError(t('errorMissingDetails')); return; } @@ -165,29 +171,34 @@ export function AppointmentBookingModal({ >

- {editingAppointment ? 'Edit appointment' : 'New appointment'} + {editingAppointment ? t('editTitle') : t('newTitle')}

- Provider: {providerName} + {t('providerLabel')}{' '} + {providerName}

- +

{editingAppointment ? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}` : patient ? `${patient.firstName} ${patient.lastName}` - : '—'} + : tPatients('emptyValue')}

- +
- + setPurpose(e.target.value as AppointmentPurpose)} style={{ color: purposeTextColor }} > - - - - - + {APPOINTMENT_PURPOSES.map((purposeOption) => ( + + ))} {error &&

{error}

} @@ -242,14 +249,14 @@ export function AppointmentBookingModal({ disabled={loading || deleting} isLoading={deleting} > - Delete + {tCommon('delete')} ) : ( )}
diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 49cd6ad..5d18b14 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -1,12 +1,13 @@ 'use client'; import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { - APPOINTMENT_PURPOSE_LABEL, + getPurposeLabel, purposeStyle, } from '@/components/ui/appointments/appointmentPurposeStyles'; -import type { AppointmentRecord } from '@/types/appointment'; +import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; type AppointmentOverlapPopoverProps = { appointments: AppointmentRecord[]; @@ -28,6 +29,7 @@ export function AppointmentOverlapPopover({ onSelect, onClose, }: AppointmentOverlapPopoverProps) { + const t = useTranslations('appointments'); const panelRef = useRef(null); useEffect(() => { @@ -75,13 +77,13 @@ export function AppointmentOverlapPopover({ >

- Overlapping appointments ({sorted.length}) + {t('overlappingTitle', { count: sorted.length })}

    {sorted.map((apt) => { - const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL; + const purpose = apt.purpose as AppointmentPurpose; return (
  • diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 041d050..2f90b9d 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -1,8 +1,18 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; 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 +20,30 @@ import { } from '@/components/appointments/appointmentOverlapLayout'; import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; +import { startOfLocalDay } from '@/components/appointments/appointmentTime'; -const HOUR_PX = 40; -const HOURS = Array.from({ length: 24 }, (_, i) => i); +const HOUR_PX = 80; +const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60; -function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null { - const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0); - const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0); +function layoutBlockInRange( + apt: AppointmentRecord, + day: Date, + rangeStartMinute: number, + rangeEndMinute: number, +): { top: string; height: string } | null { + const dayStart = startOfLocalDay(day); + const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000; + const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000; const start = new Date(apt.startAt); const end = new Date(apt.endAt); - const ms = dayEnd.getTime() - dayStart.getTime(); - const clipStart = Math.max(start.getTime(), dayStart.getTime()); - const clipEnd = Math.min(end.getTime(), dayEnd.getTime()); + const clipStart = Math.max(start.getTime(), rangeStartMs); + const clipEnd = Math.min(end.getTime(), rangeEndMs); if (clipEnd <= clipStart) { return null; } - const top = ((clipStart - dayStart.getTime()) / ms) * 100; - const height = ((clipEnd - clipStart) / ms) * 100; + const rangeMs = rangeEndMs - rangeStartMs; + const top = ((clipStart - rangeStartMs) / rangeMs) * 100; + const height = ((clipEnd - clipStart) / rangeMs) * 100; return { top: `${top}%`, height: `${height}%` }; } @@ -36,12 +53,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 +78,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 +90,40 @@ export function AppointmentScheduleGrid({ canBook, onSlotClick, onAppointmentClick, + onAppointmentOutsideHours, }: AppointmentScheduleGridProps) { - const gridHeight = HOURS.length * HOUR_PX; + const t = useTranslations('appointments'); 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 +135,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({ @@ -103,9 +161,13 @@ export function AppointmentScheduleGrid({ if (providers.length === 0) { return ( -
    - No providers available. Add staff with treatment edit access to see columns here. -
    +
    {t('noProviders')}
    + ); + } + + if (!snappedRange || slotStarts.length === 0) { + return ( +
    {t('noWorkingHours')}
    ); } @@ -121,21 +183,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 && ( + + {t('noHoursSet')} + + )} + {p.hasWorkingHours && p.dayBlocks.length === 0 && ( + + {t('offToday')} + + )}
))}
-
- {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 +223,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 ( @@ -245,7 +364,24 @@ export function AppointmentScheduleGrid({ onAppointmentClick?.(apt)} + onSelect={(apt) => { + const provider = providers.find((p) => p.userId === apt.providerUserId); + if ( + provider && + provider.hasWorkingHours && + provider.dayBlocks.length > 0 && + !appointmentWithinWorkingHours( + new Date(apt.startAt), + new Date(apt.endAt), + provider.dayBlocks, + ) + ) { + onAppointmentOutsideHours?.(apt); + setOverlapPopover(null); + return; + } + onAppointmentClick?.(apt); + }} onClose={() => setOverlapPopover(null)} /> )} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 6e36de3..8ab970f 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,20 +1,25 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { - APPOINTMENT_PURPOSE_LABEL, APPOINTMENT_PURPOSE_LEGEND_SWATCH, + getPurposeLabel, } from '@/components/ui/appointments/appointmentPurposeStyles'; import { APPOINTMENT_PURPOSES } from '@/types/appointment'; export function AppointmentScheduleLegend() { + const t = useTranslations('appointments'); + return (
-

Legend

+

{t('legend')}

{APPOINTMENT_PURPOSES.map((p) => (
- {APPOINTMENT_PURPOSE_LABEL[p]} + {getPurposeLabel(p, t)}
))}
diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index e0817fd..5dce8a1 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -1,6 +1,7 @@ 'use client'; import { Search } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import type { Patient } from '@/types/patient'; @@ -26,6 +27,9 @@ export function AppointmentsPatientSearch({ canAddPatient, onAddPatient, }: AppointmentsPatientSearchProps) { + const t = useTranslations('appointments'); + const tPatients = useTranslations('patients'); + const trimmed = search.trim(); const showAddForEmptyResults = trimmed.length > 0 && !loading && patients.length === 0; @@ -35,7 +39,7 @@ export function AppointmentsPatientSearch({
onSearchChange(e.target.value)} icon={} @@ -47,18 +51,18 @@ export function AppointmentsPatientSearch({ variant="primary" disabled={!canAddPatient} onClick={onAddPatient} - title={!canAddPatient ? 'You do not have permission to add patients.' : undefined} + title={!canAddPatient ? t('noPermissionAdd') : undefined} > - New Patient + {tPatients('newPatient')} )}
- {loading &&

Searching…

} + {loading &&

{t('searching')}

} {!loading && trimmed.length === 0 && ( -

Type to search patients by name, phone, or email.

+

{t('searchHint')}

)} {patients.map((patient) => { @@ -77,7 +81,9 @@ export function AppointmentsPatientSearch({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || 'No contact'}

+

+ {patient.phone || patient.email || tPatients('noContact')} +

); })} diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index c0803ba..813befa 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -1,12 +1,25 @@ import type { AppointmentPurpose } from '@/types/appointment'; -export const APPOINTMENT_PURPOSE_LABEL: Record = { - consultation: 'Consultation', - filling: 'Filling', - endo: 'Endo', - visit: 'Visit', - hygiene: 'Hygiene', -}; +export const APPOINTMENT_PURPOSE_LABEL_KEYS = { + consultation: 'purposeConsultation', + filling: 'purposeFilling', + endo: 'purposeEndo', + visit: 'purposeVisit', + hygiene: 'purposeHygiene', +} as const satisfies Record; + +export type AppointmentPurposeLabelKey = + (typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose]; + +export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string; + +export function getPurposeLabel( + purpose: AppointmentPurpose, + t: AppointmentPurposeTranslate, +): string { + const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose]; + return key ? t(key) : purpose; +} /** Background + border for blocks / legend (matches reference palette). */ export const APPOINTMENT_PURPOSE_STYLES: Record = { diff --git a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx index d4fd1de..4b9fa1e 100644 --- a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx +++ b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Building2, Mail } from 'lucide-react'; import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; import { Input } from '@/components/ui/shared/Input'; @@ -23,26 +24,28 @@ export function OrganizationDetailsFields({ organizationType, setValue, }: OrganizationDetailsFieldsProps) { + const t = useTranslations('auth'); + return ( <> } /> } />
@@ -58,7 +61,7 @@ export function OrganizationDetailsFields({ }`} > - Dental Clinic + {t('dentalClinic')}
{errors.organizationType && ( diff --git a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx index 16d91de..a4ee8f1 100644 --- a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx +++ b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { ChevronRight } from 'lucide-react'; type RegistrationProgressStepsProps = { @@ -10,9 +11,11 @@ type RegistrationProgressStepsProps = { export function RegistrationProgressSteps({ step, - firstLabel = 'Account', - secondLabel = 'Organization', + firstLabel, + secondLabel, }: RegistrationProgressStepsProps) { + const t = useTranslations('auth'); + return (
@@ -31,7 +34,7 @@ export function RegistrationProgressSteps({ step >= 1 ? 'text-primary' : 'text-text-muted' }`} > - {firstLabel} + {firstLabel ?? t('stepAccount')}
@@ -50,7 +53,7 @@ export function RegistrationProgressSteps({ step >= 2 ? 'text-primary' : 'text-text-muted' }`} > - {secondLabel} + {secondLabel ?? t('stepOrganization')}
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/organizations/CopyInvitationLinkButton.tsx b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx index a8df1c8..734f000 100644 --- a/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx +++ b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Check, Copy } from 'lucide-react'; import { canShareOrganizationInviteLink, @@ -19,6 +20,8 @@ export function CopyInvitationLinkButton({ copying, onCopy, }: CopyInvitationLinkButtonProps) { + const t = useTranslations('organizations'); + if (!canShareOrganizationInviteLink(invitation)) { return ; } @@ -29,8 +32,8 @@ export function CopyInvitationLinkButton({ className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" disabled={copying} onClick={onCopy} - aria-label="Copy invitation link" - title="Copy invitation link (generates a new link if needed)" + aria-label={t('copyInvitationLink')} + title={t('copyInvitationLinkTitle')} > {copied ? : } diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index 10cd2bc..4bf5af4 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; @@ -7,14 +8,6 @@ import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shar import { Table } from '@/components/ui/shared/Table'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; -function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { - if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation accepted'; - if (status === 'REJECTED') return 'Invitation rejected'; - if (status === 'EXPIRED') return 'Invitation expired'; - return status; -} - function formatTableDate(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return '—'; @@ -43,6 +36,18 @@ export function InvitationHistoryDialog({ onCopy, toastMessages, }: InvitationHistoryDialogProps) { + const t = useTranslations('organizations'); + + function formatInvitationStatusLabel( + status: OrganizationInvitationHistoryItemDto['status'], + ): string { + if (status === 'PENDING') return t('statusPending'); + if (status === 'ACTIVE') return t('statusAccepted'); + if (status === 'REJECTED') return t('statusRejected'); + if (status === 'EXPIRED') return t('statusExpired'); + return status; + } + if (!open) return null; return ( @@ -55,7 +60,7 @@ export function InvitationHistoryDialog({ >

- Invitation History + {t('historyTitle')}

@@ -63,27 +68,27 @@ export function InvitationHistoryDialog({ {toastMessages && } {loading ? ( -

Loading invitation history...

+

{t('loadingHistory')}

) : items.length === 0 ? ( -

No invitations yet.

+

{t('historyEmpty')}

) : (
NameEmailRoleStatusAccess{t('tableName')}{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableAccess')} - Action + {t('tableAction')}
{m.email} {m.isOwner ? ( - Owner + {t('roleOwner')} ) : ( - Staff + {t('roleStaff')} )} {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - Active + {t('statusActive')} ) : m.invitationStatus === 'PENDING' ? ( - Pending + {t('statusPending')} ) : m.invitationStatus === 'DISABLED' ? ( - Disabled + {t('statusDisabled')} ) : ( - Expired + {t('statusExpired')} )} {m.isOwner ? ( - All features + {t('allFeatures')} ) : ( - {formatAccessSummary(m.permissions, currentOrganization?.type)} + {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} )}
} diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index bfe1140..4d1f225 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; import { Building2, Beaker, Mail } from 'lucide-react'; @@ -8,6 +9,9 @@ import { Input } from '@/components/ui/shared/Input'; import { Button } from '@/components/ui/shared/Button'; export function OrganizationSelectorContent() { + const t = useTranslations('organizations'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); const { organizations, currentOrganization, @@ -29,6 +33,9 @@ export function OrganizationSelectorContent() { const getIcon = (type: string) => type === 'CLINIC' ? : ; + const getTypeLabel = (type: string) => + type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab'); + const handleCreateOrganization = async () => { try { clearError(); @@ -48,18 +55,16 @@ export function OrganizationSelectorContent() { }; if (isLoading) { - return

Loading...

; + return

{tCommon('loadingEllipsis')}

; } return (
-

Organizations

+

{t('selectorTitle')}

- {canCreateOrganization - ? 'Select an organization to continue, or create a new one.' - : 'Select an organization to continue.'} + {canCreateOrganization ? t('selectorSubtitleWithCreate') : t('selectorSubtitleSelectOnly')}

{canCreateOrganization && ( @@ -71,7 +76,7 @@ export function OrganizationSelectorContent() { setIsCreateOpen((prev) => !prev); }} > - {isCreateOpen ? 'Cancel' : 'Create Organization'} + {isCreateOpen ? tCommon('cancel') : t('createOrganization')} )}
@@ -79,23 +84,23 @@ export function OrganizationSelectorContent() { {canCreateOrganization && isCreateOpen && (
setOrganizationName(event.target.value)} - placeholder="Sunshine Dental Clinic" + placeholder={tAuth('organizationNamePlaceholder')} icon={} /> setOrganizationEmail(event.target.value)} - placeholder="contact@sunshineclinic.com" + placeholder={tAuth('organizationEmailPlaceholder')} type="email" icon={} />
@@ -135,7 +140,7 @@ export function OrganizationSelectorContent() { isLoading={isLoading} disabled={!organizationName.trim() || !organizationEmail.trim()} > - Create and Continue + {t('createAndContinue')}
@@ -144,9 +149,7 @@ export function OrganizationSelectorContent() { {!organizations.length ? (

- {canCreateOrganization - ? 'No organizations found. Create your first one to continue.' - : 'No organizations found. Ask an organization owner to invite you.'} + {canCreateOrganization ? t('emptyCanCreate') : t('emptyAskOwner')}

) : ( @@ -166,12 +169,12 @@ export function OrganizationSelectorContent() { {org.name}

- {org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'} + {getTypeLabel(org.type)}

- Continue → + {t('continueArrow')}
))} diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 2a52439..e6f3e33 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Input } from '@/components/ui/shared/Input'; @@ -31,26 +32,29 @@ function CreatePatientFormFields({ loading: boolean; showCancel: boolean; }) { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + return ( <>
onChange({ firstName: e.target.value })} /> onChange({ lastName: e.target.value })} /> onChange({ phone: e.target.value })} /> onChange({ email: e.target.value })} @@ -64,11 +68,11 @@ function CreatePatientFormFields({ isLoading={loading} disabled={!formData.firstName || !formData.lastName} > - Save Patient + {t('savePatient')} {showCancel && ( )}
@@ -85,6 +89,8 @@ export function CreatePatientModal({ loading = false, variant = 'inline', }: CreatePatientModalProps) { + const t = useTranslations('patients'); + if (!isOpen) { return null; } @@ -112,7 +118,7 @@ export function CreatePatientModal({ id="create-patient-dialog-title" className="text-lg font-semibold text-text-primary pr-2" > - New patient + {t('dialogTitle')} diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 1041080..4f4d707 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; import { Patient } from '@/types/patient'; @@ -21,20 +22,22 @@ export function PatientSearchSelect({ onSelectPatient, loading = false, }: PatientSearchSelectProps) { + const t = useTranslations('patients'); + return (
onSearchChange(e.target.value)} icon={} />
- {loading &&

Loading patients...

} + {loading &&

{t('loadingPatients')}

} {!loading && patients.length === 0 && ( -

No patients found for this search.

+

{t('noResults')}

)} {patients.map((patient) => { @@ -53,7 +56,7 @@ export function PatientSearchSelect({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || 'No contact'}

+

{patient.phone || patient.email || t('noContact')}

); })} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index 1e0d844..fb2e1aa 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { Patient } from '@/types/patient'; interface PatientSummaryCardProps { @@ -5,10 +8,12 @@ interface PatientSummaryCardProps { } export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { + const t = useTranslations('patients'); + if (!patient) { return (
-

Select a patient to view details.

+

{t('selectPatient')}

); } @@ -18,10 +23,15 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {

{patient.firstName} {patient.lastName}

-

Phone: {patient.phone || '-'}

-

Email: {patient.email || '-'}

- Status: {patient.isActive ? 'Active' : 'Inactive'} + {t('phoneLabel')} {patient.phone || t('emptyValue')} +

+

+ {t('emailLabel')} {patient.email || t('emptyValue')} +

+

+ {t('statusLabel')}{' '} + {patient.isActive ? t('statusActive') : t('statusInactive')}

); diff --git a/frontend/src/components/ui/shared/DialogCloseButton.tsx b/frontend/src/components/ui/shared/DialogCloseButton.tsx index 07631d9..cac7e94 100644 --- a/frontend/src/components/ui/shared/DialogCloseButton.tsx +++ b/frontend/src/components/ui/shared/DialogCloseButton.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { X } from 'lucide-react'; type DialogCloseButtonProps = { @@ -8,12 +9,14 @@ type DialogCloseButtonProps = { }; export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) { + const t = useTranslations('common'); + return ( 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/OrganizationCard.tsx b/frontend/src/components/ui/shared/OrganizationCard.tsx index 2d785f1..68e3e41 100644 --- a/frontend/src/components/ui/shared/OrganizationCard.tsx +++ b/frontend/src/components/ui/shared/OrganizationCard.tsx @@ -1,5 +1,7 @@ -// src/components/ui/OrganizationCard.tsx +'use client'; + import React from 'react'; +import { useTranslations } from 'next-intl'; import { ChevronRight } from 'lucide-react'; import type { Organization } from '@/types/organization'; import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon'; @@ -13,8 +15,11 @@ export const OrganizationCard: React.FC = ({ organization, onSelect, }) => { + const t = useTranslations('organizations'); + const tAuth = useTranslations('auth'); const Icon = organizationTypeIcon(organization.type); - const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'; + const typeText = + organization.type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab'); return (
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx index e91d297..2316703 100644 --- a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime'; @@ -10,19 +11,19 @@ interface ScheduleDayPickerProps { label?: string; } -const MONTH_LABELS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', +const MONTH_KEYS = [ + 'monthJanuary', + 'monthFebruary', + 'monthMarch', + 'monthApril', + 'monthMay', + 'monthJune', + 'monthJuly', + 'monthAugust', + 'monthSeptember', + 'monthOctober', + 'monthNovember', + 'monthDecember', ] as const; function daysInMonth(year: number, month: number): number { @@ -55,12 +56,14 @@ const selectClassName = ` * Calendar day navigator (arrows + year/month/day panel). * Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms. */ -export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { +export function ScheduleDayPicker({ value, onChange, label }: ScheduleDayPickerProps) { + const t = useTranslations('schedule'); const panelId = useId(); const rootRef = useRef(null); const [panelOpen, setPanelOpen] = useState(false); const normalizedValue = startOfLocalDay(value); + const resolvedLabel = label ?? t('defaultLabel'); const labelText = normalizedValue.toLocaleDateString(undefined, { weekday: 'short', @@ -108,13 +111,13 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: return (
-

{label}

+

{resolvedLabel}

@@ -138,7 +141,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: type="button" onClick={() => onChange(addCalendarDays(normalizedValue, 1))} className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35" - aria-label="Next day" + aria-label={t('nextDay')} > @@ -148,7 +151,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
- Organization + {t('tableOrganization')} - Owner email + {t('tableOwnerEmail')} - Date + {t('tableDate')} - Status + {t('tableStatus')} - Invitation link + {t('tableInvitationLink')}