From 39935688ad125192ffe74690c4ca27c7e88681e2 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 15:11:50 +0330 Subject: [PATCH 01/24] improvement: a flow added for owner users to make it possible for them to participate in treatments or tasks. --- backend/src/common/membership-permissions.ts | 96 +++++ backend/src/common/organization-type.ts | 19 +- .../appointments/appointments.service.ts | 25 +- backend/src/modules/auth/auth.controller.ts | 43 ++ backend/src/modules/auth/auth.module.ts | 2 + backend/src/modules/auth/auth.service.ts | 215 +++++++++- .../auth/dto/update-participation.dto.ts | 6 + .../lab-case-comments.service.ts | 34 +- .../staff/staff-working-hours.service.ts | 90 ++++- backend/src/modules/tasks/tasks.service.ts | 23 +- backend/src/modules/today/today.service.ts | 46 +-- .../modules/treatments/treatments.service.ts | 23 +- frontend/messages/en.json | 18 +- frontend/messages/fa.json | 18 +- frontend/messages/nl.json | 18 +- .../(dashboard)/settings/account/page.tsx | 370 +++++++++++++++--- .../settings/OwnerWorkingHoursDialog.tsx | 161 ++++++++ frontend/src/components/shared/permissions.ts | 7 +- .../staff/StaffWorkingHoursStep.tsx | 14 +- frontend/src/lib/api/account.ts | 48 +++ 20 files changed, 1122 insertions(+), 154 deletions(-) create mode 100644 backend/src/common/membership-permissions.ts create mode 100644 backend/src/modules/auth/dto/update-participation.dto.ts create mode 100644 frontend/src/components/settings/OwnerWorkingHoursDialog.tsx create mode 100644 frontend/src/lib/api/account.ts diff --git a/backend/src/common/membership-permissions.ts b/backend/src/common/membership-permissions.ts new file mode 100644 index 0000000..58d2bbf --- /dev/null +++ b/backend/src/common/membership-permissions.ts @@ -0,0 +1,96 @@ +import { + ownerPermissionsForOrgType, + type OrganizationTypeName, +} from './organization-type'; +import { normalizeTabPermissions } from './permissions'; + +export const CLINIC_PARTICIPATION_PERMISSIONS = [ + 'TAB_TREATMENT_READ', + 'TAB_TREATMENT_EDIT', +] as const; + +export const LAB_PARTICIPATION_PERMISSIONS = [ + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', +] as const; + +const CLINIC_PARTICIPATION_SET = new Set(CLINIC_PARTICIPATION_PERMISSIONS); +const LAB_PARTICIPATION_SET = new Set(LAB_PARTICIPATION_PERMISSIONS); + +export type MembershipWithPermissions = { + isOwner: boolean; + organization: { + plan?: { name: string; maxUsers?: number; price?: number } | null; + planId?: string | null; + type?: { name: string }; + }; + permissions?: Array<{ permission: { name: string } }>; +}; + +export function getOrgTypeFromMembership( + membership: MembershipWithPermissions, +): OrganizationTypeName { + return membership.organization.type?.name === 'LAB' ? 'LAB' : 'CLINIC'; +} + +export function hasActivePlan(membership: MembershipWithPermissions): boolean { + if (membership.organization.plan != null) { + return true; + } + return Boolean(membership.organization.planId); +} + +export function getStoredPermissionNames( + membership: MembershipWithPermissions, +): string[] { + return membership.permissions?.map((p) => p.permission.name) ?? []; +} + +export function getEffectivePermissionNames( + membership: MembershipWithPermissions, +): string[] { + const stored = getStoredPermissionNames(membership); + + if (!membership.isOwner) { + return normalizeTabPermissions(stored); + } + + const orgType = getOrgTypeFromMembership(membership); + const base = ownerPermissionsForOrgType(orgType, hasActivePlan(membership)); + return normalizeTabPermissions([...base, ...stored]); +} + +export function hasEffectivePermission( + membership: MembershipWithPermissions, + permission: string, +): boolean { + return getEffectivePermissionNames(membership).includes(permission); +} + +export function participationPermissionsForOrgType( + orgType: OrganizationTypeName, +): readonly string[] { + return orgType === 'LAB' + ? LAB_PARTICIPATION_PERMISSIONS + : CLINIC_PARTICIPATION_PERMISSIONS; +} + +export function participatesInTreatments( + membership: MembershipWithPermissions, +): boolean { + if (getOrgTypeFromMembership(membership) !== 'CLINIC') { + return false; + } + return getStoredPermissionNames(membership).includes('TAB_TREATMENT_EDIT'); +} + +export function participatesInTasks(membership: MembershipWithPermissions): boolean { + if (getOrgTypeFromMembership(membership) !== 'LAB') { + return false; + } + return getStoredPermissionNames(membership).includes('TAB_TASKS_EDIT'); +} + +export function isParticipationPermission(name: string): boolean { + return CLINIC_PARTICIPATION_SET.has(name) || LAB_PARTICIPATION_SET.has(name); +} diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts index 6ddc33a..e6cef18 100644 --- a/backend/src/common/organization-type.ts +++ b/backend/src/common/organization-type.ts @@ -49,18 +49,27 @@ export function filterPermissionsForOrgType( return normalizeTabPermissions(names.filter((n) => allowed.has(n))); } +/** Owner opt-in permissions — granted via MembershipPermission when owner chooses to participate. */ +const OWNER_OPT_IN_CLINIC = new Set(['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT']); +const OWNER_OPT_IN_LAB = new Set(['TAB_TASKS_READ', 'TAB_TASKS_EDIT']); + +function ownerBasePermissions(orgType: OrganizationTypeName): readonly string[] { + const all = orgType === 'LAB' ? LAB_TAB_PERMISSIONS : CLINIC_TAB_PERMISSIONS; + const optIn = orgType === 'LAB' ? OWNER_OPT_IN_LAB : OWNER_OPT_IN_CLINIC; + return all.filter((p) => !optIn.has(p)); +} + export function ownerPermissionsForOrgType( orgType: OrganizationTypeName, hasActivePlan: boolean, ): string[] { + const base = ownerBasePermissions(orgType); + if (hasActivePlan) { - return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS]; + return [...base]; } - const readOnly = (perms: readonly string[]) => - normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ'))); - - return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS); + return normalizeTabPermissions(base.filter((p) => p.endsWith('_READ'))); } export async function getOrganizationTypeName( diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 31cabdb..5b3e3c8 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -15,6 +15,7 @@ import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const MS_PER_DAY = 86_400_000; @@ -39,8 +40,7 @@ export class AppointmentsService { const members = await this.prisma.membership.findMany({ where: { organizationId, - isOwner: false, - isActive: true, + OR: [{ isOwner: true }, { isActive: true }], permissions: { some: { permission: { @@ -308,16 +308,10 @@ export class AppointmentsService { if (!m) { throw new BadRequestException('Provider is not a member of this organization'); } - if (m.isOwner) { - throw new BadRequestException( - 'Appointments must be assigned to staff with treatment access, not the organization owner', - ); - } - if (!m.isActive) { + if (!m.isOwner && !m.isActive) { throw new BadRequestException('Provider is not an active staff member'); } - const names = m.permissions.map((p) => p.permission.name); - if (!names.includes('TAB_TREATMENT_EDIT')) { + if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) { throw new BadRequestException('Provider does not have treatment edit access'); } } @@ -375,8 +369,15 @@ export class AppointmentsService { private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ - where: { userId, organizationId }, - include: { permissions: { include: { permission: true } } }, + where: { + userId, + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, }); } } diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 5045b15..dd97c37 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -11,6 +11,7 @@ import { HttpStatus, Get, Patch, + Put, UnauthorizedException, } from '@nestjs/common'; import type { Response } from 'express'; @@ -36,6 +37,8 @@ import { ForgotPasswordVerifyDto, } from './dto/forgot-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { UpdateParticipationDto } from './dto/update-participation.dto'; +import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto'; @ApiTags('auth') @Controller('auth') @@ -200,6 +203,46 @@ export class AuthController { return result; } + @Get('profile/participation') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get owner participation settings for current organization' }) + async getParticipation(@Req() req) { + return this.authService.getParticipation(req.user.id, req.user.organizationId); + } + + @Patch('profile/participation') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Enable or disable owner participation in treatments/tasks' }) + async updateParticipation(@Req() req, @Body() dto: UpdateParticipationDto) { + return this.authService.updateParticipation( + req.user.id, + req.user.organizationId, + dto, + ); + } + + @Get('profile/working-hours') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Get working hours for participating clinic owner' }) + async getMyWorkingHours(@Req() req) { + return this.authService.getMyWorkingHours(req.user.id, req.user.organizationId); + } + + @Put('profile/working-hours') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Save working hours for participating clinic owner' }) + async upsertMyWorkingHours(@Req() req, @Body() dto: UpsertWorkingHoursDto) { + return this.authService.upsertMyWorkingHours( + req.user.id, + req.user.organizationId, + dto, + ); + } + @Post('forgot-password/send-code') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Send forgot-password SMS verification code' }) diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 3bdf0aa..de68deb 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -9,11 +9,13 @@ import { PrismaService } from '../../../prisma/prisma.service'; import { LocalStrategy } from './strategies/local.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; import { SmsModule } from '../sms/sms.module'; +import { StaffModule } from '../staff/staff.module'; @Module({ imports: [ PassportModule, SmsModule, + StaffModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index b1132d3..9b21706 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -21,6 +21,11 @@ import { } from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type'; +import { getEffectivePermissionNames, getOrgTypeFromMembership, hasActivePlan, participatesInTasks, participatesInTreatments, participationPermissionsForOrgType } from '../../common/membership-permissions'; +import { assertClinicOrganization, assertLabOrganization } from '../../common/organization-type'; +import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; +import { UpsertWorkingHoursDto } from '../staff/dto/upsert-working-hours.dto'; +import { UpdateParticipationDto } from './dto/update-participation.dto'; import { SmsService } from '../sms/sms.service'; import { ForgotPasswordSendCodeDto, @@ -75,6 +80,7 @@ export class AuthService { private jwtService: JwtService, private configService: ConfigService, private smsService: SmsService, + private staffWorkingHoursService: StaffWorkingHoursService, ) { } private accessJwtSignOptions(): JwtSignOptions { @@ -999,17 +1005,12 @@ export class AuthService { isOwner: boolean; organization: { plan?: { name: string; maxUsers: number; price: number } | null; + planId?: string | null; type?: { name: string }; }; permissions?: Array<{ permission: { name: string } }>; }): string[] { - if (membership.isOwner) { - const orgType = (membership.organization.type?.name === 'LAB' - ? 'LAB' - : 'CLINIC') as OrganizationTypeName; - return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.plan)); - } - return membership.permissions?.map((p) => p.permission.name) || []; + return getEffectivePermissionNames(membership); } /** @@ -1161,4 +1162,204 @@ export class AuthService { mobile: user.mobile ?? null, }; } + + private async getOwnerMembership(userId: string, organizationId: string) { + if (!organizationId) { + throw new BadRequestException('Organization is not selected'); + } + + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId, isOwner: true }, + include: { + organization: { + include: { type: true, plan: true }, + }, + permissions: { include: { permission: true } }, + }, + }); + + if (!membership) { + throw new ForbiddenException('Only organization owners can manage participation'); + } + + return membership; + } + + async getParticipation(userId: string, organizationId: string) { + const membership = await this.getOwnerMembership(userId, organizationId); + const orgType = getOrgTypeFromMembership(membership); + + const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ + where: { membershipId: membership.id }, + include: { blocks: true }, + }); + + return { + success: true, + data: { + membershipId: membership.id, + orgType, + participatesInTreatments: participatesInTreatments(membership), + participatesInTasks: participatesInTasks(membership), + hasWorkingHours: (schedule?.blocks.length ?? 0) > 0, + }, + }; + } + + async updateParticipation( + userId: string, + organizationId: string, + dto: UpdateParticipationDto, + ) { + const membership = await this.getOwnerMembership(userId, organizationId); + const orgType = getOrgTypeFromMembership(membership); + + if (!hasActivePlan(membership)) { + throw new ForbiddenException( + 'An active subscription is required to participate in treatments or tasks', + ); + } + + if (dto.participate) { + await this.grantOwnerParticipation(membership.id, orgType); + } else { + await this.revokeOwnerParticipation(membership, orgType); + } + + const updated = await this.prisma.membership.findFirst({ + where: { id: membership.id }, + include: { + organization: { include: { type: true, plan: true } }, + permissions: { include: { permission: true } }, + }, + }); + + const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ + where: { membershipId: membership.id }, + include: { blocks: true }, + }); + + return { + success: true, + data: { + membershipId: membership.id, + orgType, + participatesInTreatments: participatesInTreatments(updated!), + participatesInTasks: participatesInTasks(updated!), + hasWorkingHours: (schedule?.blocks.length ?? 0) > 0, + permissions: getEffectivePermissionNames(updated!), + }, + }; + } + + async getMyWorkingHours(userId: string, organizationId: string) { + const membership = await this.getOwnerMembership(userId, organizationId); + if (getOrgTypeFromMembership(membership) !== 'CLINIC') { + throw new BadRequestException('Working hours are only available for clinic organizations'); + } + return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId); + } + + async upsertMyWorkingHours( + userId: string, + organizationId: string, + dto: UpsertWorkingHoursDto, + ) { + const membership = await this.getOwnerMembership(userId, organizationId); + if (getOrgTypeFromMembership(membership) !== 'CLINIC') { + throw new BadRequestException('Working hours are only available for clinic organizations'); + } + if (!participatesInTreatments(membership)) { + throw new ForbiddenException( + 'Enable treatment participation before setting working hours', + ); + } + return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto); + } + + private async grantOwnerParticipation( + membershipId: string, + orgType: OrganizationTypeName, + ) { + const participationNames = participationPermissionsForOrgType(orgType).filter((p) => + p.endsWith('_EDIT'), + ); + + const permissionRows = await this.prisma.permission.findMany({ + where: { name: { in: [...participationNames] } }, + }); + + if (permissionRows.length === 0) { + throw new InternalServerErrorException('Participation permissions are not configured'); + } + + const participationIds = ( + await this.prisma.permission.findMany({ + where: { name: { in: [...participationPermissionsForOrgType(orgType)] } }, + select: { id: true }, + }) + ).map((p) => p.id); + + await this.prisma.$transaction(async (tx) => { + await tx.membershipPermission.deleteMany({ + where: { + membershipId, + permissionId: { in: participationIds }, + }, + }); + + await tx.membershipPermission.createMany({ + data: permissionRows.map((p) => ({ + membershipId, + permissionId: p.id, + })), + skipDuplicates: true, + }); + }); + } + + private async revokeOwnerParticipation( + membership: { + id: string; + userId: string; + organizationId: string; + organization: { id: string }; + }, + orgType: OrganizationTypeName, + ) { + if (orgType === 'CLINIC') { + await assertClinicOrganization(this.prisma, membership.organizationId); + + const futureAppointment = await this.prisma.appointment.findFirst({ + where: { + organizationId: membership.organizationId, + providerUserId: membership.userId, + startAt: { gte: new Date() }, + }, + select: { id: true }, + }); + + if (futureAppointment) { + throw new ConflictException( + 'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.', + ); + } + } else { + await assertLabOrganization(this.prisma, membership.organizationId); + } + + const participationIds = ( + await this.prisma.permission.findMany({ + where: { name: { in: [...participationPermissionsForOrgType(orgType)] } }, + select: { id: true }, + }) + ).map((p) => p.id); + + await this.prisma.membershipPermission.deleteMany({ + where: { + membershipId: membership.id, + permissionId: { in: participationIds }, + }, + }); + } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/update-participation.dto.ts b/backend/src/modules/auth/dto/update-participation.dto.ts new file mode 100644 index 0000000..fec5059 --- /dev/null +++ b/backend/src/modules/auth/dto/update-participation.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateParticipationDto { + @IsBoolean() + participate: boolean; +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index e69a68c..4d74b13 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -6,6 +6,7 @@ import { import { LabCaseCommentSide, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const commentInclude = { authorUser: { select: { id: true, name: true } }, @@ -206,15 +207,20 @@ export class LabCaseCommentsService { } const membership = await this.prisma.membership.findFirst({ - where: { userId: actorUserId, organizationId: labOrganizationId, isActive: true }, - include: { permissions: { include: { permission: true } } }, + where: { + userId: actorUserId, + organizationId: labOrganizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, }); if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } - if (membership.isOwner) return; - const names = membership.permissions.map((p) => p.permission.name); - if (!names.includes('TAB_TASKS_EDIT')) { + if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { throw new ForbiddenException('You do not have access to task comments'); } } @@ -244,15 +250,23 @@ export class LabCaseCommentsService { ) { await this.assertClinicOwnsCase(caseId, clinicOrganizationId); const membership = await this.prisma.membership.findFirst({ - where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true }, - include: { permissions: { include: { permission: true } } }, + where: { + userId: actorUserId, + organizationId: clinicOrganizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, }); if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } - if (membership.isOwner) return; - const names = membership.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { + if ( + hasEffectivePermission(membership, 'TAB_TREATMENT_READ') || + hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT') + ) { return; } throw new ForbiddenException('You do not have access to treatment cases'); diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts index d7568a8..38284ff 100644 --- a/backend/src/modules/staff/staff-working-hours.service.ts +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -13,6 +13,9 @@ import { type WorkingHoursBlockInput, } from '../../common/working-hours'; import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; +import { + hasEffectivePermission, +} from '../../common/membership-permissions'; @Injectable() export class StaffWorkingHoursService { @@ -21,7 +24,7 @@ export class StaffWorkingHoursService { async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) { await this.assertCanViewStaff(actorUserId, organizationId); - const membership = await this.findMembership(membershipId, organizationId); + const membership = await this.findStaffMembership(membershipId, organizationId); const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ where: { membershipId: membership.id }, include: { @@ -63,7 +66,62 @@ export class StaffWorkingHoursService { ) { await this.assertCanEditStaff(actorUserId, organizationId); - const membership = await this.findMembership(membershipId, organizationId); + const membership = await this.findStaffMembership(membershipId, organizationId); + return this.persistWorkingHours(membership, organizationId, dto); + } + + async getMyWorkingHours(userId: string, organizationId: string) { + const membership = await this.findOwnerMembership(userId, organizationId); + await this.assertOwnerCanManageWorkingHours(membership); + + 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 upsertMyWorkingHours( + userId: string, + organizationId: string, + dto: UpsertWorkingHoursDto, + ) { + const membership = await this.findOwnerMembership(userId, organizationId); + await this.assertOwnerCanManageWorkingHours(membership); + return this.persistWorkingHours(membership, organizationId, dto); + } + + private async persistWorkingHours( + membership: { id: string; userId: string }, + organizationId: string, + dto: UpsertWorkingHoursDto, + ) { const validationError = validateWorkingHoursBlocks(dto.blocks); if (validationError) { throw new BadRequestException(validationError); @@ -205,7 +263,7 @@ export class StaffWorkingHoursService { ); } - private async findMembership(membershipId: string, organizationId: string) { + private async findStaffMembership(membershipId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, select: { id: true, isOwner: true, userId: true }, @@ -219,6 +277,32 @@ export class StaffWorkingHoursService { return membership; } + private async findOwnerMembership(userId: string, organizationId: string) { + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId, isOwner: true }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, + }); + if (!membership) { + throw new ForbiddenException('Only organization owners can manage their working hours'); + } + return membership; + } + + private async assertOwnerCanManageWorkingHours(membership: { + isOwner: boolean; + permissions: { permission: { name: string } }[]; + organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null }; + }) { + if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) { + throw new ForbiddenException( + 'Enable treatment participation before setting working hours', + ); + } + } + private async assertCanViewStaff(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canViewStaff(actor)) { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index e3ded96..5b76b17 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -13,6 +13,7 @@ import { } from '../catalog/catalog-label.service'; import { normalizeTaskTeeth } from '../cases/lab-case-task.util'; import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const taskListInclude = { lastStatusChangedBy: { select: { id: true, name: true } }, @@ -288,9 +289,10 @@ export class TasksService { if (!m) { throw new ForbiddenException('You are not a member of this organization'); } - if (m.isOwner) return; - const names = m.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) { + if ( + hasEffectivePermission(m, 'TAB_TASKS_READ') || + hasEffectivePermission(m, 'TAB_TASKS_EDIT') + ) { return; } throw new ForbiddenException('You do not have access to tasks'); @@ -301,9 +303,7 @@ export class TasksService { if (!m) { throw new ForbiddenException('You are not a member of this organization'); } - if (m.isOwner) return; - const names = m.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TASKS_EDIT')) { + if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) { return; } throw new ForbiddenException('You cannot update tasks'); @@ -311,8 +311,15 @@ export class TasksService { private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ - where: { userId, organizationId, isActive: true }, - include: { permissions: { include: { permission: true } } }, + where: { + userId, + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, }); } } diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index c547d33..79e9745 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -7,12 +7,11 @@ import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { isUnlimitedSeats, - normalizeTabPermissions, } from '../../common/permissions'; import { OrganizationTypeName, - ownerPermissionsForOrgType, } from '../../common/organization-type'; +import { getEffectivePermissionNames } from '../../common/membership-permissions'; import { CatalogLabelService, normalizeCatalogLocale, @@ -565,16 +564,10 @@ export class TodayService { const members = await this.prisma.membership.findMany({ where: { organizationId, - isActive: true, - OR: [ - { isOwner: true }, - { - isOwner: false, - permissions: { - some: { permission: { name: editPermission } }, - }, - }, - ], + OR: [{ isOwner: true }, { isActive: true }], + permissions: { + some: { permission: { name: editPermission } }, + }, }, select: { userId: true, isOwner: true }, }); @@ -605,10 +598,7 @@ export class TodayService { code: member.userId, label: nameById.get(member.userId) ?? member.userId, count: countsByUser.get(member.userId) ?? 0, - isOwner: member.isOwner, })) - .filter((row) => !row.isOwner || row.count > 0) - .map(({ code, label, count }) => ({ code, label, count })) .sort((a, b) => b.count - a.count); return rows.length >= 2 ? rows : undefined; @@ -834,8 +824,7 @@ export class TodayService { const members = await this.prisma.membership.findMany({ where: { organizationId, - isOwner: false, - isActive: true, + OR: [{ isOwner: true }, { isActive: true }], permissions: { some: { permission: { name: 'TAB_TREATMENT_EDIT' }, @@ -1184,19 +1173,12 @@ export class TodayService { isOwner: boolean; organization: { planId: string | null; + plan?: { name: string } | null; type: { name: string }; }; permissions: { permission: { name: string } }[]; }): string[] { - if (membership.isOwner) { - const orgType = (membership.organization.type.name === 'LAB' - ? 'LAB' - : 'CLINIC') as OrganizationTypeName; - return ownerPermissionsForOrgType(orgType, Boolean(membership.organization.planId)); - } - return normalizeTabPermissions( - membership.permissions.map((p) => p.permission.name), - ); + return getEffectivePermissionNames(membership); } private assertCanViewToday(isOwner: boolean, permissionNames: string[]) { @@ -1220,15 +1202,13 @@ export class TodayService { ); } - private canViewTreatment(isOwner: boolean, names: string[]): boolean { - if (isOwner) return true; + private canViewTreatment(_isOwner: boolean, names: string[]): boolean { return names.some((p) => ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'].includes(p), ); } - private canViewMyAppointmentsWeekChart(isOwner: boolean, names: string[]): boolean { - if (isOwner) return false; + private canViewMyAppointmentsWeekChart(_isOwner: boolean, names: string[]): boolean { return names.includes('TAB_TREATMENT_EDIT'); } @@ -1239,15 +1219,13 @@ export class TodayService { ); } - private canViewTasks(isOwner: boolean, names: string[]): boolean { - if (isOwner) return true; + private canViewTasks(_isOwner: boolean, names: string[]): boolean { return names.some((p) => ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'].includes(p), ); } - private canEditTreatment(isOwner: boolean, names: string[]): boolean { - if (isOwner) return true; + private canEditTreatment(_isOwner: boolean, names: string[]): boolean { return names.includes('TAB_TREATMENT_EDIT'); } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 7fe2c87..c749b9b 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -21,6 +21,7 @@ import { normalizeTeeth, } from './treatment.utils'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const treatmentInclude = { details: { @@ -923,9 +924,10 @@ export class TreatmentsService { if (!m) { throw new ForbiddenException('You are not a member of this organization'); } - if (m.isOwner) return; - const names = m.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) { + if ( + hasEffectivePermission(m, 'TAB_TREATMENT_READ') || + hasEffectivePermission(m, 'TAB_TREATMENT_EDIT') + ) { return; } throw new ForbiddenException('You do not have access to treatments'); @@ -936,9 +938,7 @@ export class TreatmentsService { if (!m) { throw new ForbiddenException('You are not a member of this organization'); } - if (m.isOwner) return; - const names = m.permissions.map((p) => p.permission.name); - if (names.includes('TAB_TREATMENT_EDIT')) { + if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) { return; } throw new ForbiddenException('You cannot edit treatments'); @@ -946,8 +946,15 @@ export class TreatmentsService { private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ - where: { userId, organizationId, isActive: true }, - include: { permissions: { include: { permission: true } } }, + where: { + userId, + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, }); } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 01bcb4e..6d736b3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -756,7 +756,23 @@ "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).", + "participationSectionTitle": "Clinical participation", + "participationSectionSubtitle": "Choose whether you personally take part in clinical or lab work for this organization.", + "participateInTreatments": "I want to participate in treatments", + "participateInTasks": "I want to participate in tasks", + "participateWorkingHoursTitle": "Working hours", + "participateWorkingHoursSubtitle": "Set your schedule so appointments can be booked for you.", + "participateSaveHours": "Save and participate", + "participateEnabledTreatments": "You are now participating in treatments.", + "participateEnabledTasks": "You are now participating in tasks.", + "participateDisabledTreatments": "You are no longer participating in treatments.", + "participateDisabledTasks": "You are no longer participating in tasks.", + "participateUpdateFailed": "Could not update participation settings. Please try again.", + "participateConfirmRevokeTitle": "Stop participating?", + "participateConfirmRevokeBodyTreatments": "You will lose treatment access and be removed from the appointments schedule. Your saved working hours will be kept.", + "participateConfirmRevokeBodyTasks": "You will lose task edit access and be removed from the efficiency report.", + "participateConfirmRevokeConfirm": "Stop participating", + "changePasswordOption": "Change password", "changePasswordTitle": "Change password", "resetPasswordTitle": "Set a new password", "resetPasswordSubtitle": "Your mobile was verified. Choose a new password for your account.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 7dd8243..281c9ed 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -757,7 +757,23 @@ "settings": { "accountTitle": "حساب کاربری", "accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.", - "accountPlaceholder": "تغییر رمز عبور و ویرایش پروفایل در مرحله بعدی در اینجا قرار می‌گیرند (مثلاً فرآیند دعوت، بازنشانی رمز عبور).", + "participationSectionTitle": "مشارکت بالینی", + "participationSectionSubtitle": "مشخص کنید آیا شخصاً در درمان‌ها یا کارهای آزمایشگاهی این سازمان شرکت می‌کنید.", + "participateInTreatments": "می‌خواهم در درمان‌ها شرکت کنم", + "participateInTasks": "می‌خواهم در وظایف شرکت کنم", + "participateWorkingHoursTitle": "ساعات کاری", + "participateWorkingHoursSubtitle": "برنامه خود را تنظیم کنید تا نوبت‌ها برای شما رزرو شوند.", + "participateSaveHours": "ذخیره و مشارکت", + "participateEnabledTreatments": "اکنون در درمان‌ها شرکت می‌کنید.", + "participateEnabledTasks": "اکنون در وظایف شرکت می‌کنید.", + "participateDisabledTreatments": "دیگر در درمان‌ها شرکت نمی‌کنید.", + "participateDisabledTasks": "دیگر در وظایف شرکت نمی‌کنید.", + "participateUpdateFailed": "به‌روزرسانی تنظیمات مشارکت ممکن نشد. دوباره تلاش کنید.", + "participateConfirmRevokeTitle": "توقف مشارکت؟", + "participateConfirmRevokeBodyTreatments": "دسترسی درمان را از دست می‌دهید و از برنامه نوبت‌ها حذف می‌شوید. ساعات کاری ذخیره‌شده حفظ می‌شود.", + "participateConfirmRevokeBodyTasks": "دسترسی ویرایش وظایف را از دست می‌دهید و از گزارش کارایی حذف می‌شوید.", + "participateConfirmRevokeConfirm": "توقف مشارکت", + "changePasswordOption": "تغییر رمز عبور", "changePasswordTitle": "تغییر رمز عبور", "resetPasswordTitle": "تنظیم رمز عبور جدید", "resetPasswordSubtitle": "موبایل شما تأیید شد. رمز عبور جدید برای حساب خود انتخاب کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 98ed3c8..ba51b6c 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -757,7 +757,23 @@ "settings": { "accountTitle": "Account", "accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.", - "accountPlaceholder": "Wachtwoordwijziging en profielbewerking worden hierna hier aangesloten (bijv. uitnodigingsflow, wachtwoord herstellen).", + "participationSectionTitle": "Klinische deelname", + "participationSectionSubtitle": "Kies of u persoonlijk deelneemt aan behandelingen of labtaken voor deze organisatie.", + "participateInTreatments": "Ik wil deelnemen aan behandelingen", + "participateInTasks": "Ik wil deelnemen aan taken", + "participateWorkingHoursTitle": "Werkuren", + "participateWorkingHoursSubtitle": "Stel uw rooster in zodat afspraken voor u geboekt kunnen worden.", + "participateSaveHours": "Opslaan en deelnemen", + "participateEnabledTreatments": "U neemt nu deel aan behandelingen.", + "participateEnabledTasks": "U neemt nu deel aan taken.", + "participateDisabledTreatments": "U neemt niet langer deel aan behandelingen.", + "participateDisabledTasks": "U neemt niet langer deel aan taken.", + "participateUpdateFailed": "Deelname-instellingen konden niet worden bijgewerkt. Probeer het opnieuw.", + "participateConfirmRevokeTitle": "Deelname stoppen?", + "participateConfirmRevokeBodyTreatments": "U verliest toegang tot behandelingen en wordt uit het afsprakenrooster verwijderd. Opgeslagen werkuren blijven bewaard.", + "participateConfirmRevokeBodyTasks": "U verliest bewerkingstoegang tot taken en wordt uit het efficiëntierapport verwijderd.", + "participateConfirmRevokeConfirm": "Deelname stoppen", + "changePasswordOption": "Wachtwoord wijzigen", "changePasswordTitle": "Wachtwoord wijzigen", "resetPasswordTitle": "Nieuw wachtwoord instellen", "resetPasswordSubtitle": "Uw mobiel is geverifieerd. Kies een nieuw wachtwoord voor uw account.", diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index a8ae347..0fca833 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,18 +1,22 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, 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, useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; -import { Lock } from 'lucide-react'; +import { ChevronDown, Lock } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; +import { accountApi } from '@/lib/api/account'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { Toast } from '@/components/ui/shared/Toast'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog'; type PasswordForm = { currentPassword: string; @@ -25,13 +29,27 @@ export default function AccountSettingsPage() { const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); - const { user, isAuthReady } = useAuth(); + const { user, currentOrganization, isAuthReady, refreshSession } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const isResetFlow = searchParams.get('reset') === '1'; + + const isOwner = Boolean(currentOrganization?.isOwner); + const orgType = currentOrganization?.type; + const showClinicParticipation = isOwner && orgType === 'CLINIC'; + const showLabParticipation = isOwner && orgType === 'LAB'; + const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow); + + const [participationLoading, setParticipationLoading] = useState(false); + const [participatesInTreatments, setParticipatesInTreatments] = useState(false); + const [participatesInTasks, setParticipatesInTasks] = useState(false); + const [workingHoursOpen, setWorkingHoursOpen] = useState(false); + const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false); + const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null); const passwordSchema = useMemo( () => @@ -75,12 +93,147 @@ export default function AccountSettingsPage() { }, }); + const loadParticipation = useCallback(async () => { + if (!isOwner) return; + try { + const res = await accountApi.getParticipation(); + setParticipatesInTreatments(res.data.participatesInTreatments); + setParticipatesInTasks(res.data.participatesInTasks); + } catch { + /* non-owners or missing org context */ + } + }, [isOwner]); + useEffect(() => { if (isAuthReady && !user) { router.replace('/login'); } }, [isAuthReady, user, router]); + useEffect(() => { + if (isResetFlow) { + setPasswordExpanded(true); + } + }, [isResetFlow]); + + useEffect(() => { + void loadParticipation(); + }, [loadParticipation, currentOrganization?.id]); + + const syncSessionAfterParticipationChange = useCallback(async () => { + await refreshSession(); + }, [refreshSession]); + + const enableClinicParticipation = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + await accountApi.updateParticipation(true); + if (!options.skipHours && options.hoursPayload) { + await accountApi.upsertMyWorkingHours(options.hoursPayload); + } + setParticipatesInTreatments(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTreatments')); + }, + [syncSessionAfterParticipationChange, t], + ); + + const enableLabParticipation = async () => { + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(true); + setParticipatesInTasks(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTasks')); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('participateUpdateFailed'); + setError(message || t('participateUpdateFailed')); + setParticipatesInTasks(false); + } finally { + setParticipationLoading(false); + } + }; + + const confirmRevokeParticipation = async () => { + if (!pendingRevokeType) return; + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(false); + if (pendingRevokeType === 'CLINIC') { + setParticipatesInTreatments(false); + } else { + setParticipatesInTasks(false); + } + await syncSessionAfterParticipationChange(); + setSuccessMessage( + pendingRevokeType === 'CLINIC' + ? t('participateDisabledTreatments') + : t('participateDisabledTasks'), + ); + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('participateUpdateFailed'); + setError(message || t('participateUpdateFailed')); + } finally { + setParticipationLoading(false); + } + }; + + const handleClinicParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + setWorkingHoursOpen(true); + return; + } + setPendingRevokeType('CLINIC'); + setRevokeConfirmOpen(true); + }; + + const handleWorkingHoursClose = useCallback(() => { + setWorkingHoursOpen(false); + }, []); + + const handleWorkingHoursComplete = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + setParticipationLoading(true); + setError(null); + try { + await enableClinicParticipation(options); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('participateUpdateFailed'); + setError(message || t('participateUpdateFailed')); + throw err; + } finally { + setParticipationLoading(false); + } + }, + [enableClinicParticipation, t], + ); + + const handleLabParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + void enableLabParticipation(); + return; + } + setPendingRevokeType('LAB'); + setRevokeConfirmOpen(true); + }; + const onSubmit = async (data: PasswordForm) => { try { setError(null); @@ -121,65 +274,178 @@ export default function AccountSettingsPage() { {tCommon('backToApp')}

{t('accountTitle')}

-

- {isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')} -

+

{t('accountSubtitle')}

-
-

- {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} -

-

- {user.email} - {user.mobile ? ` · ${user.mobile}` : ''} -

+ {(showClinicParticipation || showLabParticipation) && ( +
+
+

{t('participationSectionTitle')}

+

{t('participationSectionSubtitle')}

+
-
- {!isResetFlow && ( - } - passwordToggleLabels={passwordToggleLabels} + {showClinicParticipation && ( + )} - } - passwordToggleLabels={passwordToggleLabels} - /> - - } - passwordToggleLabels={passwordToggleLabels} - /> - - {error && ( -
-

{error}

-
+ {showLabParticipation && ( + )} +
+ )} - - +
+ + + {passwordExpanded && ( +
+

+ {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} +

+ {isResetFlow && ( +

{t('resetPasswordSubtitle')}

+ )} + +
+ {!isResetFlow && ( + } + passwordToggleLabels={passwordToggleLabels} + /> + )} + + } + passwordToggleLabels={passwordToggleLabels} + /> + + } + passwordToggleLabels={passwordToggleLabels} + /> + + {error && ( +
+

{error}

+
+ )} + + +
+
+ )}
+ {error && !passwordExpanded && ( +
+

{error}

+
+ )} + + + + {revokeConfirmOpen && ( +
+
+
+

+ {t('participateConfirmRevokeTitle')} +

+ { + if (participationLoading) return; + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + }} + /> +
+

+ {pendingRevokeType === 'CLINIC' + ? t('participateConfirmRevokeBodyTreatments') + : t('participateConfirmRevokeBodyTasks')} +

+
+ + +
+
+
+ )} + {successMessage && ( {successMessage} )} diff --git a/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx b/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx new file mode 100644 index 0000000..537076b --- /dev/null +++ b/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { + StaffWorkingHoursStep, + createDefaultWorkingHoursState, + useWorkingHoursForm, + workingHoursPayloadFromState, + workingHoursStateFromApi, +} from '@/components/staff/StaffWorkingHoursStep'; +import { accountApi } from '@/lib/api/account'; + +type OwnerWorkingHoursDialogProps = { + open: boolean; + onClose: () => void; + onComplete: (options: { + skipHours: boolean; + hoursPayload?: ReturnType; + }) => Promise; +}; + +export function OwnerWorkingHoursDialog({ + open, + onClose, + onComplete, +}: OwnerWorkingHoursDialogProps) { + const t = useTranslations('settings'); + const tStaff = useTranslations('staff'); + const tCommon = useTranslations('common'); + const { + days, + setDays, + autoRepeatWeekly, + setAutoRepeatWeekly, + validationError, + setValidationError, + reset, + } = useWorkingHoursForm(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) { + setLoading(false); + return; + } + + let cancelled = false; + reset(createDefaultWorkingHoursState()); + + void accountApi + .getMyWorkingHours() + .then((res) => { + if (cancelled) return; + reset(workingHoursStateFromApi(res.data)); + }) + .catch(() => { + /* keep defaults for first-time opt-in */ + }); + + return () => { + cancelled = true; + }; + }, [open, reset]); + + const handleValidationChange = useCallback( + (error: string | null) => { + setValidationError(error); + }, + [setValidationError], + ); + + const handleClose = useCallback(() => { + if (loading) return; + onClose(); + }, [loading, onClose]); + + const handleSkip = async () => { + try { + setLoading(true); + await onComplete({ skipHours: true }); + onClose(); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (validationError) return; + try { + setLoading(true); + await onComplete({ + skipHours: false, + hoursPayload: workingHoursPayloadFromState({ days, autoRepeatWeekly }), + }); + onClose(); + } finally { + setLoading(false); + } + }; + + if (!open) { + return null; + } + + return ( +
+
+
+
+

+ {t('participateWorkingHoursTitle')} +

+

{t('participateWorkingHoursSubtitle')}

+
+ +
+ + + +
+ + + +
+
+
+ ); +} + +export { workingHoursPayloadFromState }; diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index c90b75c..cad5b75 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -142,15 +142,13 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean export function canEditTreatment(org: Organization | null): boolean { if (!org) return false; if (org.type !== 'CLINIC') return false; - if (org.isOwner) return true; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } -/** Staff treatment editors only — personal schedule Today gadgets (not owners). */ +/** Staff and participating owners — personal schedule Today gadgets */ export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean { if (!org) return false; if (org.type !== 'CLINIC') return false; - if (org.isOwner) return false; return hasPermission(org, 'TAB_TREATMENT_EDIT'); } @@ -158,7 +156,6 @@ export function canViewMyAppointmentsWeekChart(org: Organization | null): boolea export function canViewTreatment(org: Organization | null): boolean { if (!org) return false; if (org.type !== 'CLINIC') return false; - if (org.isOwner) return true; return ( hasPermission(org, 'TAB_TREATMENT_READ') || hasPermission(org, 'TAB_TREATMENT_EDIT') @@ -187,7 +184,6 @@ export function canEditCases(org: Organization | null): boolean { export function canViewTasks(org: Organization | null): boolean { if (!org) return false; if (org.type !== 'LAB') return false; - if (org.isOwner) return true; return ( hasPermission(org, 'TAB_TASKS_READ') || hasPermission(org, 'TAB_TASKS_EDIT') @@ -197,7 +193,6 @@ export function canViewTasks(org: Organization | null): boolean { export function canEditTasks(org: Organization | null): boolean { if (!org) return false; if (org.type !== 'LAB') return false; - if (org.isOwner) return true; return hasPermission(org, 'TAB_TASKS_EDIT'); } diff --git a/frontend/src/components/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/staff/StaffWorkingHoursStep.tsx index e972af7..524176e 100644 --- a/frontend/src/components/staff/StaffWorkingHoursStep.tsx +++ b/frontend/src/components/staff/StaffWorkingHoursStep.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; import { @@ -87,6 +87,12 @@ export function useWorkingHoursForm(initial?: { const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true); const [validationError, setValidationError] = useState(null); + const reset = useCallback((next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) => { + setDays(next?.days ?? emptyWorkingHoursEditorDays()); + setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true); + setValidationError(null); + }, []); + return { days, setDays, @@ -94,10 +100,6 @@ export function useWorkingHoursForm(initial?: { setAutoRepeatWeekly, validationError, setValidationError, - reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) { - setDays(next?.days ?? emptyWorkingHoursEditorDays()); - setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true); - setValidationError(null); - }, + reset, }; } diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts new file mode 100644 index 0000000..93c82ef --- /dev/null +++ b/frontend/src/lib/api/account.ts @@ -0,0 +1,48 @@ +import { apiClient } from './client'; + +export type WorkingHoursPayload = { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; +}; + +export type ParticipationData = { + membershipId: string; + orgType: 'CLINIC' | 'LAB'; + participatesInTreatments: boolean; + participatesInTasks: boolean; + hasWorkingHours: boolean; +}; + +export type ParticipationUpdateData = ParticipationData & { + permissions?: string[]; +}; + +export const accountApi = { + getParticipation: async (): Promise<{ success: boolean; data: ParticipationData }> => { + const response = await apiClient.get('/auth/profile/participation'); + return response.data; + }, + + updateParticipation: async (participate: boolean): Promise<{ + success: boolean; + data: ParticipationUpdateData; + }> => { + const response = await apiClient.patch('/auth/profile/participation', { participate }); + return response.data; + }, + + getMyWorkingHours: async (): Promise<{ + success: boolean; + data: WorkingHoursPayload & { hasWorkingHours: boolean }; + }> => { + const response = await apiClient.get('/auth/profile/working-hours'); + return response.data; + }, + + upsertMyWorkingHours: async ( + payload: WorkingHoursPayload, + ): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.put('/auth/profile/working-hours', payload); + return response.data; + }, +}; -- 2.53.0.windows.1 From 901d838a2c1035dbbb41e3694658c862e2a03ef7 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 17:51:31 +0330 Subject: [PATCH 02/24] bugfix: organization not selected problem fixed. being logged out too often fixed. --- backend/.env.example | 3 + backend/src/common/jwt-duration.ts | 39 +++++ backend/src/configs/configurations.ts | 21 +++ backend/src/modules/auth/auth.controller.ts | 46 +++++- backend/src/modules/auth/auth.service.ts | 72 ++++++++- frontend/src/lib/api/auth.ts | 9 +- frontend/src/lib/api/client.ts | 21 ++- frontend/src/lib/auth/accessToken.ts | 37 +++++ frontend/src/lib/auth/proactiveRefresh.ts | 155 ++++++++++++++++++++ frontend/src/lib/hooks/useAuth.tsx | 99 ++++++++++++- frontend/src/proxy.ts | 3 +- frontend/src/types/auth.ts | 1 + infrastructure/backend.prod.env.example | 3 + infrastructure/backend.staging.env.example | 3 + 14 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 backend/src/common/jwt-duration.ts create mode 100644 frontend/src/lib/auth/accessToken.ts create mode 100644 frontend/src/lib/auth/proactiveRefresh.ts diff --git a/backend/.env.example b/backend/.env.example index a5e8458..3d2145e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,6 +26,9 @@ API_PREFIX=/api # CORS and invite links — must match the URL where the Next.js app runs FRONTEND_URL=http://localhost:3001 +# Set true when the app is served over HTTPS (required for Secure auth cookies) +COOKIE_SECURE=false + # OAuth (optional — uncomment when configured) # GOOGLE_CLIENT_ID=your-google-client-id # GOOGLE_CLIENT_SECRET=your-google-client-secret diff --git a/backend/src/common/jwt-duration.ts b/backend/src/common/jwt-duration.ts new file mode 100644 index 0000000..ac6c2c3 --- /dev/null +++ b/backend/src/common/jwt-duration.ts @@ -0,0 +1,39 @@ +const MS_PER_UNIT: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 7 * 86_400_000, + y: 365 * 86_400_000, +}; + +/** Parse jsonwebtoken-style durations (e.g. 15m, 30d, or seconds as plain number). */ +export function jwtDurationToMs(value: string): number { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('JWT duration must not be empty'); + } + + if (/^\d+$/.test(trimmed)) { + return parseInt(trimmed, 10) * 1000; + } + + const match = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d|w|y)?$/i); + if (!match) { + throw new Error(`Invalid JWT duration: "${value}"`); + } + + const amount = parseFloat(match[1]); + const unit = (match[2] ?? 's').toLowerCase(); + const multiplier = MS_PER_UNIT[unit]; + if (!multiplier) { + throw new Error(`Invalid JWT duration unit in "${value}"`); + } + + return amount * multiplier; +} + +export function sessionExpiresAtFromNow(refreshExpiresIn: string): Date { + return new Date(Date.now() + jwtDurationToMs(refreshExpiresIn)); +} diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index a1000f5..473fa83 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -31,6 +31,20 @@ function assertJwtTimespan(value: string, envKey: string): string { return trimmed; } +function parseEnvBoolean(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined || value.trim() === '') { + return defaultValue; + } + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + return defaultValue; +} + export interface Config { port: number; database: { @@ -42,6 +56,9 @@ export interface Config { refreshSecret: string; refreshExpiresIn: string; }; + cookie: { + secure: boolean; + }; throttle: { ttl: number; limit: number; @@ -88,6 +105,7 @@ export default (): Config => { getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'), 'JWT_REFRESH_EXPIRES_IN', ); + const cookieSecure = parseEnvBoolean(process.env.COOKIE_SECURE, false); return { port: getEnvVarAsNumber('PORT', 3000), @@ -100,6 +118,9 @@ export default (): Config => { refreshSecret: jwtRefreshSecret, refreshExpiresIn: jwtRefreshExpiresIn, }, + cookie: { + secure: cookieSecure, + }, throttle: { ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100), diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index dd97c37..94292b8 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -26,6 +26,8 @@ import { } from '@nestjs/swagger'; import { AuthService } from './auth.service'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; @@ -46,7 +48,11 @@ export class AuthController { private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000; - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + ) {} // ========================= // LOGIN @@ -165,10 +171,20 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Profile retrieved successfully' }) @ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' }) async getProfile(@Req() req) { - console.log('Profile endpoint hit'); - console.log('USER FROM JWT:', req.user); + const result = await this.authService.getProfile(req.user.id); + const accessTokenExpiresAt = this.readAccessTokenExpiresAt(req); - return this.authService.getProfile(req.user.id); + if (!accessTokenExpiresAt || !result.data) { + return result; + } + + return { + ...result, + data: { + ...result.data, + accessTokenExpiresAt, + }, + }; } @Patch('profile/language') @@ -309,7 +325,10 @@ export class AuthController { throw new UnauthorizedException('Refresh token not found'); } - const result = await this.authService.refreshToken(refreshToken); + const result = await this.authService.refreshToken( + refreshToken, + req?.cookies?.accessToken, + ); this.setAccessToken( res, @@ -321,10 +340,25 @@ export class AuthController { success: true, data: { accessToken: result.data.accessToken, + accessTokenExpiresAt: result.data.accessTokenExpiresAt, }, }; } + private readAccessTokenExpiresAt(req: { cookies?: { accessToken?: string } }): string | undefined { + const token = req?.cookies?.accessToken; + if (!token) { + return undefined; + } + + const payload = this.jwtService.decode(token) as { exp?: number } | null; + if (!payload?.exp) { + return undefined; + } + + return new Date(payload.exp * 1000).toISOString(); + } + // ========================= // LOGOUT // ========================= @@ -366,7 +400,7 @@ export class AuthController { private baseCookieOptions() { return { httpOnly: true, - secure: false, // ⚠️ true in production (HTTPS) + secure: this.configService.get('cookie.secure') ?? false, sameSite: 'lax' as const, path: '/', }; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9b21706..3e88fce 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -32,6 +32,7 @@ import { ForgotPasswordVerifyDto, } from './dto/forgot-password.dto'; import { normalizeIranMobile } from '../../common/utils/mobile.util'; +import { sessionExpiresAtFromNow } from '../../common/jwt-duration'; import * as crypto from 'crypto'; const FORGOT_PASSWORD_PURPOSE = 'forgot_password'; @@ -97,6 +98,18 @@ export class AuthService { } as JwtSignOptions; } + private sessionExpiresAt(): Date { + const refreshExpiresIn = + this.configService.get('jwt.refreshExpiresIn') ?? '30d'; + return sessionExpiresAtFromNow(refreshExpiresIn); + } + + private accessTokenExpiresAt(): Date { + const accessExpiresIn = + this.configService.get('jwt.expiresIn') ?? '15m'; + return sessionExpiresAtFromNow(accessExpiresIn); + } + /** * Validate user credentials (used by LocalStrategy) * @param email - User's email @@ -182,7 +195,7 @@ export class AuthService { userId: user.id, token: accessToken, refreshToken: refreshToken, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + expiresAt: this.sessionExpiresAt(), }, }); @@ -207,6 +220,7 @@ export class AuthService { data: { accessToken, refreshToken, + accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(), user: this.toPublicUser(user), organizations, }, @@ -459,9 +473,10 @@ export class AuthService { /** * Refresh access token using refresh token * @param refreshToken - Valid refresh token + * @param previousAccessToken - Expired access token used to preserve organization context * @returns New access token */ - async refreshToken(refreshToken: string) { + async refreshToken(refreshToken: string, previousAccessToken?: string) { try { // Verify the refresh token const payload = await this.jwtService.verifyAsync(refreshToken, { @@ -506,11 +521,17 @@ export class AuthService { throw new UnauthorizedException('Invalid refresh token'); } + const organizationId = await this.resolveOrganizationIdForRefresh( + previousAccessToken, + session.user.memberships, + ); + // Generate new access token const newAccessPayload: JwtPayload = { sub: session.user.id, email: session.user.email, type: 'access', + ...(organizationId ? { organizationId } : {}), }; const newAccessToken = await this.jwtService.signAsync( @@ -523,7 +544,7 @@ export class AuthService { where: { id: session.id }, data: { token: newAccessToken, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + expiresAt: this.sessionExpiresAt(), }, }); @@ -547,6 +568,7 @@ export class AuthService { success: true, data: { accessToken: newAccessToken, + accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(), user: this.toPublicUser(session.user), organizations, }, @@ -1163,6 +1185,50 @@ export class AuthService { }; } + private async decodeAccessOrganizationId(token?: string): Promise { + if (!token?.trim()) { + return undefined; + } + + try { + const payload = (await this.jwtService.verifyAsync(token, { + secret: this.configService.get('jwt.secret')!, + ignoreExpiration: true, + })) as JwtPayload; + + return typeof payload.organizationId === 'string' ? payload.organizationId : undefined; + } catch { + return undefined; + } + } + + private async resolveOrganizationIdForRefresh( + previousAccessToken: string | undefined, + memberships: Array<{ + organizationId: string; + isOwner: boolean; + isActive: boolean; + }>, + ): Promise { + const activeMemberships = memberships.filter((m) => m.isOwner || m.isActive); + const candidateFromToken = await this.decodeAccessOrganizationId(previousAccessToken); + + if (candidateFromToken) { + const stillMember = activeMemberships.some( + (membership) => membership.organizationId === candidateFromToken, + ); + if (stillMember) { + return candidateFromToken; + } + } + + if (activeMemberships.length === 1) { + return activeMemberships[0].organizationId; + } + + return undefined; + } + private async getOwnerMembership(userId: string, organizationId: string) { if (!organizationId) { throw new BadRequestException('Organization is not selected'); diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index e861584..e56038b 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -60,9 +60,12 @@ export const authApi = { return response.data; }, - // Refresh token - refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => { - const response = await apiClient.post('/auth/refresh', { refreshToken }); + // Refresh access token using httpOnly cookies (same as the axios interceptor). + refreshSessionFromCookies: async (): Promise<{ + success: boolean; + data?: { accessToken?: string; accessTokenExpiresAt?: string }; + }> => { + const response = await apiClient.post('/auth/refresh', {}); return response.data; }, diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 6dd64e5..8ea3575 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -30,7 +30,6 @@ function isPublicInvitationRequest(url: string | undefined): boolean { function shouldSkipRefreshRetry(url: string | undefined): boolean { if (!url) return false; return ( - url.includes('/auth/profile') || url.includes('/auth/refresh') || url.includes('/auth/login') || url.includes('/auth/register') || @@ -57,13 +56,29 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { - // ✅ refresh via cookie (no body needed ideally) + // Refresh access token, then restore selected organization context on the new JWT. await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, {}, - { withCredentials: true } + { withCredentials: true }, ); + const orgId = + typeof window !== 'undefined' + ? localStorage.getItem('currentOrganizationId') + : null; + if (orgId) { + try { + await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/auth/select-organization`, + { organizationId: orgId }, + { withCredentials: true }, + ); + } catch { + /* original retry may still succeed if refresh preserved organizationId */ + } + } + return apiClient(originalRequest); } catch (refreshError) { if (typeof window !== 'undefined') { diff --git a/frontend/src/lib/auth/accessToken.ts b/frontend/src/lib/auth/accessToken.ts new file mode 100644 index 0000000..3ea0f64 --- /dev/null +++ b/frontend/src/lib/auth/accessToken.ts @@ -0,0 +1,37 @@ +/** Decode JWT payload without verification — used only for coarse expiry checks in middleware. */ +function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const json = + typeof atob === 'function' + ? atob(padded) + : Buffer.from(padded, 'base64').toString('utf8'); + const payload = JSON.parse(json) as Record; + return payload && typeof payload === 'object' ? payload : null; + } catch { + return null; + } +} + +export function isAccessTokenExpired(token: string | undefined | null): boolean { + if (!token?.trim()) { + return true; + } + + const payload = decodeJwtPayload(token); + if (!payload || typeof payload.exp !== 'number') { + return true; + } + + return payload.exp * 1000 <= Date.now(); +} + +export function hasUsableAccessToken(token: string | undefined | null): boolean { + return Boolean(token?.trim()) && !isAccessTokenExpired(token); +} diff --git a/frontend/src/lib/auth/proactiveRefresh.ts b/frontend/src/lib/auth/proactiveRefresh.ts new file mode 100644 index 0000000..8178958 --- /dev/null +++ b/frontend/src/lib/auth/proactiveRefresh.ts @@ -0,0 +1,155 @@ +import { authApi } from '@/lib/api/auth'; + +const STORAGE_KEY = 'dyolink.accessTokenExpiresAt'; +/** Refresh this long before the access JWT expires. */ +const REFRESH_BUFFER_MS = 2 * 60 * 1000; +/** Safety cap — never call /auth/refresh more than once per minute per tab. */ +const MIN_REFRESH_GAP_MS = 60 * 1000; + +let timerId: ReturnType | null = null; +let refreshInFlight: Promise | null = null; +let lastRefreshAt = 0; + +export function setAccessTokenExpiresAt(iso: string): void { + if (typeof window === 'undefined') { + return; + } + sessionStorage.setItem(STORAGE_KEY, iso); +} + +export function clearAccessTokenExpiresAt(): void { + if (typeof window === 'undefined') { + return; + } + sessionStorage.removeItem(STORAGE_KEY); +} + +export function rememberAccessTokenExpiresAt( + iso: string | undefined | null, +): void { + if (iso) { + setAccessTokenExpiresAt(iso); + } +} + +function getAccessTokenExpiresAtMs(): number | null { + if (typeof window === 'undefined') { + return null; + } + + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + + const ms = Date.parse(raw); + return Number.isFinite(ms) ? ms : null; +} + +function clearTimer(): void { + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } +} + +function computeDelayMs(expiresAtMs: number): number { + const refreshAt = expiresAtMs - REFRESH_BUFFER_MS; + const delay = refreshAt - Date.now(); + return Math.max(delay, MIN_REFRESH_GAP_MS); +} + +function shouldRefreshNow(expiresAtMs: number): boolean { + return expiresAtMs - REFRESH_BUFFER_MS <= Date.now(); +} + +async function refreshAccessTokenWithOrg(): Promise { + if (refreshInFlight) { + return refreshInFlight; + } + + if (Date.now() - lastRefreshAt < MIN_REFRESH_GAP_MS) { + const expiresAtMs = getAccessTokenExpiresAtMs(); + return expiresAtMs + ? new Date(expiresAtMs).toISOString() + : undefined; + } + + refreshInFlight = (async () => { + try { + const result = await authApi.refreshSessionFromCookies(); + const expiresAt = result.data?.accessTokenExpiresAt; + rememberAccessTokenExpiresAt(expiresAt); + + const orgId = localStorage.getItem('currentOrganizationId'); + if (orgId) { + try { + await authApi.selectOrganization(orgId); + } catch { + /* refresh may already preserve organizationId on the JWT */ + } + } + + lastRefreshAt = Date.now(); + return expiresAt; + } finally { + refreshInFlight = null; + } + })(); + + return refreshInFlight; +} + +function scheduleNextRefresh(reschedule: () => void): void { + clearTimer(); + + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + const expiresAtMs = getAccessTokenExpiresAtMs(); + if (!expiresAtMs) { + return; + } + + timerId = setTimeout(() => { + void refreshAccessTokenWithOrg() + .catch(() => { + /* reactive refresh / next visibility check will recover */ + }) + .finally(reschedule); + }, computeDelayMs(expiresAtMs)); +} + +/** Keeps the access cookie fresh while the tab is visible. Returns a cleanup function. */ +export function startProactiveSessionRefresh(): () => void { + if (typeof window === 'undefined') { + return () => undefined; + } + + const reschedule = () => scheduleNextRefresh(reschedule); + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + const expiresAtMs = getAccessTokenExpiresAtMs(); + if (expiresAtMs && shouldRefreshNow(expiresAtMs)) { + void refreshAccessTokenWithOrg() + .catch(() => undefined) + .finally(reschedule); + } else { + reschedule(); + } + return; + } + + clearTimer(); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + reschedule(); + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + clearTimer(); + }; +} diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index f037fc5..678d87d 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -11,6 +11,11 @@ import { } from '@/lib/auth/rememberMe'; import { User, Organization } from '@/types/organization'; import { isAppLocale, getLocaleFromPathname } from '@/i18n/routing'; +import { + clearAccessTokenExpiresAt, + rememberAccessTokenExpiresAt, + startProactiveSessionRefresh, +} from '@/lib/auth/proactiveRefresh'; interface AuthContextType { user: User | null; @@ -91,6 +96,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (response.success) { const { user: userData, organizations: orgs } = normalizeProfilePayload(response.data); + rememberAccessTokenExpiresAt( + (response.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt, + ); setUser(userData); setOrganizations(orgs); @@ -99,17 +107,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (storedOrgId && orgs.length > 0) { const org = orgs.find(o => o.id === storedOrgId); if (org) { - setCurrentOrganization(org); - // Ensure cookie token carries organizationId for org-scoped APIs. - await authApi.selectOrganization(org.id); + const selected = await authApi.selectOrganization(org.id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); } else { setCurrentOrganization(null); } } else if (orgs.length === 1 && userData) { - setCurrentOrganization(orgs[0]); + const selected = await authApi.selectOrganization(orgs[0].id); localStorage.setItem('currentOrganizationId', orgs[0].id); - // Keep JWT in sync with selected org even for single-org users. - await authApi.selectOrganization(orgs[0].id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); } else { setCurrentOrganization(null); } @@ -119,6 +139,61 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { (err as { statusCode?: number })?.statusCode ?? (err as { response?: { status?: number } })?.response?.status; + // Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m). + if (status === 401) { + try { + const refreshResult = await authApi.refreshSessionFromCookies(); + rememberAccessTokenExpiresAt(refreshResult.data?.accessTokenExpiresAt); + const orgId = localStorage.getItem('currentOrganizationId'); + if (orgId) { + await authApi.selectOrganization(orgId); + } + const retry = await authApi.getProfile(); + if (retry.success) { + const { user: userData, organizations: orgs } = normalizeProfilePayload(retry.data); + rememberAccessTokenExpiresAt( + (retry.data as { accessTokenExpiresAt?: string }).accessTokenExpiresAt, + ); + setUser(userData); + setOrganizations(orgs); + + const storedOrgId = localStorage.getItem('currentOrganizationId'); + if (storedOrgId && orgs.length > 0) { + const org = orgs.find((o) => o.id === storedOrgId); + if (org) { + const selected = await authApi.selectOrganization(org.id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); + } else { + setCurrentOrganization(null); + } + } else if (orgs.length === 1 && userData) { + const selected = await authApi.selectOrganization(orgs[0].id); + localStorage.setItem('currentOrganizationId', orgs[0].id); + setCurrentOrganization({ + id: selected.data.organization.id, + name: selected.data.organization.name, + type: selected.data.organization.type as Organization['type'], + isOwner: Boolean(selected.data.organization.isOwner), + permissions: selected.data.organization.permissions, + plan: selected.data.organization.plan, + }); + } else { + setCurrentOrganization(null); + } + return; + } + } catch { + /* fall through to logged-out state */ + } + } + // 401 on profile is expected when there is no session — not an application error. if (status !== 401) { console.error('Auth check failed:', err); @@ -128,6 +203,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(null); setOrganizations([]); setCurrentOrganization(null); + clearAccessTokenExpiresAt(); } finally { setIsLoading(false); setIsAuthReady(true); @@ -138,6 +214,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { void checkAuth(); }, [checkAuth]); + useEffect(() => { + if (!user || !isAuthReady) { + return; + } + + return startProactiveSessionRefresh(); + }, [user, isAuthReady]); + const applyUrlLocaleToUser = useCallback(async (user: User): Promise => { if (typeof window === 'undefined') return user; @@ -180,6 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); const userData = await applyUrlLocaleToUser(response.data.user); + rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt); setUser(userData); setOrganizations(response.data.organizations); @@ -223,6 +308,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const userData = await applyUrlLocaleToUser(response.data.user); + rememberAccessTokenExpiresAt(response.data.accessTokenExpiresAt); setUser(userData); setOrganizations(response.data.organizations); @@ -263,6 +349,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setOrganizations([]); setCurrentOrganization(null); setError(null); + clearAccessTokenExpiresAt(); setIsAuthReady(true); router.replace('/'); router.refresh(); diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 18fc536..26d1888 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -6,6 +6,7 @@ import { routing, stripLocaleFromPathname, } from './i18n/routing'; +import { hasUsableAccessToken } from './lib/auth/accessToken'; const handleI18nRouting = createMiddleware(routing); @@ -36,7 +37,7 @@ export function proxy(request: NextRequest) { const pathWithoutLocale = stripLocaleFromPathname(pathname); const locale = getLocaleFromPathname(pathname); const token = request.cookies.get('accessToken')?.value; - const isAuthenticated = !!token; + const isAuthenticated = hasUsableAccessToken(token); if (isAuthenticated && pathWithoutLocale === '/') { return NextResponse.redirect(new URL(`/${locale}/today`, request.url)); diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 7ef75b3..60a75f2 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -5,6 +5,7 @@ export interface AuthResponse { data: { accessToken: string; refreshToken: string; + accessTokenExpiresAt?: string; user: User; organizations: Organization[]; }; diff --git a/infrastructure/backend.prod.env.example b/infrastructure/backend.prod.env.example index 8a3c474..323cc86 100644 --- a/infrastructure/backend.prod.env.example +++ b/infrastructure/backend.prod.env.example @@ -14,6 +14,9 @@ JWT_REFRESH_EXPIRES_IN=30d # Must match DOMAIN in .env — used for CORS, invite links, cookies FRONTEND_URL=https://wixur.ir +# Required for HTTPS — browsers reject Secure cookies over plain HTTP +COOKIE_SECURE=true + # SMS (sms.ir) SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY SMS_IR_TEMPLATE_ID=123456 diff --git a/infrastructure/backend.staging.env.example b/infrastructure/backend.staging.env.example index 2f70917..6ae019f 100644 --- a/infrastructure/backend.staging.env.example +++ b/infrastructure/backend.staging.env.example @@ -12,6 +12,9 @@ JWT_REFRESH_EXPIRES_IN=30d # CORS, cookies, and invite links — must match how users open the app (nginx host port) FRONTEND_URL=http://178.131.50.201:8088 +# HTTP staging — keep false unless you terminate TLS in front of the app +COOKIE_SECURE=false + # SMS (sms.ir) SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY SMS_IR_TEMPLATE_ID=123456 -- 2.53.0.windows.1 From fab5111aa861ddf75f55fe0ee3e37bbd32cdcdb9 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 18:27:38 +0330 Subject: [PATCH 03/24] improvement: error handeling structure changed and unified all across the app. user no longer sees inappropriate messages. --- backend/src/common/errors/app.exception.ts | 17 ++ backend/src/common/errors/error-codes.ts | 73 +++++++ .../common/errors/http-exception.filter.ts | 184 ++++++++++++++++++ backend/src/common/errors/index.ts | 4 + .../errors/validation-exception.factory.ts | 73 +++++++ backend/src/common/guards/clinic-org.guard.ts | 5 +- backend/src/common/guards/lab-org.guard.ts | 5 +- backend/src/common/organization-type.ts | 11 +- backend/src/main.ts | 7 + backend/src/modules/auth/auth.controller.ts | 4 +- backend/src/modules/auth/auth.service.ts | 92 ++++----- .../modules/auth/dto/change-password.dto.ts | 3 +- .../auth/dto/create-organization.dto.ts | 8 +- .../modules/auth/dto/forgot-password.dto.ts | 9 +- backend/src/modules/auth/dto/login.dto.ts | 27 +-- backend/src/modules/auth/dto/register.dto.ts | 15 +- .../modules/auth/dto/update-language.dto.ts | 3 +- .../modules/auth/strategies/jwt.strategy.ts | 5 +- .../modules/auth/strategies/local.strategy.ts | 8 +- .../dto/accept-organization-invite.dto.ts | 13 +- .../staff/dto/accept-staff-invite.dto.ts | 6 +- frontend/messages/en.json | 61 ++++++ frontend/messages/fa.json | 61 ++++++ frontend/messages/nl.json | 61 ++++++ .../(dashboard)/appointments/page.tsx | 31 ++- .../app/[locale]/(dashboard)/cases/page.tsx | 9 +- .../(dashboard)/organizations/page.tsx | 13 +- .../[locale]/(dashboard)/patients/page.tsx | 7 +- .../(dashboard)/settings/account/page.tsx | 14 +- .../app/[locale]/(dashboard)/staff/page.tsx | 19 +- .../app/[locale]/(dashboard)/tasks/page.tsx | 7 +- .../app/[locale]/(dashboard)/today/page.tsx | 5 +- .../[locale]/(public)/accept-invite/page.tsx | 8 +- .../accept-organization-invite/page.tsx | 8 +- .../(public)/forgot-password/page.tsx | 8 +- .../src/app/[locale]/(public)/login/page.tsx | 5 +- .../app/[locale]/(public)/register/page.tsx | 5 +- .../src/components/shared/formatApiError.ts | 125 +++++++++++- .../ui/lab/LabCaseCommentsPanel.tsx | 9 +- .../ConnectionCaseHistoryContent.tsx | 9 +- .../OrganizationSelectorContent.tsx | 15 +- .../ui/treatment/TreatmentWorkspace.tsx | 25 +-- frontend/src/lib/api/client.ts | 31 ++- frontend/src/lib/hooks/useAuth.tsx | 45 +++-- frontend/src/types/api.ts | 65 ++++++- 45 files changed, 977 insertions(+), 241 deletions(-) create mode 100644 backend/src/common/errors/app.exception.ts create mode 100644 backend/src/common/errors/error-codes.ts create mode 100644 backend/src/common/errors/http-exception.filter.ts create mode 100644 backend/src/common/errors/index.ts create mode 100644 backend/src/common/errors/validation-exception.factory.ts diff --git a/backend/src/common/errors/app.exception.ts b/backend/src/common/errors/app.exception.ts new file mode 100644 index 0000000..0dc49d8 --- /dev/null +++ b/backend/src/common/errors/app.exception.ts @@ -0,0 +1,17 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import type { ErrorCodeValue, ValidationErrorDetail } from './error-codes'; + +export interface AppErrorResponse { + code: ErrorCodeValue; + details?: ValidationErrorDetail[] | unknown; +} + +export class AppException extends HttpException { + constructor( + code: ErrorCodeValue, + status: HttpStatus, + details?: ValidationErrorDetail[] | unknown, + ) { + super({ code, details } satisfies AppErrorResponse, status); + } +} diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts new file mode 100644 index 0000000..b3ed3fc --- /dev/null +++ b/backend/src/common/errors/error-codes.ts @@ -0,0 +1,73 @@ +/** Stable API error codes — frontend maps these to translated user messages. */ +export const ErrorCode = { + // Auth + AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS', + AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED', + AUTH_REFRESH_TOKEN_NOT_FOUND: 'AUTH_REFRESH_TOKEN_NOT_FOUND', + AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID', + AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED', + AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND', + AUTH_ORG_NOT_SELECTED: 'AUTH_ORG_NOT_SELECTED', + AUTH_ORG_ACCESS_DENIED: 'AUTH_ORG_ACCESS_DENIED', + AUTH_INVITATION_PENDING: 'AUTH_INVITATION_PENDING', + AUTH_REGISTRATION_EMAIL_EXISTS: 'AUTH_REGISTRATION_EMAIL_EXISTS', + AUTH_REGISTRATION_MOBILE_EXISTS: 'AUTH_REGISTRATION_MOBILE_EXISTS', + AUTH_REGISTRATION_MOBILE_INVALID: 'AUTH_REGISTRATION_MOBILE_INVALID', + AUTH_REGISTRATION_FAILED: 'AUTH_REGISTRATION_FAILED', + AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT', + AUTH_PASSWORD_RESET_EXPIRED: 'AUTH_PASSWORD_RESET_EXPIRED', + AUTH_VERIFICATION_CODE_INVALID: 'AUTH_VERIFICATION_CODE_INVALID', + AUTH_VERIFICATION_RATE_LIMIT: 'AUTH_VERIFICATION_RATE_LIMIT', + AUTH_SELECT_ORG_FIRST: 'AUTH_SELECT_ORG_FIRST', + AUTH_CREATE_ORG_OWNER_ONLY: 'AUTH_CREATE_ORG_OWNER_ONLY', + AUTH_PASSWORD_CURRENT_REQUIRED: 'AUTH_PASSWORD_CURRENT_REQUIRED', + + // Permission + PERMISSION_DENIED: 'PERMISSION_DENIED', + PERMISSION_ORG_MANAGE: 'PERMISSION_ORG_MANAGE', + PERMISSION_CLINIC_ONLY: 'PERMISSION_CLINIC_ONLY', + PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY', + PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER', + PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY', + PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION', + PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST', + PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS', + PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS', + PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS', + PERMISSION_ACCESS_TREATMENTS: 'PERMISSION_ACCESS_TREATMENTS', + PERMISSION_EDIT_TREATMENTS: 'PERMISSION_EDIT_TREATMENTS', + PERMISSION_ACCESS_TASKS: 'PERMISSION_ACCESS_TASKS', + PERMISSION_EDIT_TASKS: 'PERMISSION_EDIT_TASKS', + PERMISSION_ACCESS_CASES: 'PERMISSION_ACCESS_CASES', + PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF', + PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF', + PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND', + + // Validation + VALIDATION_FAILED: 'VALIDATION_FAILED', + VALIDATION_EMAIL_INVALID: 'VALIDATION_EMAIL_INVALID', + VALIDATION_PASSWORD_TOO_SHORT: 'VALIDATION_PASSWORD_TOO_SHORT', + VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED', + VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID', + VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT', + VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED', + VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID', + VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED', + VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED', + VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID', + VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST', + + // Generic HTTP + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', + BAD_REQUEST: 'BAD_REQUEST', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const; + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; + +export interface ValidationErrorDetail { + field: string; + code: ErrorCodeValue; +} diff --git a/backend/src/common/errors/http-exception.filter.ts b/backend/src/common/errors/http-exception.filter.ts new file mode 100644 index 0000000..cc9d3d7 --- /dev/null +++ b/backend/src/common/errors/http-exception.filter.ts @@ -0,0 +1,184 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { AppException, type AppErrorResponse } from './app.exception'; +import { ErrorCode, type ErrorCodeValue } from './error-codes'; + +interface ClientErrorBody { + success: false; + error: { + code: ErrorCodeValue; + statusCode: number; + details?: unknown; + }; +} + +const STATUS_FALLBACK_CODES: Partial> = { + [HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST, + [HttpStatus.UNAUTHORIZED]: ErrorCode.AUTH_UNAUTHORIZED, + [HttpStatus.FORBIDDEN]: ErrorCode.PERMISSION_DENIED, + [HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND, + [HttpStatus.CONFLICT]: ErrorCode.CONFLICT, + [HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR, +}; + +/** Maps legacy English messages to stable codes during migration. */ +const LEGACY_MESSAGE_CODES: Record = { + 'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS, + Unauthorized: ErrorCode.AUTH_UNAUTHORIZED, + 'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND, + 'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, + 'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, + 'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID, + 'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED, + 'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED, + 'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED, + 'User not found': ErrorCode.AUTH_USER_NOT_FOUND, + 'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED, + 'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED, + 'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING, + 'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED, + 'User already exists. Please login and create a new organization from your account.': + ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS, + 'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS, + 'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, + 'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT, + 'Password reset verification expired. Please verify your mobile again.': + ErrorCode.AUTH_PASSWORD_RESET_EXPIRED, + 'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID, + 'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT, + 'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED, + 'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST, + 'Only owners of the current organization can create new organizations.': + ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY, + 'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY, + 'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY, + 'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND, + 'Unknown organization type': ErrorCode.PERMISSION_DENIED, + 'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE, + 'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER, + 'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY, + 'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY, + 'An active subscription is required to participate in treatments or tasks': + ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION, + 'Enable treatment participation before setting working hours': + ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, + 'Working hours are only available for clinic organizations': + ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, + 'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, + 'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS, + 'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS, + 'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS, + 'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS, + 'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF, + 'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF, +}; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const { statusCode, body } = this.normalizeException(exception); + + if (statusCode >= 500) { + this.logger.error( + exception instanceof Error ? exception.stack : String(exception), + ); + } + + response.status(statusCode).json(body); + } + + private normalizeException(exception: unknown): { + statusCode: number; + body: ClientErrorBody; + } { + if (!(exception instanceof HttpException)) { + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + body: this.buildBody(ErrorCode.INTERNAL_ERROR, HttpStatus.INTERNAL_SERVER_ERROR), + }; + } + + const statusCode = exception.getStatus(); + const rawResponse = exception.getResponse(); + + if (exception instanceof AppException || this.isAppErrorResponse(rawResponse)) { + const appError = rawResponse as AppErrorResponse; + return { + statusCode, + body: this.buildBody(appError.code, statusCode, appError.details), + }; + } + + const code = this.resolveLegacyCode(rawResponse, statusCode); + return { + statusCode, + body: this.buildBody(code, statusCode), + }; + } + + private isAppErrorResponse(value: unknown): value is AppErrorResponse { + return ( + typeof value === 'object' && + value !== null && + 'code' in value && + typeof (value as AppErrorResponse).code === 'string' + ); + } + + private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue { + const message = this.extractMessage(rawResponse); + + if (typeof message === 'string') { + const mapped = LEGACY_MESSAGE_CODES[message.trim()]; + if (mapped) { + return mapped; + } + } + + if (Array.isArray(message)) { + return ErrorCode.VALIDATION_FAILED; + } + + return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR; + } + + private extractMessage(rawResponse: string | object): string | string[] | undefined { + if (typeof rawResponse === 'string') { + return rawResponse; + } + + if (typeof rawResponse === 'object' && rawResponse !== null && 'message' in rawResponse) { + const message = (rawResponse as { message?: string | string[] }).message; + return message; + } + + return undefined; + } + + private buildBody( + code: ErrorCodeValue, + statusCode: number, + details?: unknown, + ): ClientErrorBody { + return { + success: false, + error: { + code, + statusCode, + ...(details !== undefined ? { details } : {}), + }, + }; + } +} diff --git a/backend/src/common/errors/index.ts b/backend/src/common/errors/index.ts new file mode 100644 index 0000000..e363097 --- /dev/null +++ b/backend/src/common/errors/index.ts @@ -0,0 +1,4 @@ +export { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes'; +export { AppException, type AppErrorResponse } from './app.exception'; +export { HttpExceptionFilter } from './http-exception.filter'; +export { validationExceptionFactory } from './validation-exception.factory'; diff --git a/backend/src/common/errors/validation-exception.factory.ts b/backend/src/common/errors/validation-exception.factory.ts new file mode 100644 index 0000000..4d85008 --- /dev/null +++ b/backend/src/common/errors/validation-exception.factory.ts @@ -0,0 +1,73 @@ +import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import type { ValidationError } from 'class-validator'; +import { AppException } from './app.exception'; +import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes'; + +const KNOWN_VALIDATION_CODES = new Set(Object.values(ErrorCode)); + +function isKnownValidationCode(value: string): value is ErrorCodeValue { + return KNOWN_VALIDATION_CODES.has(value); +} + +function constraintToCode(constraintKey: string, message: string): ErrorCodeValue { + if (isKnownValidationCode(message)) { + return message; + } + + switch (constraintKey) { + case 'isEmail': + return ErrorCode.VALIDATION_EMAIL_INVALID; + case 'minLength': + return ErrorCode.VALIDATION_PASSWORD_TOO_SHORT; + case 'isEnum': + return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID; + case 'matches': + return ErrorCode.VALIDATION_MOBILE_INVALID; + case 'isIn': + return ErrorCode.VALIDATION_LANGUAGE_INVALID; + case 'whitelistValidation': + return ErrorCode.VALIDATION_INVALID_REQUEST; + default: + return ErrorCode.VALIDATION_FIELD_REQUIRED; + } +} + +function flattenValidationErrors( + errors: ValidationError[], + parentPath = '', +): ValidationErrorDetail[] { + const details: ValidationErrorDetail[] = []; + + for (const error of errors) { + const field = parentPath ? `${parentPath}.${error.property}` : error.property; + + if (error.constraints) { + const [constraintKey, message] = Object.entries(error.constraints)[0]; + details.push({ + field, + code: constraintToCode(constraintKey, message), + }); + } + + if (error.children?.length) { + details.push(...flattenValidationErrors(error.children, field)); + } + } + + return details; +} + +export function validationExceptionFactory(errors: ValidationError[]): HttpException { + const details = flattenValidationErrors(errors); + + if (details.length === 0) { + return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST); + } + + return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details); +} + +/** Handles forbidNonWhitelisted errors from ValidationPipe. */ +export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException { + return exception instanceof BadRequestException; +} diff --git a/backend/src/common/guards/clinic-org.guard.ts b/backend/src/common/guards/clinic-org.guard.ts index 5b16b09..cbe729d 100644 --- a/backend/src/common/guards/clinic-org.guard.ts +++ b/backend/src/common/guards/clinic-org.guard.ts @@ -1,11 +1,12 @@ import { CanActivate, ExecutionContext, + HttpStatus, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { assertClinicOrganization } from '../../common/organization-type'; +import { AppException, ErrorCode } from '../errors'; @Injectable() export class ClinicOrgGuard implements CanActivate { @@ -16,7 +17,7 @@ export class ClinicOrgGuard implements CanActivate { const organizationId = request.user?.organizationId; if (!organizationId) { - throw new UnauthorizedException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.UNAUTHORIZED); } await assertClinicOrganization(this.prisma, organizationId); diff --git a/backend/src/common/guards/lab-org.guard.ts b/backend/src/common/guards/lab-org.guard.ts index 69bc829..24130aa 100644 --- a/backend/src/common/guards/lab-org.guard.ts +++ b/backend/src/common/guards/lab-org.guard.ts @@ -1,11 +1,12 @@ import { CanActivate, ExecutionContext, + HttpStatus, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { assertLabOrganization } from '../../common/organization-type'; +import { AppException, ErrorCode } from '../errors'; @Injectable() export class LabOrgGuard implements CanActivate { @@ -16,7 +17,7 @@ export class LabOrgGuard implements CanActivate { const organizationId = request.user?.organizationId; if (!organizationId) { - throw new UnauthorizedException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.UNAUTHORIZED); } await assertLabOrganization(this.prisma, organizationId); diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts index e6cef18..86167e8 100644 --- a/backend/src/common/organization-type.ts +++ b/backend/src/common/organization-type.ts @@ -1,6 +1,7 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions'; +import { AppException, ErrorCode } from './errors'; export type OrganizationTypeName = 'CLINIC' | 'LAB'; @@ -82,12 +83,12 @@ export async function getOrganizationTypeName( }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } const name = org.type.name; if (name !== 'CLINIC' && name !== 'LAB') { - throw new ForbiddenException('Unknown organization type'); + throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); } return name; @@ -99,7 +100,7 @@ export async function assertClinicOrganization( ): Promise { const type = await getOrganizationTypeName(prisma, organizationId); if (type !== 'CLINIC') { - throw new ForbiddenException('This action is only available for clinic organizations'); + throw new AppException(ErrorCode.PERMISSION_CLINIC_ONLY, HttpStatus.FORBIDDEN); } } @@ -109,6 +110,6 @@ export async function assertLabOrganization( ): Promise { const type = await getOrganizationTypeName(prisma, organizationId); if (type !== 'LAB') { - throw new ForbiddenException('This action is only available for lab organizations'); + throw new AppException(ErrorCode.PERMISSION_LAB_ONLY, HttpStatus.FORBIDDEN); } } diff --git a/backend/src/main.ts b/backend/src/main.ts index a7053f1..4af42c2 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,10 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import cookieParser from 'cookie-parser'; // 👈 Change this line! import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { + HttpExceptionFilter, + validationExceptionFactory, +} from './common/errors'; // At the VERY TOP of main.ts, before anything else const originalConsoleLog = console.log; @@ -21,11 +25,14 @@ console.log = (...args) => { async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalFilters(new HttpExceptionFilter()); + // Global pipes app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, + exceptionFactory: validationExceptionFactory, })); // Cookie parser - this is correct for Express diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 94292b8..c29ec9b 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -12,7 +12,6 @@ import { Get, Patch, Put, - UnauthorizedException, } from '@nestjs/common'; import type { Response } from 'express'; import { @@ -28,6 +27,7 @@ import { import { AuthService } from './auth.service'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { AppException, ErrorCode } from '../../common/errors'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; @@ -322,7 +322,7 @@ export class AuthController { const refreshToken = req?.cookies?.refreshToken; if (!refreshToken) { - throw new UnauthorizedException('Refresh token not found'); + throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND, HttpStatus.UNAUTHORIZED); } const result = await this.authService.refreshToken( diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3e88fce..9a29aa4 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,10 +1,8 @@ // backend/src/modules/auth/auth.service.ts import { Injectable, - UnauthorizedException, - BadRequestException, - ConflictException, - ForbiddenException, + HttpStatus, + HttpException, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -33,6 +31,7 @@ import { } from './dto/forgot-password.dto'; import { normalizeIranMobile } from '../../common/utils/mobile.util'; import { sessionExpiresAtFromNow } from '../../common/jwt-duration'; +import { AppException, ErrorCode } from '../../common/errors'; import * as crypto from 'crypto'; const FORGOT_PASSWORD_PURPOSE = 'forgot_password'; @@ -242,7 +241,7 @@ export class AuthService { const email = registerDto.email.trim().toLowerCase(); if (!RegisterDto.isValidMobile(registerDto.mobile)) { - throw new BadRequestException('Please enter a valid mobile number'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST); } const mobile = normalizeIranMobile(registerDto.mobile); @@ -256,9 +255,9 @@ export class AuthService { if (existingUser) { if (existingUser.email === email) { - throw new ConflictException('User already exists. Please login and create a new organization from your account.'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS, HttpStatus.CONFLICT); } - throw new ConflictException('This mobile number is already registered.'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS, HttpStatus.CONFLICT); } // 2. Hash password @@ -315,7 +314,7 @@ export class AuthService { const validatedUser = await this.validateUser(email, password); if (!validatedUser) { - throw new UnauthorizedException('Auto-login failed'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_FAILED, HttpStatus.UNAUTHORIZED); } return this.login({ email, password } as any, validatedUser); @@ -332,13 +331,11 @@ export class AuthService { }); if (!owner) { - throw new UnauthorizedException('User not found'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED); } if (!currentOrganizationId) { - throw new ForbiddenException( - 'Select an organization before creating a new one.', - ); + throw new AppException(ErrorCode.AUTH_SELECT_ORG_FIRST, HttpStatus.FORBIDDEN); } const currentMembership = await this.prisma.membership.findUnique({ @@ -352,9 +349,7 @@ export class AuthService { }); if (!currentMembership?.isOwner) { - throw new ForbiddenException( - 'Only owners of the current organization can create new organizations.', - ); + throw new AppException(ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY, HttpStatus.FORBIDDEN); } const organization = await this.prisma.$transaction(async (tx) => { @@ -417,7 +412,7 @@ export class AuthService { }); if (!user) { - throw new UnauthorizedException('User not found'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...result } = user; @@ -485,7 +480,7 @@ export class AuthService { // Ensure this is a refresh token if (payload.type !== 'refresh') { - throw new UnauthorizedException('Invalid token type'); + throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED); } // Find session with this refresh token @@ -518,7 +513,7 @@ export class AuthService { }); if (!session) { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED); } const organizationId = await this.resolveOrganizationIdForRefresh( @@ -574,10 +569,13 @@ export class AuthService { }, }; } catch (error) { - if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { - throw new UnauthorizedException('Invalid or expired refresh token'); + if (error instanceof HttpException) { + throw error; } - throw new UnauthorizedException('Refresh token failed'); + if (error instanceof Error && (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError')) { + throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED); + } + throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED); } } @@ -601,22 +599,22 @@ export class AuthService { }); if (!user || !user.passwordHash) { - throw new BadRequestException('User not found or invalid password method'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.BAD_REQUEST); } if (skipCurrentPassword) { const hasRecentReset = await this.hasRecentPasswordResetVerification(userId); if (!hasRecentReset) { - throw new UnauthorizedException('Password reset verification expired. Please verify your mobile again.'); + throw new AppException(ErrorCode.AUTH_PASSWORD_RESET_EXPIRED, HttpStatus.UNAUTHORIZED); } } else { if (!oldPassword) { - throw new BadRequestException('Current password is required'); + throw new AppException(ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED, HttpStatus.BAD_REQUEST); } const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash); if (!isPasswordValid) { - throw new UnauthorizedException('Current password is incorrect'); + throw new AppException(ErrorCode.AUTH_PASSWORD_INCORRECT, HttpStatus.UNAUTHORIZED); } } @@ -636,7 +634,7 @@ export class AuthService { message: 'Password changed successfully. Please login again.', }; } catch (error) { - if (error instanceof UnauthorizedException || error instanceof BadRequestException) { + if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException('Failed to change password'); @@ -645,7 +643,7 @@ export class AuthService { async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) { if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) { - throw new BadRequestException('Please enter a valid mobile number'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST); } const mobile = normalizeIranMobile(dto.mobile); @@ -671,7 +669,7 @@ export class AuthService { }); if (recentCode) { - throw new BadRequestException('Please wait before requesting another code'); + throw new AppException(ErrorCode.AUTH_VERIFICATION_RATE_LIMIT, HttpStatus.BAD_REQUEST); } const code = this.generateVerificationCode(); @@ -701,7 +699,7 @@ export class AuthService { async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) { if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) { - throw new BadRequestException('Please enter a valid mobile number'); + throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST); } const mobile = normalizeIranMobile(dto.mobile); @@ -718,7 +716,7 @@ export class AuthService { }); if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) { - throw new UnauthorizedException('Invalid or expired verification code'); + throw new AppException(ErrorCode.AUTH_VERIFICATION_CODE_INVALID, HttpStatus.UNAUTHORIZED); } await this.prisma.phoneVerificationCode.update({ @@ -748,7 +746,7 @@ export class AuthService { }); if (!user) { - throw new UnauthorizedException('Invalid or expired verification code'); + throw new AppException(ErrorCode.AUTH_VERIFICATION_CODE_INVALID, HttpStatus.UNAUTHORIZED); } const loginResult = await this.login( @@ -877,7 +875,7 @@ export class AuthService { }); if (payload.type !== 'access') { - throw new UnauthorizedException('Invalid token type'); + throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED); } const session = await this.prisma.session.findFirst({ @@ -909,7 +907,7 @@ export class AuthService { }); if (!session) { - throw new UnauthorizedException('Session not found or expired'); + throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...user } = session.user; @@ -937,7 +935,7 @@ export class AuthService { }, }; } catch (error) { - throw new UnauthorizedException('Invalid token'); + throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED); } } @@ -965,10 +963,10 @@ export class AuthService { }); if (!membership) { - throw new UnauthorizedException('Access denied to this organization'); + throw new AppException(ErrorCode.AUTH_ORG_ACCESS_DENIED, HttpStatus.UNAUTHORIZED); } if (!membership.isOwner && !membership.isActive) { - throw new UnauthorizedException('Your invitation is still pending activation'); + throw new AppException(ErrorCode.AUTH_INVITATION_PENDING, HttpStatus.UNAUTHORIZED); } // 2. Build payload WITH org context @@ -1147,7 +1145,7 @@ export class AuthService { const language = dto.language; if (!SUPPORTED_USER_LANGUAGES.includes(language)) { - throw new BadRequestException('Language must be one of: en, fa, nl'); + throw new AppException(ErrorCode.VALIDATION_LANGUAGE_INVALID, HttpStatus.BAD_REQUEST); } const user = await this.prisma.user.update({ @@ -1231,7 +1229,7 @@ export class AuthService { private async getOwnerMembership(userId: string, organizationId: string) { if (!organizationId) { - throw new BadRequestException('Organization is not selected'); + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); } const membership = await this.prisma.membership.findFirst({ @@ -1245,7 +1243,7 @@ export class AuthService { }); if (!membership) { - throw new ForbiddenException('Only organization owners can manage participation'); + throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN); } return membership; @@ -1281,9 +1279,7 @@ export class AuthService { const orgType = getOrgTypeFromMembership(membership); if (!hasActivePlan(membership)) { - throw new ForbiddenException( - 'An active subscription is required to participate in treatments or tasks', - ); + throw new AppException(ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION, HttpStatus.FORBIDDEN); } if (dto.participate) { @@ -1321,7 +1317,7 @@ export class AuthService { async getMyWorkingHours(userId: string, organizationId: string) { const membership = await this.getOwnerMembership(userId, organizationId); if (getOrgTypeFromMembership(membership) !== 'CLINIC') { - throw new BadRequestException('Working hours are only available for clinic organizations'); + throw new AppException(ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, HttpStatus.BAD_REQUEST); } return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId); } @@ -1333,12 +1329,10 @@ export class AuthService { ) { const membership = await this.getOwnerMembership(userId, organizationId); if (getOrgTypeFromMembership(membership) !== 'CLINIC') { - throw new BadRequestException('Working hours are only available for clinic organizations'); + throw new AppException(ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, HttpStatus.BAD_REQUEST); } if (!participatesInTreatments(membership)) { - throw new ForbiddenException( - 'Enable treatment participation before setting working hours', - ); + throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN); } return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto); } @@ -1406,9 +1400,7 @@ export class AuthService { }); if (futureAppointment) { - throw new ConflictException( - 'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.', - ); + throw new AppException(ErrorCode.CONFLICT_FUTURE_APPOINTMENTS, HttpStatus.CONFLICT); } } else { await assertLabOrganization(this.prisma, membership.organizationId); diff --git a/backend/src/modules/auth/dto/change-password.dto.ts b/backend/src/modules/auth/dto/change-password.dto.ts index 14a9783..0b08fe2 100644 --- a/backend/src/modules/auth/dto/change-password.dto.ts +++ b/backend/src/modules/auth/dto/change-password.dto.ts @@ -1,4 +1,5 @@ import { IsOptional, IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class ChangePasswordDto { @IsOptional() @@ -6,6 +7,6 @@ export class ChangePasswordDto { currentPassword?: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) newPassword: string; } diff --git a/backend/src/modules/auth/dto/create-organization.dto.ts b/backend/src/modules/auth/dto/create-organization.dto.ts index 716744d..642bace 100644 --- a/backend/src/modules/auth/dto/create-organization.dto.ts +++ b/backend/src/modules/auth/dto/create-organization.dto.ts @@ -1,13 +1,15 @@ -import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class CreateOrganizationDto { @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; @IsOptional() diff --git a/backend/src/modules/auth/dto/forgot-password.dto.ts b/backend/src/modules/auth/dto/forgot-password.dto.ts index 02ce7fe..1dcc935 100644 --- a/backend/src/modules/auth/dto/forgot-password.dto.ts +++ b/backend/src/modules/auth/dto/forgot-password.dto.ts @@ -1,9 +1,10 @@ -import { IsString, Matches, Length } from 'class-validator'; +import { IsString, Matches, Length, MinLength } from 'class-validator'; import { isValidIranMobile } from '../../../common/utils/mobile.util'; +import { ErrorCode } from '../../../common/errors'; export class ForgotPasswordSendCodeDto { @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; static validateMobile(mobile: string): boolean { @@ -13,10 +14,10 @@ export class ForgotPasswordSendCodeDto { export class ForgotPasswordVerifyDto { @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; @IsString() - @Length(5, 6) + @Length(5, 6, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) code: string; } diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts index 32df602..a253e18 100644 --- a/backend/src/modules/auth/dto/login.dto.ts +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -1,32 +1,15 @@ -// backend/src/modules/auth/dto/login.dto.ts -import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, IsOptional, IsBoolean } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class LoginDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - required: true, - }) - @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email: string; - @ApiProperty({ - description: 'User password (min 6 characters)', - example: 'password123', - required: true, - minLength: 6, - }) @IsString() - @MinLength(6, { message: 'Password must be at least 6 characters long' }) + @MinLength(6, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; - @ApiProperty({ - description: 'Keep the user signed in for 30 days on this device', - required: false, - default: false, - }) @IsOptional() @IsBoolean() rememberMe?: boolean; -} \ No newline at end of file +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts index f3a795f..0eb3f8f 100644 --- a/backend/src/modules/auth/dto/register.dto.ts +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -1,31 +1,34 @@ import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator'; import { isValidIranMobile } from '../../../common/utils/mobile.util'; +import { ErrorCode } from '../../../common/errors'; export class RegisterDto { - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email: string; @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) name: string; @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; static isValidMobile(mobile: string): boolean { return isValidIranMobile(mobile); } -} \ 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 index 5bc91c1..16f9cbe 100644 --- a/backend/src/modules/auth/dto/update-language.dto.ts +++ b/backend/src/modules/auth/dto/update-language.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsIn, IsString } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const; export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number]; @@ -8,7 +9,7 @@ export class UpdateLanguageDto { @ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' }) @IsString() @IsIn(SUPPORTED_USER_LANGUAGES, { - message: 'Language must be one of: en, fa, nl', + message: ErrorCode.VALIDATION_LANGUAGE_INVALID, }) language: SupportedUserLanguage; } diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index 412ad61..811a262 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -1,10 +1,11 @@ // backend/src/modules/auth/strategies/jwt.strategy.ts import { Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../../../prisma/prisma.service'; import { Request } from 'express'; +import { AppException, ErrorCode } from '../../../common/errors'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); if (!user) { - throw new UnauthorizedException(); + throw new AppException(ErrorCode.AUTH_UNAUTHORIZED, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...result } = user; diff --git a/backend/src/modules/auth/strategies/local.strategy.ts b/backend/src/modules/auth/strategies/local.strategy.ts index fcec4b5..c888686 100644 --- a/backend/src/modules/auth/strategies/local.strategy.ts +++ b/backend/src/modules/auth/strategies/local.strategy.ts @@ -1,8 +1,8 @@ -// backend/src/modules/auth/strategies/local.strategy.ts import { Strategy } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { AuthService } from '../auth.service'; +import { AppException, ErrorCode } from '../../../common/errors'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { @@ -13,8 +13,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(email: string, password: string): Promise { const user = await this.authService.validateUser(email, password); if (!user) { - throw new UnauthorizedException('Invalid credentials'); + throw new AppException(ErrorCode.AUTH_INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); } return user; } -} \ No newline at end of file +} diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts index 2ef24f4..4f9c562 100644 --- a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -1,25 +1,26 @@ import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class AcceptOrganizationInviteDto { @IsString() - @MinLength(1) + @MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED }) token: string; @IsString() - @MinLength(1) + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; @IsString() - @MinLength(1) + @MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) ownerName: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; } diff --git a/backend/src/modules/staff/dto/accept-staff-invite.dto.ts b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts index 03809b5..8a5cd45 100644 --- a/backend/src/modules/staff/dto/accept-staff-invite.dto.ts +++ b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts @@ -1,14 +1,16 @@ import { IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class AcceptStaffInviteDto { @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED }) token: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; @IsString() - @MinLength(1) + @MinLength(1, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) name: string; } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6d736b3..fcd43f4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -828,5 +828,66 @@ "monthOctober": "October", "monthNovember": "November", "monthDecember": "December" + }, + "errors": { + "GENERIC": "Something went wrong. Please try again.", + "NETWORK_ERROR": "Could not reach the server. Check your connection and try again.", + "TIMEOUT": "The request took too long. Please try again.", + "AUTH_INVALID_CREDENTIALS": "Incorrect email or password.", + "AUTH_UNAUTHORIZED": "Your session has expired. Please sign in again.", + "AUTH_REFRESH_TOKEN_NOT_FOUND": "Your session has expired. Please sign in again.", + "AUTH_REFRESH_TOKEN_INVALID": "Your session has expired. Please sign in again.", + "AUTH_SESSION_EXPIRED": "Your session has expired. Please sign in again.", + "AUTH_USER_NOT_FOUND": "We could not find your account.", + "AUTH_ORG_NOT_SELECTED": "Please select an organization to continue.", + "AUTH_ORG_ACCESS_DENIED": "You do not have access to this organization.", + "AUTH_INVITATION_PENDING": "Your invitation is still pending. Ask an administrator to activate your account.", + "AUTH_REGISTRATION_EMAIL_EXISTS": "An account with this email already exists. Sign in to add a new organization from your account.", + "AUTH_REGISTRATION_MOBILE_EXISTS": "This mobile number is already registered.", + "AUTH_REGISTRATION_MOBILE_INVALID": "Please enter a valid mobile number.", + "AUTH_REGISTRATION_FAILED": "Registration could not be completed. Please try again.", + "AUTH_PASSWORD_INCORRECT": "Current password is incorrect.", + "AUTH_PASSWORD_RESET_EXPIRED": "Password reset verification expired. Please verify your mobile number again.", + "AUTH_VERIFICATION_CODE_INVALID": "Invalid or expired verification code.", + "AUTH_VERIFICATION_RATE_LIMIT": "Please wait a moment before requesting another code.", + "AUTH_SELECT_ORG_FIRST": "Select an organization before creating a new one.", + "AUTH_CREATE_ORG_OWNER_ONLY": "Only owners of the current organization can create new organizations.", + "AUTH_PASSWORD_CURRENT_REQUIRED": "Current password is required.", + "PERMISSION_DENIED": "You do not have permission to perform this action.", + "PERMISSION_ORG_MANAGE": "You do not have permission to manage organizations.", + "PERMISSION_CLINIC_ONLY": "This action is only available for clinic organizations.", + "PERMISSION_LAB_ONLY": "This action is only available for lab organizations.", + "PERMISSION_NOT_MEMBER": "You are not a member of this organization.", + "PERMISSION_OWNER_ONLY": "Only organization owners can perform this action.", + "PERMISSION_PARTICIPATION_SUBSCRIPTION": "An active subscription is required to participate in treatments or tasks.", + "PERMISSION_ENABLE_PARTICIPATION_FIRST": "Enable treatment participation before setting working hours.", + "PERMISSION_CLINIC_WORKING_HOURS": "Working hours are only available for clinic organizations.", + "PERMISSION_ACCESS_APPOINTMENTS": "You do not have access to appointments.", + "PERMISSION_EDIT_APPOINTMENTS": "You cannot create or modify appointments.", + "PERMISSION_ACCESS_TREATMENTS": "You do not have access to treatments.", + "PERMISSION_EDIT_TREATMENTS": "You cannot edit treatments.", + "PERMISSION_ACCESS_TASKS": "You do not have access to tasks.", + "PERMISSION_EDIT_TASKS": "You cannot edit tasks.", + "PERMISSION_ACCESS_CASES": "You do not have access to cases.", + "PERMISSION_ACCESS_STAFF": "You do not have access to staff management.", + "PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.", + "PERMISSION_ORG_NOT_FOUND": "Organization not found.", + "VALIDATION_FAILED": "Please check the form and try again.", + "VALIDATION_EMAIL_INVALID": "Please enter a valid email address.", + "VALIDATION_PASSWORD_TOO_SHORT": "Password is too short.", + "VALIDATION_PASSWORD_REQUIRED": "Password is required.", + "VALIDATION_MOBILE_INVALID": "Please enter a valid mobile number.", + "VALIDATION_NAME_TOO_SHORT": "Name is too short.", + "VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organization name is required.", + "VALIDATION_ORGANIZATION_TYPE_INVALID": "Please select a valid organization type.", + "VALIDATION_TOKEN_REQUIRED": "This link is invalid or incomplete.", + "VALIDATION_FIELD_REQUIRED": "Please fill in all required fields.", + "VALIDATION_LANGUAGE_INVALID": "Please select a supported language.", + "VALIDATION_INVALID_REQUEST": "The request contains invalid data.", + "NOT_FOUND": "The requested item was not found.", + "CONFLICT": "This action conflicts with existing data.", + "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", + "BAD_REQUEST": "The request could not be processed.", + "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 281c9ed..edf6bcd 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -829,5 +829,66 @@ "monthOctober": "اکتبر", "monthNovember": "نوامبر", "monthDecember": "دسامبر" + }, + "errors": { + "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", + "NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.", + "TIMEOUT": "درخواست بیش از حد طول کشید. لطفاً دوباره تلاش کنید.", + "AUTH_INVALID_CREDENTIALS": "ایمیل یا رمز عبور نادرست است.", + "AUTH_UNAUTHORIZED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.", + "AUTH_REFRESH_TOKEN_NOT_FOUND": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.", + "AUTH_REFRESH_TOKEN_INVALID": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.", + "AUTH_SESSION_EXPIRED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.", + "AUTH_USER_NOT_FOUND": "حساب کاربری یافت نشد.", + "AUTH_ORG_NOT_SELECTED": "لطفاً یک سازمان را انتخاب کنید.", + "AUTH_ORG_ACCESS_DENIED": "به این سازمان دسترسی ندارید.", + "AUTH_INVITATION_PENDING": "دعوت‌نامه شما هنوز فعال نشده است. از مدیر بخواهید حساب شما را فعال کند.", + "AUTH_REGISTRATION_EMAIL_EXISTS": "حسابی با این ایمیل وجود دارد. وارد شوید و از حساب خود سازمان جدید بسازید.", + "AUTH_REGISTRATION_MOBILE_EXISTS": "این شماره موبایل قبلاً ثبت شده است.", + "AUTH_REGISTRATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.", + "AUTH_REGISTRATION_FAILED": "ثبت‌نام انجام نشد. لطفاً دوباره تلاش کنید.", + "AUTH_PASSWORD_INCORRECT": "رمز عبور فعلی نادرست است.", + "AUTH_PASSWORD_RESET_EXPIRED": "زمان بازیابی رمز عبور تمام شده است. دوباره شماره موبایل را تأیید کنید.", + "AUTH_VERIFICATION_CODE_INVALID": "کد تأیید نامعتبر یا منقضی شده است.", + "AUTH_VERIFICATION_RATE_LIMIT": "لطفاً کمی صبر کنید و دوباره درخواست کد دهید.", + "AUTH_SELECT_ORG_FIRST": "قبل از ایجاد سازمان جدید، یک سازمان را انتخاب کنید.", + "AUTH_CREATE_ORG_OWNER_ONLY": "فقط مالک سازمان فعلی می‌تواند سازمان جدید ایجاد کند.", + "AUTH_PASSWORD_CURRENT_REQUIRED": "رمز عبور فعلی الزامی است.", + "PERMISSION_DENIED": "اجازه انجام این کار را ندارید.", + "PERMISSION_ORG_MANAGE": "اجازه مدیریت سازمان‌ها را ندارید.", + "PERMISSION_CLINIC_ONLY": "این عمل فقط برای کلینیک‌ها در دسترس است.", + "PERMISSION_LAB_ONLY": "این عمل فقط برای لابراتوارها در دسترس است.", + "PERMISSION_NOT_MEMBER": "عضو این سازمان نیستید.", + "PERMISSION_OWNER_ONLY": "فقط مالک سازمان می‌تواند این کار را انجام دهد.", + "PERMISSION_PARTICIPATION_SUBSCRIPTION": "برای شرکت در درمان یا وظایف، اشتراک فعال لازم است.", + "PERMISSION_ENABLE_PARTICIPATION_FIRST": "قبل از تنظیم ساعات کاری، مشارکت در درمان را فعال کنید.", + "PERMISSION_CLINIC_WORKING_HOURS": "ساعات کاری فقط برای کلینیک‌ها در دسترس است.", + "PERMISSION_ACCESS_APPOINTMENTS": "به نوبت‌ها دسترسی ندارید.", + "PERMISSION_EDIT_APPOINTMENTS": "نمی‌توانید نوبت ایجاد یا ویرایش کنید.", + "PERMISSION_ACCESS_TREATMENTS": "به درمان‌ها دسترسی ندارید.", + "PERMISSION_EDIT_TREATMENTS": "نمی‌توانید درمان‌ها را ویرایش کنید.", + "PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.", + "PERMISSION_EDIT_TASKS": "نمی‌توانید وظایف را ویرایش کنید.", + "PERMISSION_ACCESS_CASES": "به پرونده‌ها دسترسی ندارید.", + "PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.", + "PERMISSION_EDIT_STAFF": "نمی‌توانید ساعات کاری پرسنل را مدیریت کنید.", + "PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.", + "VALIDATION_FAILED": "لطفاً فرم را بررسی و دوباره تلاش کنید.", + "VALIDATION_EMAIL_INVALID": "لطفاً یک ایمیل معتبر وارد کنید.", + "VALIDATION_PASSWORD_TOO_SHORT": "رمز عبور کوتاه است.", + "VALIDATION_PASSWORD_REQUIRED": "رمز عبور الزامی است.", + "VALIDATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.", + "VALIDATION_NAME_TOO_SHORT": "نام کوتاه است.", + "VALIDATION_ORGANIZATION_NAME_REQUIRED": "نام سازمان الزامی است.", + "VALIDATION_ORGANIZATION_TYPE_INVALID": "نوع سازمان معتبر انتخاب کنید.", + "VALIDATION_TOKEN_REQUIRED": "این لینک نامعتبر یا ناقص است.", + "VALIDATION_FIELD_REQUIRED": "لطفاً همه فیلدهای الزامی را پر کنید.", + "VALIDATION_LANGUAGE_INVALID": "زبان پشتیبانی‌شده انتخاب کنید.", + "VALIDATION_INVALID_REQUEST": "درخواست حاوی داده نامعتبر است.", + "NOT_FOUND": "مورد درخواستی یافت نشد.", + "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", + "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", + "BAD_REQUEST": "درخواست قابل پردازش نبود.", + "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } } \ No newline at end of file diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index ba51b6c..878c88d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -829,5 +829,66 @@ "monthOctober": "Oktober", "monthNovember": "November", "monthDecember": "December" + }, + "errors": { + "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", + "NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.", + "TIMEOUT": "Het verzoek duurde te lang. Probeer het opnieuw.", + "AUTH_INVALID_CREDENTIALS": "Onjuist e-mailadres of wachtwoord.", + "AUTH_UNAUTHORIZED": "Uw sessie is verlopen. Meld u opnieuw aan.", + "AUTH_REFRESH_TOKEN_NOT_FOUND": "Uw sessie is verlopen. Meld u opnieuw aan.", + "AUTH_REFRESH_TOKEN_INVALID": "Uw sessie is verlopen. Meld u opnieuw aan.", + "AUTH_SESSION_EXPIRED": "Uw sessie is verlopen. Meld u opnieuw aan.", + "AUTH_USER_NOT_FOUND": "We konden uw account niet vinden.", + "AUTH_ORG_NOT_SELECTED": "Selecteer een organisatie om door te gaan.", + "AUTH_ORG_ACCESS_DENIED": "U hebt geen toegang tot deze organisatie.", + "AUTH_INVITATION_PENDING": "Uw uitnodiging is nog in behandeling. Vraag een beheerder om uw account te activeren.", + "AUTH_REGISTRATION_EMAIL_EXISTS": "Er bestaat al een account met dit e-mailadres. Meld u aan om een nieuwe organisatie toe te voegen.", + "AUTH_REGISTRATION_MOBILE_EXISTS": "Dit mobiele nummer is al geregistreerd.", + "AUTH_REGISTRATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.", + "AUTH_REGISTRATION_FAILED": "Registratie kon niet worden voltooid. Probeer het opnieuw.", + "AUTH_PASSWORD_INCORRECT": "Huidig wachtwoord is onjuist.", + "AUTH_PASSWORD_RESET_EXPIRED": "Wachtwoordherstel is verlopen. Verifieer uw mobiele nummer opnieuw.", + "AUTH_VERIFICATION_CODE_INVALID": "Ongeldige of verlopen verificatiecode.", + "AUTH_VERIFICATION_RATE_LIMIT": "Wacht even voordat u opnieuw een code aanvraagt.", + "AUTH_SELECT_ORG_FIRST": "Selecteer een organisatie voordat u een nieuwe aanmaakt.", + "AUTH_CREATE_ORG_OWNER_ONLY": "Alleen eigenaren van de huidige organisatie kunnen nieuwe organisaties aanmaken.", + "AUTH_PASSWORD_CURRENT_REQUIRED": "Huidig wachtwoord is verplicht.", + "PERMISSION_DENIED": "U hebt geen toestemming voor deze actie.", + "PERMISSION_ORG_MANAGE": "U hebt geen toestemming om organisaties te beheren.", + "PERMISSION_CLINIC_ONLY": "Deze actie is alleen beschikbaar voor klinieken.", + "PERMISSION_LAB_ONLY": "Deze actie is alleen beschikbaar voor laboratoria.", + "PERMISSION_NOT_MEMBER": "U bent geen lid van deze organisatie.", + "PERMISSION_OWNER_ONLY": "Alleen organisatie-eigenaren kunnen dit doen.", + "PERMISSION_PARTICIPATION_SUBSCRIPTION": "Een actief abonnement is vereist om deel te nemen aan behandelingen of taken.", + "PERMISSION_ENABLE_PARTICIPATION_FIRST": "Schakel deelname aan behandelingen in voordat u werktijden instelt.", + "PERMISSION_CLINIC_WORKING_HOURS": "Werktijden zijn alleen beschikbaar voor klinieken.", + "PERMISSION_ACCESS_APPOINTMENTS": "U hebt geen toegang tot afspraken.", + "PERMISSION_EDIT_APPOINTMENTS": "U kunt geen afspraken maken of wijzigen.", + "PERMISSION_ACCESS_TREATMENTS": "U hebt geen toegang tot behandelingen.", + "PERMISSION_EDIT_TREATMENTS": "U kunt behandelingen niet bewerken.", + "PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.", + "PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.", + "PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.", + "PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.", + "PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.", + "PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.", + "VALIDATION_FAILED": "Controleer het formulier en probeer het opnieuw.", + "VALIDATION_EMAIL_INVALID": "Voer een geldig e-mailadres in.", + "VALIDATION_PASSWORD_TOO_SHORT": "Wachtwoord is te kort.", + "VALIDATION_PASSWORD_REQUIRED": "Wachtwoord is verplicht.", + "VALIDATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.", + "VALIDATION_NAME_TOO_SHORT": "Naam is te kort.", + "VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organisatienaam is verplicht.", + "VALIDATION_ORGANIZATION_TYPE_INVALID": "Selecteer een geldig organisatietype.", + "VALIDATION_TOKEN_REQUIRED": "Deze link is ongeldig of onvolledig.", + "VALIDATION_FIELD_REQUIRED": "Vul alle verplichte velden in.", + "VALIDATION_LANGUAGE_INVALID": "Selecteer een ondersteunde taal.", + "VALIDATION_INVALID_REQUEST": "Het verzoek bevat ongeldige gegevens.", + "NOT_FOUND": "Het gevraagde item is niet gevonden.", + "CONFLICT": "Deze actie conflicteert met bestaande gegevens.", + "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", + "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", + "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } } \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index df110b4..517c6b3 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -20,7 +20,7 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { ToastStack } from '@/components/ui/shared/Toast'; import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; const EMPTY_PATIENT_FORM: CreatePatientInput = { @@ -32,6 +32,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { export default function AppointmentsPage() { const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); const tPatients = useTranslations('patients'); const { currentOrganization } = useAuth(); const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); @@ -105,7 +106,7 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule'))); + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); @@ -178,11 +179,7 @@ export default function AppointmentsPage() { ); } } catch (err: unknown) { - const message = - err && typeof err === 'object' && 'message' in err - ? String((err as { message: unknown }).message) - : tPatients('errorSavePatient'); - toast.showError(message); + toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); } finally { setSavingPatient(false); } @@ -248,13 +245,13 @@ export default function AppointmentsPage() { 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 - ? t('errorUpdate') - : t('errorSave'); - toast.showError(message); + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); } finally { setSavingAppointment(false); } @@ -276,11 +273,7 @@ export default function AppointmentsPage() { toast.showSuccess(t('successRemoved')); await loadSchedule(); } catch (err: unknown) { - const message = - err && typeof err === 'object' && 'message' in err - ? String((err as { message: unknown }).message) - : t('errorDelete'); - toast.showError(message); + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); } finally { setDeletingAppointment(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index c6df3f4..0eeadd9 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { canEditCases, canEditTasks } from '@/components/shared/permissions'; @@ -35,6 +35,7 @@ const PAGE_SIZE = 20; export default function CasesPage() { const t = useTranslations('cases'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); const toast = useToast(); @@ -112,7 +113,7 @@ export default function CasesPage() { setCases(response.data.items); setPagination(response.data.pagination); } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadList'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); } finally { setLoadingList(false); } @@ -127,7 +128,7 @@ export default function CasesPage() { const response = await casesApi.getOne(caseId); setSelectedCase(response.data); } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadDetail'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); if (!options?.silent) { setSelectedCase(null); } @@ -218,7 +219,7 @@ export default function CasesPage() { setSelectedCase(response.data); } catch (error: unknown) { setSelectedCase(previousCase); - toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); } finally { setUpdatingImportant(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index 954960e..65230c9 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -24,7 +24,7 @@ import { Input } from '@/components/ui/shared/Input'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Table } from '@/components/ui/shared/Table'; import { ToastStack } from '@/components/ui/shared/Toast'; -import type { ApiError } from '@/types/api'; +import { getUserFacingError } from '@/components/shared/formatApiError'; function formatOrganizationStatusLabel(status: string): string { if (!status) return status; @@ -42,6 +42,7 @@ type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); @@ -49,14 +50,8 @@ export default function OrganizationsPage() { 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], + (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), + [tCommon, tErrors], ); const formatConnectionStatusLabel = useCallback( diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 9184d3e..e84ae56 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -5,7 +5,7 @@ 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 { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; @@ -23,6 +23,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { export default function PatientsPage() { const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const toast = useToast(); @@ -67,7 +68,7 @@ export default function PatientsPage() { setSelectedPatient(freshSelected); } } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadPatients'))); } finally { setLoadingPatients(false); } @@ -98,7 +99,7 @@ export default function PatientsPage() { ); } } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient'))); } finally { setSavingPatient(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 0fca833..9585e41 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/shared/Input'; import { Toast } from '@/components/ui/shared/Toast'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog'; type PasswordForm = { @@ -26,6 +27,7 @@ type PasswordForm = { export default function AccountSettingsPage() { const t = useTranslations('settings'); + const tErrors = useTranslations('errors'); const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); @@ -152,8 +154,7 @@ export default function AccountSettingsPage() { await syncSessionAfterParticipationChange(); setSuccessMessage(t('participateEnabledTasks')); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); setParticipatesInTasks(false); } finally { setParticipationLoading(false); @@ -180,8 +181,7 @@ export default function AccountSettingsPage() { setRevokeConfirmOpen(false); setPendingRevokeType(null); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); } finally { setParticipationLoading(false); } @@ -214,8 +214,7 @@ export default function AccountSettingsPage() { try { await enableClinicParticipation(options); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); throw err; } finally { setParticipationLoading(false); @@ -249,8 +248,7 @@ export default function AccountSettingsPage() { setSuccessMessage(t('passwordChanged')); router.replace('/login'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('passwordChangeFailed'); - setError(message || t('passwordChangeFailed')); + setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); } finally { setIsSubmitting(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index bdfe947..6f4ccef 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -35,7 +35,7 @@ import { Input } from '@/components/ui/shared/Input'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Table } from '@/components/ui/shared/Table'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList'; import { useToast } from '@/lib/hooks/useToast'; @@ -147,6 +147,7 @@ function PermissionGrid({ export default function StaffPage() { const router = useRouter(); const t = useTranslations('staff'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const tFeatures = useTranslations('staff.features'); const tWorkingHours = useTranslations('staff.workingHours'); @@ -229,7 +230,7 @@ export default function StaffPage() { setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadStaff'))); + toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff'))); } finally { setLoading(false); } @@ -304,7 +305,7 @@ export default function StaffPage() { await load(); } } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } @@ -387,7 +388,7 @@ export default function StaffPage() { resetInviteForm(); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorSendInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite'))); } finally { setInviteLoading(false); } @@ -410,7 +411,7 @@ export default function StaffPage() { setEditWorkingHoursDays(state.days); setEditAutoRepeatWeekly(state.autoRepeatWeekly); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours'))); + toast.showError(getUserFacingError(e, tErrors, t('errorLoadWorkingHours'))); } finally { setEditLoadingWorkingHours(false); } @@ -449,7 +450,7 @@ export default function StaffPage() { setEditStep(1); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember'))); } finally { setEditLoading(false); } @@ -470,7 +471,7 @@ export default function StaffPage() { setDisableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember'))); } finally { setDisablingMembershipId(null); } @@ -487,7 +488,7 @@ export default function StaffPage() { setEnableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember'))); } finally { setEnablingMembershipId(null); } @@ -600,7 +601,7 @@ export default function StaffPage() { setCopiedInviteMembershipId(lastInviteInfo.membershipId); setTimeout(() => setCopiedInviteMembershipId(null), 1500); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index d3f2b24..9898c9d 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -17,7 +17,7 @@ import { formatToothList, prosthesisTypeBadgeStyle, } from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; @@ -38,6 +38,7 @@ function formatPatientName(patient: { firstName: string; lastName: string }) { export default function TasksPage() { const t = useTranslations('tasks'); + const tErrors = useTranslations('errors'); const { currentOrganization, user, isAuthReady } = useAuth(); const { showError, setError, messages: toastMessages } = useToast(); @@ -107,7 +108,7 @@ export default function TasksPage() { setTasks(response.data.items); setPagination(response.data.pagination); } catch (error: unknown) { - showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); + showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList'))); } finally { setLoading(false); } @@ -127,7 +128,7 @@ export default function TasksPage() { await tasksApi.updateStatus(taskId, status); await loadTasks(); } catch (error: unknown) { - showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); } finally { setUpdatingTaskId(null); } diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index b51a2a2..65e938c 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { TodayDashboard } from '@/components/today/TodayDashboard'; import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; @@ -13,6 +13,7 @@ import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; export default function TodayPage() { const t = useTranslations('today'); + const tErrors = useTranslations('errors'); const { currentOrganization } = useAuth(); const orgId = currentOrganization?.id; const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); @@ -54,7 +55,7 @@ export default function TodayPage() { {error ? ( void reload()} isRetrying={loading && Boolean(data)} diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 15b79e2..443a137 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -7,10 +7,12 @@ 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 { getUserFacingError } from '@/components/shared/formatApiError'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); @@ -49,8 +51,7 @@ function AcceptInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -86,8 +87,7 @@ function AcceptInviteContent() { router.replace('/login'); }, 1000); } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index afe6515..6362110 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { organizationApi } from '@/lib/api/organization'; type AcceptOrganizationInviteForm = { @@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = { function AcceptOrganizationInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const params = useSearchParams(); @@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() { 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 || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx index 30442e7..29eca1d 100644 --- a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { Phone, ShieldCheck } from 'lucide-react'; import { authApi } from '@/lib/api/auth'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { Button } from '@/components/ui/shared/Button'; @@ -29,6 +30,7 @@ export default function ForgotPasswordPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const router = useRouter(); const { refreshSession } = useAuth(); const [step, setStep] = useState<'mobile' | 'code'>('mobile'); @@ -76,8 +78,7 @@ export default function ForgotPasswordPage() { setSentMobile(mobile); setStep('code'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('codeSendFailed'); - setError(message || t('codeSendFailed')); + setError(getUserFacingError(err, tErrors, t('codeSendFailed'))); } finally { setIsSending(false); } @@ -116,8 +117,7 @@ export default function ForgotPasswordPage() { await refreshSession(); router.push('/settings/account?reset=1'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('verifyFailed'); - setError(message || t('verifyFailed')); + setError(getUserFacingError(err, tErrors, t('verifyFailed'))); } finally { setIsVerifying(false); } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index b37e80a..5e1487c 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -8,6 +8,7 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { getRememberedEmail } from '@/lib/auth/rememberMe'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; @@ -25,6 +26,7 @@ export default function LoginPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const { login, isLoading, user, isAuthReady } = useAuth(); const router = useRouter(); const [error, setError] = useState(null); @@ -67,8 +69,7 @@ export default function LoginPage() { setError(null); await login(data.email, data.password, data.rememberMe); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('invalidCredentials'); - setError(message || t('invalidCredentials')); + setError(getUserFacingError(err, tErrors, t('invalidCredentials'))); } }; diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 7ebe926..36ec6ae 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -7,6 +7,7 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock, User, Phone } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; @@ -36,6 +37,7 @@ export default function RegisterPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const { registerTrial, isLoading } = useAuth(); const [step, setStep] = useState(1); const [error, setError] = useState(null); @@ -110,8 +112,7 @@ export default function RegisterPage() { data.organizationType, ); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('registrationFailed'); - setError(message || t('registrationFailed')); + setError(getUserFacingError(err, tErrors, t('registrationFailed'))); } }; diff --git a/frontend/src/components/shared/formatApiError.ts b/frontend/src/components/shared/formatApiError.ts index d60c0e1..5748d97 100644 --- a/frontend/src/components/shared/formatApiError.ts +++ b/frontend/src/components/shared/formatApiError.ts @@ -1,12 +1,123 @@ -export function formatApiErrorMessage(err: unknown, fallback: string): string { - if (err && typeof err === 'object' && 'message' in err) { - const m = (err as { message: unknown }).message; - if (Array.isArray(m)) { - return m.filter(Boolean).join(', '); +import { asApiError, isApiError, type ApiError, type ApiErrorDetail } from '@/types/api'; + +type TranslateFn = (key: string) => string; + +const STATUS_FALLBACK_KEYS: Record = { + 401: 'AUTH_UNAUTHORIZED', + 403: 'PERMISSION_DENIED', + 404: 'NOT_FOUND', + 409: 'CONFLICT', + 500: 'INTERNAL_ERROR', +}; + +function translateKey(t: TranslateFn, key: string): string | null { + const translated = t(key); + return translated !== key ? translated : null; +} + +function formatValidationDetails(details: ApiErrorDetail[], t: TranslateFn): string | null { + const messages = details + .map((detail) => translateKey(t, detail.code)) + .filter((message): message is string => Boolean(message)); + + if (messages.length === 0) { + return null; + } + + return messages.join(' '); +} + +function resolveFromStatus(statusCode: number, t: TranslateFn): string | null { + const key = STATUS_FALLBACK_KEYS[statusCode] ?? (statusCode >= 500 ? 'INTERNAL_ERROR' : null); + return key ? translateKey(t, key) : null; +} + +function normalizeLegacyError(value: unknown): ApiError | null { + if (isApiError(value)) { + return value; + } + + if (typeof value === 'object' && value !== null && 'statusCode' in value) { + const statusCode = + typeof (value as { statusCode?: unknown }).statusCode === 'number' + ? (value as { statusCode: number }).statusCode + : 500; + + return { + statusCode, + code: + statusCode === 401 + ? 'AUTH_UNAUTHORIZED' + : statusCode === 403 + ? 'PERMISSION_DENIED' + : statusCode >= 500 + ? 'INTERNAL_ERROR' + : 'BAD_REQUEST', + }; + } + + if (value instanceof Error) { + if (value.message === 'Network Error') { + return { statusCode: 0, code: 'NETWORK_ERROR' }; } - if (typeof m === 'string' && m.trim()) { - return m; + if (value.message.includes('timeout')) { + return { statusCode: 0, code: 'TIMEOUT' }; } } + + return null; +} + +/** Maps API error codes (and status fallbacks) to translated user-facing text. */ +export function getUserFacingError( + err: unknown, + t: TranslateFn, + featureFallback?: string, +): string { + const apiError = asApiError(err) ?? normalizeLegacyError(err); + + if (apiError?.code === 'VALIDATION_FAILED' && apiError.details?.length) { + const validationMessage = formatValidationDetails(apiError.details, t); + if (validationMessage) { + return validationMessage; + } + } + + if (apiError?.code) { + const codeMessage = translateKey(t, apiError.code); + if (codeMessage) { + return codeMessage; + } + } + + if (apiError?.statusCode) { + const statusMessage = resolveFromStatus(apiError.statusCode, t); + if (statusMessage) { + return statusMessage; + } + } + + if (featureFallback?.trim()) { + return featureFallback; + } + + return translateKey(t, 'GENERIC') ?? 'Something went wrong'; +} + +/** @deprecated Prefer getUserFacingError(err, tErrors, fallback). */ +export function formatApiErrorMessage( + err: unknown, + fallback: string, + t?: TranslateFn, +): string { + if (t) { + return getUserFacingError(err, t, fallback); + } + + const apiError = asApiError(err); + if (apiError?.code) { + return apiError.code; + } + return fallback; } diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx index ff666c5..a76fbf2 100644 --- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx +++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent } from 'react'; import { useTranslations } from 'next-intl'; import { Eye, EyeOff, Send } from 'lucide-react'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import type { LabCaseComment } from '@/types/cases'; interface LabCaseCommentsPanelProps { @@ -36,6 +36,7 @@ export function LabCaseCommentsPanel({ onComposerValueChange, }: LabCaseCommentsPanelProps) { const t = useTranslations('caseComments'); + const tErrors = useTranslations('errors'); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(false); const [posting, setPosting] = useState(false); @@ -48,7 +49,7 @@ export function LabCaseCommentsPanel({ const items = await loadComments(); setComments(items); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorLoad'))); + onError?.(getUserFacingError(error, tErrors, t('errorLoad'))); } finally { setLoading(false); } @@ -68,7 +69,7 @@ export function LabCaseCommentsPanel({ setBody(''); setVisibleToClinic(false); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorPost'))); + onError?.(getUserFacingError(error, tErrors, t('errorPost'))); } finally { setPosting(false); } @@ -87,7 +88,7 @@ export function LabCaseCommentsPanel({ const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic); setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorToggle'))); + onError?.(getUserFacingError(error, tErrors, t('errorToggle'))); } } diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index 44f1af6..fa057cf 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { canEditCases } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; @@ -37,6 +37,7 @@ export function ConnectionCaseHistoryContent({ onBack, }: ConnectionCaseHistoryContentProps) { const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); const tCases = useTranslations('cases'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); @@ -102,7 +103,7 @@ export function ConnectionCaseHistoryContent({ setPagination(response.data.pagination); } catch (error: unknown) { if (cancelled) return; - showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList'))); + showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadList'))); } finally { if (!cancelled) setLoadingList(false); } @@ -142,7 +143,7 @@ export function ConnectionCaseHistoryContent({ setSelectedCase(response.data); } catch (error: unknown) { if (cancelled) return; - showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail'))); + showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadDetail'))); setSelectedCase(null); } finally { if (!cancelled) setLoadingDetail(false); @@ -182,7 +183,7 @@ export function ConnectionCaseHistoryContent({ setSelectedCase(response.data); } catch (error: unknown) { setSelectedCase(previousCase); - showError(formatApiErrorMessage(error, tCases('errorUpdateTask'))); + showError(getUserFacingError(error, tErrors, tCases('errorUpdateTask'))); } finally { setUpdatingImportant(false); } diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index a18efd6..b1505a4 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; import { Building2, Beaker, Mail } from 'lucide-react'; @@ -12,13 +13,14 @@ export function OrganizationSelectorContent() { const t = useTranslations('organizations'); const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); + const tErrors = useTranslations('errors'); const { organizations, currentOrganization, selectOrganization, createOrganization, isLoading, - error, + apiError, clearError, } = useAuth(); const canCreateOrganization = useMemo( @@ -82,6 +84,12 @@ export function OrganizationSelectorContent() { )}
+ {apiError && ( +
+

{getUserFacingError(apiError, tErrors)}

+
+ )} + {canCreateOrganization && isCreateOpen && (
- {error && ( -
-

{error}

-
- )}
- {mode === 'search' && ( - - )} - + ) : undefined } /> - {loading ? ( + {loading || (mode === 'search' && searching) ? ( {tCommon('loadingEllipsis')} -- 2.53.0.windows.1 From 1cdf853d32211720c45b113998a96ac0046dbfca Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 18:56:01 +0330 Subject: [PATCH 05/24] improvement: appointments history component added to patients feature. --- .../patients/dto/create-patient.dto.ts | 8 +- .../modules/patients/patients.controller.ts | 10 ++ .../src/modules/patients/patients.service.ts | 92 +++++++++++-- frontend/messages/en.json | 15 ++- frontend/messages/fa.json | 15 ++- frontend/messages/nl.json | 15 ++- .../[locale]/(dashboard)/patients/page.tsx | 4 + .../ui/patient/CreatePatientModal.tsx | 98 ++++++++++++-- .../ui/patient/PatientAppointmentHistory.tsx | 122 ++++++++++++++++++ .../ui/patient/PatientSummaryCard.tsx | 3 +- frontend/src/lib/api/patients.ts | 6 + frontend/src/types/patient.ts | 14 ++ 12 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/ui/patient/PatientAppointmentHistory.tsx diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index 5e72673..21a2ae7 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -1,20 +1,24 @@ -import { IsDateString, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class CreatePatientDto { @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(80) firstName: string; @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(80) lastName: string; @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @MaxLength(30) mobile: string; @IsOptional() - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email?: string; @IsOptional() diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index cd6fe86..b40b2f0 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -37,6 +37,16 @@ export class PatientsController { return this.patientsService.findAll(query); } + @Get(':id/appointments') + @ApiOperation({ + summary: + 'List this patient\'s appointments for the current clinic (requires TAB_PATIENTS_READ; not gated by appointments permission)', + }) + listAppointments(@Param('id') id: string, @Req() req: { user: { id: string; organizationId?: string } }) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.listAppointments(id, organizationId, req.user.id); + } + @Get(':id') @ApiOperation({ summary: 'Get one patient by id' }) findOne(@Param('id') id: string) { diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index 6a1f6fc..a573acd 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -1,6 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone'; +import { hasEffectivePermission } from '../../common/membership-permissions'; +import { AppException, ErrorCode } from '../../common/errors'; import { CreatePatientDto } from './dto/create-patient.dto'; import { ListPatientsDto } from './dto/list-patients.dto'; import { UpdatePatientDto } from './dto/update-patient.dto'; @@ -10,6 +12,8 @@ export class PatientsService { constructor(private readonly prisma: PrismaService) {} async create(createPatientDto: CreatePatientDto, organizationId: string) { + const firstName = this.requireNonEmptyName(createPatientDto.firstName, 'firstName'); + const lastName = this.requireNonEmptyName(createPatientDto.lastName, 'lastName'); const mobile = this.resolveMobile(createPatientDto.mobile); const existing = await this.prisma.patient.findUnique({ @@ -22,8 +26,8 @@ export class PatientsService { const patient = await this.prisma.patient.create({ data: { - firstName: createPatientDto.firstName.trim(), - lastName: createPatientDto.lastName.trim(), + firstName, + lastName, mobile, email: createPatientDto.email?.trim() || null, notes: createPatientDto.notes?.trim() || null, @@ -92,10 +96,10 @@ export class PatientsService { } = {}; if (updatePatientDto.firstName !== undefined) { - data.firstName = updatePatientDto.firstName.trim(); + data.firstName = this.requireNonEmptyName(updatePatientDto.firstName, 'firstName'); } if (updatePatientDto.lastName !== undefined) { - data.lastName = updatePatientDto.lastName.trim(); + data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName'); } if (updatePatientDto.mobile !== undefined) { data.mobile = this.resolveMobile(updatePatientDto.mobile); @@ -120,6 +124,42 @@ export class PatientsService { return { success: true, data: patient }; } + async listAppointments( + patientId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanViewPatients(actorUserId, organizationId); + await this.ensurePatient(patientId); + + const items = await this.prisma.appointment.findMany({ + where: { organizationId, patientId }, + orderBy: [{ startAt: 'desc' }], + }); + + const providerIds = [...new Set(items.map((item) => item.providerUserId))]; + const providers = + providerIds.length === 0 + ? [] + : await this.prisma.user.findMany({ + where: { id: { in: providerIds } }, + select: { id: true, name: true }, + }); + const providerNameById = new Map(providers.map((p) => [p.id, p.name])); + + return { + success: true, + data: items.map((item) => ({ + id: item.id, + startAt: item.startAt, + endAt: item.endAt, + purpose: item.purpose, + providerUserId: item.providerUserId, + providerName: providerNameById.get(item.providerUserId) ?? '', + })), + }; + } + getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); @@ -148,15 +188,51 @@ export class PatientsService { } private resolveMobile(raw: string): string { + if (!raw?.trim()) { + throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [ + { field: 'mobile', code: ErrorCode.VALIDATION_FIELD_REQUIRED }, + ]); + } + const mobile = normalizeMobile(raw); if (!mobile || !isValidMobile(mobile)) { - throw new BadRequestException( - 'Invalid mobile number. Use a valid Iran mobile (e.g. 09121234567 or +989121234567).', - ); + throw new AppException(ErrorCode.VALIDATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST, [ + { field: 'mobile', code: ErrorCode.VALIDATION_MOBILE_INVALID }, + ]); } return mobile; } + private requireNonEmptyName(value: string, field: 'firstName' | 'lastName'): string { + const trimmed = value?.trim() ?? ''; + if (!trimmed) { + throw new AppException(ErrorCode.VALIDATION_FIELD_REQUIRED, HttpStatus.BAD_REQUEST, [ + { field, code: ErrorCode.VALIDATION_FIELD_REQUIRED }, + ]); + } + return trimmed; + } + + private async assertCanViewPatients(userId: string, organizationId: string) { + const membership = await this.prisma.membership.findUnique({ + where: { + userId_organizationId: { userId, organizationId }, + }, + include: { + organization: { include: { type: true, plan: true } }, + permissions: { include: { permission: true } }, + }, + }); + + if (!membership) { + throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN); + } + + if (!hasEffectivePermission(membership, 'TAB_PATIENTS_READ')) { + throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); + } + } + private async ensurePatient(id: string) { const patient = await this.prisma.patient.findUnique({ where: { id }, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fcd43f4..f0d67e6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Active", "statusInactive": "Inactive", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Fields marked with * are required. Email is optional.", + "firstNameRequired": "First name is required.", + "lastNameRequired": "Last name is required.", + "emailOptional": "Email (optional)", + "emailOptionalSummary": "Email (optional):", + "appointmentHistoryTitle": "Appointment history", + "appointmentHistorySubtitle": "Past and upcoming appointments for this patient at your clinic.", + "appointmentHistoryLoading": "Loading appointment history…", + "appointmentHistoryEmpty": "No appointments recorded for this patient yet.", + "appointmentHistoryError": "Could not load appointment history.", + "appointmentHistoryProvider": "Provider: {name}", + "appointmentHistoryUnknownProvider": "Unknown provider" }, "cases": { "title": "Cases", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index edf6bcd..5c56946 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -393,7 +393,20 @@ "statusLabel": "وضعیت:", "statusActive": "فعال", "statusInactive": "غیرفعال", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "فیلدهای دارای * الزامی هستند. ایمیل اختیاری است.", + "firstNameRequired": "نام الزامی است.", + "lastNameRequired": "نام خانوادگی الزامی است.", + "emailOptional": "ایمیل (اختیاری)", + "emailOptionalSummary": "ایمیل (اختیاری):", + "appointmentHistoryTitle": "سوابق نوبت", + "appointmentHistorySubtitle": "نوبت‌های گذشته و آینده این بیمار در کلینیک شما.", + "appointmentHistoryLoading": "در حال بارگذاری سوابق نوبت…", + "appointmentHistoryEmpty": "هنوز نوبتی برای این بیمار ثبت نشده است.", + "appointmentHistoryError": "بارگذاری سوابق نوبت انجام نشد.", + "appointmentHistoryProvider": "ارائه‌دهنده: {name}", + "appointmentHistoryUnknownProvider": "ارائه‌دهنده نامشخص" }, "cases": { "title": "پرونده‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 878c88d..8abac2d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -393,7 +393,20 @@ "statusLabel": "Status:", "statusActive": "Actief", "statusInactive": "Inactief", - "emptyValue": "-" + "emptyValue": "-", + "requiredMark": "*", + "requiredFieldsHint": "Velden met * zijn verplicht. E-mail is optioneel.", + "firstNameRequired": "Voornaam is verplicht.", + "lastNameRequired": "Achternaam is verplicht.", + "emailOptional": "E-mail (optioneel)", + "emailOptionalSummary": "E-mail (optioneel):", + "appointmentHistoryTitle": "Afspraakgeschiedenis", + "appointmentHistorySubtitle": "Eerdere en komende afspraken voor deze patiënt in uw kliniek.", + "appointmentHistoryLoading": "Afspraakgeschiedenis laden…", + "appointmentHistoryEmpty": "Er zijn nog geen afspraken voor deze patiënt.", + "appointmentHistoryError": "Kon afspraakgeschiedenis niet laden.", + "appointmentHistoryProvider": "Behandelaar: {name}", + "appointmentHistoryUnknownProvider": "Onbekende behandelaar" }, "cases": { "title": "Dossiers", diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index e84ae56..9266431 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -13,6 +13,7 @@ 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'; +import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -154,6 +155,9 @@ export default function PatientsPage() {
+ {selectedPatient ? ( + + ) : null}
diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index e1da9a7..edeebde 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; @@ -22,6 +23,8 @@ interface CreatePatientModalProps { variant?: 'inline' | 'dialog'; } +type FieldErrors = Partial>; + function CreatePatientFormFields({ formData, onChange, @@ -39,46 +42,113 @@ function CreatePatientFormFields({ }) { const t = useTranslations('patients'); const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const [fieldErrors, setFieldErrors] = useState({}); + + const requiredMark = t('requiredMark'); + + const validate = (): boolean => { + const nextErrors: FieldErrors = {}; + + if (!formData.firstName?.trim()) { + nextErrors.firstName = t('firstNameRequired'); + } + if (!formData.lastName?.trim()) { + nextErrors.lastName = t('lastNameRequired'); + } + if (!formData.mobile?.trim()) { + nextErrors.mobile = tValidation('mobileRequired'); + } else if (!isValidMobile(normalizeMobile(formData.mobile) ?? '')) { + nextErrors.mobile = tValidation('mobileInvalid'); + } + + if (formData.email?.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email.trim())) { + nextErrors.email = tValidation('emailInvalid'); + } + + setFieldErrors(nextErrors); + return Object.keys(nextErrors).length === 0; + }; + + const handleSubmit = () => { + if (!validate()) { + return; + } + onSubmit(); + }; + + const isSubmitDisabled = useMemo( + () => + !formData.firstName?.trim() || + !formData.lastName?.trim() || + !isValidMobile(normalizeMobile(formData.mobile || '') ?? ''), + [formData.firstName, formData.lastName, formData.mobile], + ); return ( <> +

{t('requiredFieldsHint')}

+
onChange({ firstName: e.target.value })} + onChange={(e) => { + onChange({ firstName: e.target.value }); + if (fieldErrors.firstName) { + setFieldErrors((prev) => ({ ...prev, firstName: undefined })); + } + }} + required + error={fieldErrors.firstName} /> onChange({ lastName: e.target.value })} + onChange={(e) => { + onChange({ lastName: e.target.value }); + if (fieldErrors.lastName) { + setFieldErrors((prev) => ({ ...prev, lastName: undefined })); + } + }} + required + error={fieldErrors.lastName} /> onChange({ mobile: e.target.value })} + onChange={(e) => { + onChange({ mobile: e.target.value }); + if (fieldErrors.mobile) { + setFieldErrors((prev) => ({ ...prev, mobile: undefined })); + } + }} placeholder={t('mobilePlaceholder')} + required + error={fieldErrors.mobile} /> onChange({ email: e.target.value })} + onChange={(e) => { + onChange({ email: e.target.value }); + if (fieldErrors.email) { + setFieldErrors((prev) => ({ ...prev, email: undefined })); + } + }} + error={fieldErrors.email} />
diff --git a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx new file mode 100644 index 0000000..93d86c1 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { formatTimeForInput } from '@/components/appointments/appointmentTime'; +import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { patientsApi } from '@/lib/api/patients'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { PatientAppointmentHistoryItem } from '@/types/patient'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; + +interface PatientAppointmentHistoryProps { + patientId: string; +} + +function formatAppointmentDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function PatientAppointmentHistory({ patientId }: PatientAppointmentHistoryProps) { + const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((response) => setTreatmentCatalog(response.data)) + .catch(() => {}); + }, []); + + useEffect(() => { + let cancelled = false; + + void (async () => { + setLoading(true); + setError(null); + try { + const response = await patientsApi.listAppointments(patientId); + if (!cancelled) { + setItems(response.data); + } + } catch (err: unknown) { + if (!cancelled) { + setError(getUserFacingError(err, tErrors, t('appointmentHistoryError'))); + setItems([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [patientId, t, tErrors]); + + return ( +
+
+

{t('appointmentHistoryTitle')}

+

{t('appointmentHistorySubtitle')}

+
+ + {loading ? ( +

{t('appointmentHistoryLoading')}

+ ) : error ? ( +

{error}

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

{t('appointmentHistoryEmpty')}

+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    +

    + {formatAppointmentDate(item.startAt)} +

    +

    + {formatTimeForInput(new Date(item.startAt))} + {' – '} + {formatTimeForInput(new Date(item.endAt))} +

    +
    +
    + {item.purpose ? ( + + ) : null} +

    + {t('appointmentHistoryProvider', { + name: item.providerName || t('appointmentHistoryUnknownProvider'), + })} +

    +
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index 63974d2..5a714c0 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -28,7 +28,8 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { {t('mobileLabel')} {formatMobileForDisplay(patient.mobile)}

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

{t('statusLabel')}{' '} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index 27309e6..a4ea0f6 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -3,6 +3,7 @@ import { CreatePatientInput, CreatePatientResponse, Patient, + PatientAppointmentHistoryResponse, PatientsListResponse, } from '@/types/patient'; @@ -21,4 +22,9 @@ export const patientsApi = { const response = await apiClient.get(`/patients/${id}`); return response.data; }, + + listAppointments: async (patientId: string): Promise => { + const response = await apiClient.get(`/patients/${patientId}/appointments`); + return response.data; + }, }; diff --git a/frontend/src/types/patient.ts b/frontend/src/types/patient.ts index 0fa9aee..1dad2bf 100644 --- a/frontend/src/types/patient.ts +++ b/frontend/src/types/patient.ts @@ -39,3 +39,17 @@ export interface CreatePatientResponse { data: Patient; existing?: boolean; } + +export interface PatientAppointmentHistoryItem { + id: string; + startAt: string; + endAt: string; + purpose: string; + providerUserId: string; + providerName: string; +} + +export interface PatientAppointmentHistoryResponse { + success: boolean; + data: PatientAppointmentHistoryItem[]; +} -- 2.53.0.windows.1 From 0d3fb0a51d4e1877fe992f3b8eb8fbe381d57bc2 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 21:08:29 +0330 Subject: [PATCH 06/24] improvement: some files replaced, lots of them i shall say. AGENT.MD file created. some rules and skills added for cursor agent. --- .cursor/rules/api-errors-i18n.mdc | 28 + .cursor/rules/backend-nestjs.mdc | 41 + .cursor/rules/dyolink-overview.mdc | 29 + .cursor/rules/frontend-components.mdc | 47 + .cursor/rules/maintain-agent-docs.mdc | 35 + .cursor/skills/add-feature/SKILL.md | 69 + .cursor/skills/api-errors/SKILL.md | 40 + .cursor/skills/capture-convention/SKILL.md | 53 + .cursor/skills/frontend-structure/SKILL.md | 39 + AGENTS.md | 89 ++ README.md | 2 + .../(dashboard)/appointments/page.tsx | 374 +----- .../app/[locale]/(dashboard)/billing/page.tsx | 297 +---- .../app/[locale]/(dashboard)/cases/page.tsx | 473 +------ .../(dashboard)/organizations/page.tsx | 605 +-------- .../[locale]/(dashboard)/patients/page.tsx | 166 +-- .../(dashboard)/settings/account/page.tsx | 453 +------ .../settings/subscriptions/page.tsx | 205 +-- .../app/[locale]/(dashboard)/staff/page.tsx | 1123 +---------------- .../app/[locale]/(dashboard)/tasks/page.tsx | 404 +----- .../app/[locale]/(dashboard)/today/page.tsx | 81 +- .../appointments/appointmentPurposeStyles.ts | 2 +- .../{ui => }/lab/caseDetailUtils.ts | 0 .../{ui => }/lab/labTaskStatusDisplay.ts | 0 .../organizations/connectionStatusVariant.ts | 16 + .../catalog-type-colors.ts | 0 .../{ui => }/shared/formSelectStyles.ts | 0 .../treatmentTypeDisplay.ts | 2 +- .../src/components/staff/staffPermissions.ts | 48 - frontend/src/components/today/chart-theme.ts | 2 +- .../treatment/prosthesisTypeDisplay.ts | 2 +- .../{ui => }/treatment/toothPathModel.ts | 0 .../treatment/treatmentStatusStyles.ts | 0 .../appointments/AppointmentBookingModal.tsx | 2 +- .../AppointmentOverlapPopover.tsx | 2 +- .../appointments/AppointmentScheduleGrid.tsx | 2 +- .../AppointmentScheduleLegend.tsx | 2 +- .../ui/appointments/AppointmentsPage.tsx | 373 ++++++ .../src/components/ui/billing/BillingPage.tsx | 296 +++++ .../src/components/ui/lab/CaseDetailPanel.tsx | 6 +- .../components/ui/lab/CaseToothChartPanel.tsx | 2 +- frontend/src/components/ui/lab/CasesPage.tsx | 472 +++++++ frontend/src/components/ui/lab/TasksPage.tsx | 403 ++++++ .../ConnectionCaseHistoryContent.tsx | 4 +- .../organizations/InvitationHistoryDialog.tsx | 3 +- .../OrganizationConnectionsMobileList.tsx | 3 +- .../ui/organizations/OrganizationsPage.tsx | 603 +++++++++ .../ui/patient/PatientAppointmentHistory.tsx | 2 +- .../components/ui/patient/PatientsPage.tsx | 165 +++ .../ui/settings/AccountSettingsPage.tsx | 452 +++++++ .../settings/OwnerWorkingHoursDialog.tsx | 2 +- .../ui/settings/SubscriptionsPage.tsx | 204 +++ frontend/src/components/ui/shared/Badge.tsx | 15 - .../{ => ui}/staff/StaffMembersMobileList.tsx | 0 .../src/components/ui/staff/StaffPage.tsx | 1122 ++++++++++++++++ .../{ => ui}/staff/StaffWorkingHoursStep.tsx | 2 +- .../{ => ui}/staff/WorkingHoursEditor.tsx | 0 .../components/{ => ui}/today/ChartCard.tsx | 2 +- .../src/components/{ => ui}/today/KpiCard.tsx | 0 .../{ => ui}/today/TodayAreaChart.tsx | 2 +- .../{ => ui}/today/TodayBarChart.tsx | 2 +- .../{ => ui}/today/TodayChartFrame.tsx | 0 .../today/TodayCompletionGaugeKpiCard.tsx | 2 +- .../{ => ui}/today/TodayDashboard.tsx | 30 +- .../{ => ui}/today/TodayDashboardGrid.tsx | 0 .../{ => ui}/today/TodayDonutChart.tsx | 2 +- .../today/TodayHorizontalBarChart.tsx | 2 +- .../today/TodayLabTaskActivityChart.tsx | 2 +- .../{ => ui}/today/TodayLoadErrorBanner.tsx | 0 .../src/components/ui/today/TodayPage.tsx | 80 ++ .../TodayPartnerCasesStackedBarChart.tsx | 2 +- .../{ => ui}/today/TodayRadialGaugeChart.tsx | 0 .../today/TodaySectionErrorFallback.tsx | 0 .../{ => ui}/today/TodaySkeleton.tsx | 0 .../today/TodaySubscriptionKpiCard.tsx | 2 +- .../today/TodayUpcomingAppointments.tsx | 6 +- .../today/TodayWidgetErrorBoundary.tsx | 0 .../ui/treatment/AppointmentsStrip.tsx | 2 +- .../ui/treatment/DetailLabSendBadge.tsx | 2 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 4 +- .../components/ui/treatment/ToothGlyph.tsx | 2 +- .../treatment/TreatmentDetailSummaryRow.tsx | 2 +- .../ui/treatment/TreatmentDetailsEditor.tsx | 4 +- .../treatment/TreatmentHistoryDetailLine.tsx | 2 +- .../ui/treatment/TreatmentTypeBadge.tsx | 2 +- .../ui/treatment/TreatmentWorkspace.tsx | 2 +- 86 files changed, 4758 insertions(+), 4260 deletions(-) create mode 100644 .cursor/rules/api-errors-i18n.mdc create mode 100644 .cursor/rules/backend-nestjs.mdc create mode 100644 .cursor/rules/dyolink-overview.mdc create mode 100644 .cursor/rules/frontend-components.mdc create mode 100644 .cursor/rules/maintain-agent-docs.mdc create mode 100644 .cursor/skills/add-feature/SKILL.md create mode 100644 .cursor/skills/api-errors/SKILL.md create mode 100644 .cursor/skills/capture-convention/SKILL.md create mode 100644 .cursor/skills/frontend-structure/SKILL.md create mode 100644 AGENTS.md rename frontend/src/components/{ui => }/appointments/appointmentPurposeStyles.ts (95%) rename frontend/src/components/{ui => }/lab/caseDetailUtils.ts (100%) rename frontend/src/components/{ui => }/lab/labTaskStatusDisplay.ts (100%) create mode 100644 frontend/src/components/organizations/connectionStatusVariant.ts rename frontend/src/components/{ui/treatment => shared}/catalog-type-colors.ts (100%) rename frontend/src/components/{ui => }/shared/formSelectStyles.ts (100%) rename frontend/src/components/{ui/treatment => shared}/treatmentTypeDisplay.ts (97%) delete mode 100644 frontend/src/components/staff/staffPermissions.ts rename frontend/src/components/{ui => }/treatment/prosthesisTypeDisplay.ts (95%) rename frontend/src/components/{ui => }/treatment/toothPathModel.ts (100%) rename frontend/src/components/{ui => }/treatment/treatmentStatusStyles.ts (100%) create mode 100644 frontend/src/components/ui/appointments/AppointmentsPage.tsx create mode 100644 frontend/src/components/ui/billing/BillingPage.tsx create mode 100644 frontend/src/components/ui/lab/CasesPage.tsx create mode 100644 frontend/src/components/ui/lab/TasksPage.tsx create mode 100644 frontend/src/components/ui/organizations/OrganizationsPage.tsx create mode 100644 frontend/src/components/ui/patient/PatientsPage.tsx create mode 100644 frontend/src/components/ui/settings/AccountSettingsPage.tsx rename frontend/src/components/{ => ui}/settings/OwnerWorkingHoursDialog.tsx (98%) create mode 100644 frontend/src/components/ui/settings/SubscriptionsPage.tsx rename frontend/src/components/{ => ui}/staff/StaffMembersMobileList.tsx (100%) create mode 100644 frontend/src/components/ui/staff/StaffPage.tsx rename frontend/src/components/{ => ui}/staff/StaffWorkingHoursStep.tsx (97%) rename frontend/src/components/{ => ui}/staff/WorkingHoursEditor.tsx (100%) rename frontend/src/components/{ => ui}/today/ChartCard.tsx (97%) rename frontend/src/components/{ => ui}/today/KpiCard.tsx (100%) rename frontend/src/components/{ => ui}/today/TodayAreaChart.tsx (96%) rename frontend/src/components/{ => ui}/today/TodayBarChart.tsx (97%) rename frontend/src/components/{ => ui}/today/TodayChartFrame.tsx (100%) rename frontend/src/components/{ => ui}/today/TodayCompletionGaugeKpiCard.tsx (95%) rename frontend/src/components/{ => ui}/today/TodayDashboard.tsx (94%) rename frontend/src/components/{ => ui}/today/TodayDashboardGrid.tsx (100%) rename frontend/src/components/{ => ui}/today/TodayDonutChart.tsx (98%) rename frontend/src/components/{ => ui}/today/TodayHorizontalBarChart.tsx (96%) rename frontend/src/components/{ => ui}/today/TodayLabTaskActivityChart.tsx (98%) rename frontend/src/components/{ => ui}/today/TodayLoadErrorBanner.tsx (100%) create mode 100644 frontend/src/components/ui/today/TodayPage.tsx rename frontend/src/components/{ => ui}/today/TodayPartnerCasesStackedBarChart.tsx (97%) rename frontend/src/components/{ => ui}/today/TodayRadialGaugeChart.tsx (100%) rename frontend/src/components/{ => ui}/today/TodaySectionErrorFallback.tsx (100%) rename frontend/src/components/{ => ui}/today/TodaySkeleton.tsx (100%) rename frontend/src/components/{ => ui}/today/TodaySubscriptionKpiCard.tsx (97%) rename frontend/src/components/{ => ui}/today/TodayUpcomingAppointments.tsx (95%) rename frontend/src/components/{ => ui}/today/TodayWidgetErrorBoundary.tsx (100%) diff --git a/.cursor/rules/api-errors-i18n.mdc b/.cursor/rules/api-errors-i18n.mdc new file mode 100644 index 0000000..713e1f6 --- /dev/null +++ b/.cursor/rules/api-errors-i18n.mdc @@ -0,0 +1,28 @@ +--- +description: Error codes backend ↔ frontend and i18n message keys +globs: backend/src/common/errors/**,frontend/src/components/shared/formatApiError.ts,frontend/messages/** +alwaysApply: false +--- + +# API errors & translations + +## Adding a new error + +1. Add code to `backend/src/common/errors/error-codes.ts` +2. Throw via `AppException` (or validation DTO with that code) +3. Add matching key under `errors` in **all three** message files: + - `frontend/messages/en.json` + - `frontend/messages/fa.json` + - `frontend/messages/nl.json` +4. Frontend catch: `getUserFacingError(err, tErrors, t('fallbackKey'))` + +## Validation field errors + +Backend returns `{ success: false, error: { code, details: [{ field, code }] } }`. + +Frontend maps `details[].code` through the `errors` namespace. + +## Do not + +- Show raw `error.message` or stack traces to users. +- Add English-only strings inline in components. diff --git a/.cursor/rules/backend-nestjs.mdc b/.cursor/rules/backend-nestjs.mdc new file mode 100644 index 0000000..07d3bdf --- /dev/null +++ b/.cursor/rules/backend-nestjs.mdc @@ -0,0 +1,41 @@ +--- +description: Backend NestJS modules, Prisma, permissions, guards +globs: backend/src/** +alwaysApply: false +--- + +# Backend conventions + +## Module layout + +`backend/src/modules/{feature}/` → `{feature}.module.ts`, `.controller.ts`, `.service.ts`, `dto/`. + +Register new modules in `app.module.ts`. + +## Errors + +Use coded errors — not raw user-facing strings: + +```typescript +throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); +``` + +- Codes: `backend/src/common/errors/error-codes.ts` +- DTO validation: `{ message: ErrorCode.VALIDATION_* }` on class-validator decorators +- Global filter: `HttpExceptionFilter` in `main.ts` + +## Permissions + +- Check access with `hasEffectivePermission(membership, 'TAB_*')` from `common/membership-permissions.ts`. +- Clinic-only routes: `ClinicOrgGuard`. Lab-only: `LabOrgGuard`. +- Feature-specific checks belong in the **service**, not only the controller. + +## Prisma + +- Schema: `backend/prisma/schema.prisma` +- Always add a migration for schema changes (`npm run prisma:migrate` in backend). +- Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`. + +## API responses + +Prefer `{ success: true, data: ... }` shape consistent with existing modules. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc new file mode 100644 index 0000000..581523d --- /dev/null +++ b/.cursor/rules/dyolink-overview.mdc @@ -0,0 +1,29 @@ +--- +description: Dyolink project context — stack, org types, git safety, verification +alwaysApply: true +--- + +# Dyolink overview + +Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infrastructure/` (Docker). + +## Domain + +- **CLINIC** orgs: patients, appointments, treatment, staff. +- **LAB** orgs: cases, tasks, lab workflows. +- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ. + +## Agent behavior + +- Read `AGENTS.md` and file-scoped rules before large changes. +- **Never commit or push** unless the user explicitly asks. +- Prefer minimal diffs; reuse existing components and API patterns. +- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`. + +## i18n + +All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**. + +## Treatment / appointment colors + +Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc new file mode 100644 index 0000000..aecfc52 --- /dev/null +++ b/.cursor/rules/frontend-components.mdc @@ -0,0 +1,47 @@ +--- +description: Frontend folder structure — ui vs non-ui, thin pages, feature layout +globs: frontend/src/** +alwaysApply: false +--- + +# Frontend component structure + +## Rules + +| Kind | Location | +|------|----------| +| Cross-feature UI | `components/ui/shared/` | +| Feature UI | `components/ui/{feature}/` | +| Cross-feature non-UI | `components/shared/` | +| Feature non-UI | `components/{feature}/` | +| Route logic | `components/ui/{feature}/{Feature}Page.tsx` | +| App routes | `app/**/page.tsx` — **thin wrapper only** | + +## Thin page pattern + +```tsx +'use client'; +import { PatientsPage } from '@/components/ui/patient/PatientsPage'; +export default function Page() { + return ; +} +``` + +Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWorkspace.tsx`. + +## Do not + +- Put React components (`.tsx` with JSX) in `components/` outside `ui/`. +- Put pure helpers (`.ts`, no JSX) inside `components/ui/`. +- Put business logic, API calls, or large forms directly in `app/**/page.tsx`. + +## API & errors + +- API clients: `lib/api/`. +- Catch blocks: `getUserFacingError(err, tErrors, fallback)` from `components/shared/formatApiError.ts`. + +## When adding UI + +1. Check `components/ui/shared/` for an existing primitive. +2. Check the feature's `ui/{feature}/` folder for an existing pattern. +3. Add i18n keys to en, fa, and nl. diff --git a/.cursor/rules/maintain-agent-docs.mdc b/.cursor/rules/maintain-agent-docs.mdc new file mode 100644 index 0000000..b57d3c8 --- /dev/null +++ b/.cursor/rules/maintain-agent-docs.mdc @@ -0,0 +1,35 @@ +--- +description: When and how to update AGENTS.md, rules, and skills after new conventions +alwaysApply: true +--- + +# Maintaining agent docs + +Rules and skills **load automatically** but **do not self-update**. Update them when the user establishes a durable convention. + +## Update when the user says (or clearly means) + +- "Remember this" / "Save as convention" / "Add to project rules" +- "Document this for future agents" +- "We always do X in this project" (and it is not already in rules/skills) + +## Where to put new knowledge + +| Kind of knowledge | Update | +|-------------------|--------| +| Always true, 1–5 bullets | `.cursor/rules/*.mdc` (pick existing file or create new, <50 lines) | +| Multi-step workflow | `.cursor/skills/{name}/SKILL.md` | +| Project map / onboarding | `AGENTS.md` (index only — link to rules/skills) | + +## Do not auto-update when + +- One-off task instructions ("fix this bug today") +- Experimental code not yet agreed as standard +- User did not ask to persist the pattern + +## After updating + +- Keep rules concise; split if a file grows past ~50 lines. +- Tell the user which file(s) changed in one sentence. + +Use skill `.cursor/skills/capture-convention/` for the full workflow. diff --git a/.cursor/skills/add-feature/SKILL.md b/.cursor/skills/add-feature/SKILL.md new file mode 100644 index 0000000..bc97fbe --- /dev/null +++ b/.cursor/skills/add-feature/SKILL.md @@ -0,0 +1,69 @@ +--- +name: dyolink-add-feature +description: Adds a new Dyolink feature end-to-end (permission, backend module, frontend tab, i18n). Use when the user asks for a new tab, module, screen, or CRUD feature in Dyolink. +--- + +# Add a Dyolink feature + +Follow this checklist. Adapt steps if the feature is read-only or org-type-specific. + +## Checklist + +``` +- [ ] 1. Permissions & org type +- [ ] 2. Backend module +- [ ] 3. Frontend UI + thin page +- [ ] 4. i18n (en, fa, nl) +- [ ] 5. Verify build / tsc +``` + +## 1. Permissions & org type + +- Add `TAB_{FEATURE}_READ` and `TAB_{FEATURE}_EDIT` to: + - `backend/src/common/permissions.ts` (`ALL_TAB_PERMISSIONS`, `EDIT_TO_READ`) + - `backend/prisma/seed.ts` (owner defaults per org type) + - `backend/src/modules/auth/auth.service.ts` if listed there +- Frontend: `components/staff/staff-permission-form.ts`, `components/shared/permissions.ts` route prefix if needed. +- Sidebar: `components/ui/shared/Sidebar.tsx` with `orgTypes` filter. + +## 2. Backend module + +``` +backend/src/modules/{feature}/ + {feature}.module.ts + {feature}.controller.ts + {feature}.service.ts + dto/ +``` + +- Apply guards (`JwtAuthGuard`, org-type guard as needed). +- Service-level permission checks with `hasEffectivePermission`. +- DTOs use `ErrorCode` validation messages. +- Register in `app.module.ts`. + +## 3. Frontend + +- API client: `frontend/src/lib/api/{feature}.ts` +- Types: `frontend/src/types/{feature}.ts` +- UI: `frontend/src/components/ui/{feature}/` +- Non-UI helpers: `frontend/src/components/{feature}/` +- Page: thin `app/[locale]/(dashboard)/{feature}/page.tsx` → `{Feature}Page.tsx` + +## 4. i18n + +Add keys to `en.json`, `fa.json`, `nl.json` under a feature namespace (e.g. `"patients": { ... }`). + +## 5. Verify + +```bash +cd backend && npm run build +cd frontend && npx tsc --noEmit +``` + +## Reference implementations + +| Pattern | Look at | +|---------|---------| +| Thin page + workspace | `treatment/page.tsx`, `TreatmentWorkspace.tsx` | +| CRUD + permissions | `modules/patients/` | +| Lab feature | `modules/cases/`, `ui/lab/` | diff --git a/.cursor/skills/api-errors/SKILL.md b/.cursor/skills/api-errors/SKILL.md new file mode 100644 index 0000000..2e95e6d --- /dev/null +++ b/.cursor/skills/api-errors/SKILL.md @@ -0,0 +1,40 @@ +--- +name: dyolink-api-errors +description: Adds or migrates Dyolink API error codes with frontend translations. Use when adding backend validation errors, permission errors, or migrating catch blocks to getUserFacingError. +--- + +# Dyolink API errors + +## Backend + +1. Add to `ErrorCode` in `backend/src/common/errors/error-codes.ts`. +2. Throw with `AppException`: + +```typescript +throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST, [ + { field: 'email', code: ErrorCode.VALIDATION_EMAIL_INVALID }, +]); +``` + +3. DTOs: `@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })` + +## Frontend + +1. Add key under `"errors"` in `en.json`, `fa.json`, `nl.json` (key = error code string). +2. In components: + +```typescript +const tErrors = useTranslations('errors'); +// ... +catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('fallbackKey'))); +} +``` + +3. Do not use `err.message` or `(err as Error).message` for user display. + +## Axios shape + +Parsed in `lib/api/client` — expects `{ success: false, error: { code, details? } }`. + +See rule: `.cursor/rules/api-errors-i18n.mdc` diff --git a/.cursor/skills/capture-convention/SKILL.md b/.cursor/skills/capture-convention/SKILL.md new file mode 100644 index 0000000..bdddf2d --- /dev/null +++ b/.cursor/skills/capture-convention/SKILL.md @@ -0,0 +1,53 @@ +--- +name: dyolink-capture-convention +description: Saves a new Dyolink project convention into AGENTS.md, .cursor/rules, or .cursor/skills. Use when the user says remember this, save as convention, add to project rules, document for future agents, or asks to update agent docs after a task. +--- + +# Capture convention + +Persist a **durable** project pattern so the next agent chat knows it without re-explaining. + +## Trigger phrases + +- "Remember this" +- "Save as convention" / "Add to project rules" +- "Document this for future agents" +- "Update the cursor rules/skills" + +## Workflow + +1. **Confirm it is durable** — not a one-off fix. If unclear, ask: "Should every future agent follow this?" +2. **Choose target:** + - Short rule (always or file-scoped) → `.cursor/rules/{topic}.mdc` + - Step-by-step process → `.cursor/skills/{name}/SKILL.md` (new folder if needed) + - High-level pointer only → one line in `AGENTS.md` linking to the rule/skill +3. **Write concisely** — bullets, one example, under 50 lines per rule file. +4. **Avoid duplication** — merge into an existing rule if the topic fits. +5. **Commit with the feature** — remind user these files belong in git with the code change. + +## Rule file template + +```markdown +--- +description: One-line summary +globs: frontend/src/** # omit if alwaysApply: true +alwaysApply: false +--- + +# Title + +- Bullet convention +- ✅ Do / ❌ Don't example +``` + +## What not to capture + +- Temporary deadlines or "for v1 only" unless labeled as such +- Secrets, env values, credentials +- Entire chat transcripts — distill to 3–7 bullets + +## Example + +User: "Remember: all lab task status badges use labTaskStatusDisplay helpers." + +Action: Add bullet to `frontend-components.mdc` or `backend-nestjs.mdc` (whichever fits), not a new 200-line doc. diff --git a/.cursor/skills/frontend-structure/SKILL.md b/.cursor/skills/frontend-structure/SKILL.md new file mode 100644 index 0000000..7d4f4f8 --- /dev/null +++ b/.cursor/skills/frontend-structure/SKILL.md @@ -0,0 +1,39 @@ +--- +name: dyolink-frontend-structure +description: Audits or refactors Dyolink frontend folder layout (components vs components/ui, thin pages). Use when moving components, fixing structure violations, or when the user mentions folder rules, page.tsx bloat, or component organization. +--- + +# Frontend structure audit + +## Target layout + +``` +components/ui/shared/ → reusable UI (Button, Dialog, …) +components/ui/{feature}/ → feature UI + {Feature}Page.tsx +components/shared/ → cross-feature non-UI +components/{feature}/ → feature non-UI (helpers, config) +app/**/page.tsx → thin wrapper importing ui/{feature} page component +``` + +## Audit steps + +1. List files in `components/` **outside** `ui/` — any `.tsx` with JSX → move to `components/ui/{feature}/`. +2. List files in `components/ui/` — any pure `.ts` helper → move to `components/{feature}/` or `components/shared/`. +3. List `app/**/page.tsx` — if > ~30 lines of logic/state, extract to `components/ui/{feature}/{Feature}Page.tsx`. +4. Update all `@/components/...` imports. +5. Run `npx tsc --noEmit` in `frontend/`. + +## Common mistakes + +| Wrong | Right | +|-------|-------| +| `components/today/TodayDashboard.tsx` | `components/ui/today/TodayDashboard.tsx` | +| `components/ui/treatment/treatmentTypeDisplay.ts` | `components/shared/treatmentTypeDisplay.ts` | +| Logic in `app/.../staff/page.tsx` | `components/ui/staff/StaffPage.tsx` | + +## Non-UI that stays outside ui/ + +- `components/today/widget-registry.ts`, `chart-theme.ts` (config) +- `components/staff/workingHours.ts` +- `components/appointments/appointmentTime.ts` +- `components/i18n/LocaleSync.tsx` (null-render side effect for layout) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a229352 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,89 @@ +# Dyolink — Agent guide + +This file orients Cursor agents at the start of a **new chat**. Project conventions live in **`.cursor/rules/`** (auto-loaded). Workflow playbooks live in **`.cursor/skills/`**. + +## What Dyolink is + +Dental clinic ↔ lab platform (monorepo): + +| Path | Stack | +|------|--------| +| `backend/` | NestJS, Prisma, PostgreSQL | +| `frontend/` | Next.js 16, React 19, next-intl, Tailwind | +| `infrastructure/` | Docker, nginx, deploy scripts | + +**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`. + +## Before you code + +1. **Read applicable rules** in `.cursor/rules/` (especially `dyolink-overview` and the file-scoped rule for the area you touch). +2. **Match existing patterns** in the nearest feature folder — do not invent parallel structures. +3. **Keep diffs small** — one concern per change unless the user asks for a refactor. +4. **Verify:** `npm run build` (backend) and `npx tsc --noEmit` (frontend) when you change types or cross-cutting code. + +## Frontend layout (critical) + +``` +frontend/src/ + app/ → thin page.tsx only; compose from ui/ + components/ + ui/shared/ → cross-feature UI (Button, Sidebar, …) + ui/{feature}/ → feature UI (+ {Feature}Page.tsx for route logic) + shared/ → cross-feature non-UI (formatApiError, permissions, …) + {feature}/ → feature non-UI (helpers, config, pure functions) + lib/ → api clients, hooks + types/ → shared TS types + messages/{en,fa,nl}.json → all user-facing strings +``` + +**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. + +## Backend layout + +``` +backend/src/ + modules/{feature}/ → controller, service, dto, module + common/ → guards, permissions, errors, utils + prisma/ → schema, migrations, seed +``` + +Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures. + +## Git & commits + +- **Do not commit or push** unless the user explicitly asks. +- **Do not** amend commits, force-push, or skip hooks unless explicitly requested. + +## Skills (workflows) + +| Skill | When to use | +|-------|-------------| +| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | +| `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | +| `.cursor/skills/api-errors/` | New backend errors + frontend translations | + +## Subagents (Task tool) + +Use subagents to **save context**, not to avoid work: + +| Type | Use for | +|------|---------| +| `explore` | Broad codebase search, unfamiliar areas | +| `shell` | Git, npm, long command sequences | +| `generalPurpose` | Multi-step research when parent context is large | + +Do **not** delegate the user's main task to a subagent and return its summary — implement in the parent unless the user asked for exploration only. + +## Improving this setup + +When you and the user agree on a new convention, **add or update a rule** in `.cursor/rules/` (keep each rule under ~50 lines, one topic). For multi-step workflows, extend `.cursor/skills/`. + +**To save a convention mid-task**, say: *"Remember this"* or *"Add to project rules"* — the agent uses the `capture-convention` skill and updates the repo (commit with your code). + +| You say | Agent does | +|---------|------------| +| "Remember this: …" | Updates the right `.mdc` rule or skill | +| "Add a skill for …" | Creates `.cursor/skills/{name}/SKILL.md` | +| "This rule is wrong" | Edits the rule file; you commit | + +Rules/skills **load automatically** in new chats; they do **not** update themselves unless you ask. diff --git a/README.md b/README.md index 3ce8ea7..a412280 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Monorepo: **NestJS** backend (`backend/`), **Next.js** frontend (`frontend/`), * Local development: see **`backend/README.md`** and **`frontend/README.md`**. +**Cursor AI:** project conventions for agents are in [`AGENTS.md`](AGENTS.md), [`.cursor/rules/`](.cursor/rules/), and [`.cursor/skills/`](.cursor/skills/). + --- ## Production deploy (Docker Hub + HTTPS + Let's Encrypt) diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index 517c6b3..7822f52 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -1,373 +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 { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { CreatePatientInput, Patient } from '@/types/patient'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; +import { AppointmentsPage } from '@/components/ui/appointments/AppointmentsPage'; -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export default function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const tPatients = useTranslations('patients'); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - 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 [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data); - setAppointments(aRes.data); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data)) - .catch(() => {}); - }, []); - - useEffect(() => { - const t = setTimeout(() => { - void loadPatientsSearch(search); - }, 300); - return () => clearTimeout(t); - }, [search]); - - async function loadPatientsSearch(q: string) { - if (!currentOrganization) { - return; - } - setLoadingPatients(true); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - if (selectedPatient) { - const stillThere = items.find((p) => p.id === selectedPatient.id); - if (stillThere) { - setSelectedPatient(stillThere); - } - } - } catch { - setPatients([]); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatientsSearch(search); - setSelectedPatient(response.data); - if (response.existing) { - toast.showInfo( - tPatients('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - if (!selectedPatient) { - toast.showInfo(t('infoSelectPatient')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - 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; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -

-
-

{t('title')}

-

{t('subtitle')}

-
- - - -
-
- { - if (!canEditPatients) { - return; - } - setPatientForm(EMPTY_PATIENT_FORM); - setIsCreateOpen(true); - }} - /> - -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> - - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/billing/page.tsx b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx index e2420c4..bed1384 100644 --- a/frontend/src/app/[locale]/(dashboard)/billing/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx @@ -1,296 +1,7 @@ -// src/app/(dashboard)/billing/page.tsx 'use client'; -import { useMemo, useState } from 'react'; -import { Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Card } from '@/components/ui/shared/Card'; -import { Table } from '@/components/ui/shared/Table'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { hasPermission } from '@/components/shared/permissions'; +import { BillingPage } from '@/components/ui/billing/BillingPage'; -type InvoiceStatus = 'paid' | 'unpaid' | 'overdue'; - -type Invoice = { - id: string; - patient: string; - date: string; - service: string; - amount: number; - paid: number; - status: InvoiceStatus; -}; - -const invoices: Invoice[] = [ - { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, - { id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' }, - { id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' }, -]; - -const statusColors = { - paid: 'success', - unpaid: 'warning', - overdue: 'danger', -} as const; - -const statusFilters = ['all', 'paid', 'unpaid', 'overdue'] as const; - -type StatCardColor = 'blue' | 'yellow' | 'green' | 'red'; - -interface StatCardProps { - title: string; - count: number; - amount: number; - color: StatCardColor; -} - -export default function BillingPage() { - const { currentOrganization } = useAuth(); - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all'); - const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT'); - - const stats = { - total: { count: 235, amount: 80900 }, - unpaid: { count: 30, amount: 2800 }, - paid: { count: 190, amount: 80900 }, - overdue: { count: 235, amount: 80900 }, - }; - - const filteredInvoices = useMemo(() => { - const query = search.trim().toLowerCase(); - - return invoices.filter((invoice) => { - const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter; - const matchesSearch = - !query || - invoice.patient.toLowerCase().includes(query) || - invoice.id.toLowerCase().includes(query) || - invoice.service.toLowerCase().includes(query); - - return matchesStatus && matchesSearch; - }); - }, [search, statusFilter]); - - return ( -
-
-

Billing

- -
- -
- - - - -
- - - {statusFilters.map((status) => ( - - ))} - - )} - /> - -
- {filteredInvoices.length === 0 ? ( -
No invoices match your filters.
- ) : ( - filteredInvoices.map((invoice) => ( - - )) - )} - -
- -
- - - - - - - - - - - } - body={ - <> - {filteredInvoices.map((invoice) => ( - - - - - - - - - - - ))} - - } - footer={} - /> - - - ); -} - -function InvoiceMobileCard({ - invoice, - canEditBilling, -}: { - invoice: Invoice; - canEditBilling: boolean; -}) { - const remaining = invoice.amount - invoice.paid; - - return ( - -
-
-

{invoice.patient}

-

{invoice.id}

-
- - {invoice.status} - -
- -
- {invoice.service} - · - {invoice.date} -
- -
-
-

Total

-

${invoice.amount}

-
-
-

Paid

-

${invoice.paid}

-
-
-

Due

-

${remaining}

-
-
- -
- -
-
- ); -} - -function InvoiceEditButton({ canEditBilling }: { canEditBilling: boolean }) { - return ( - - ); -} - -function InvoicePagination({ className = '' }: { className?: string }) { - return ( -
- -
Page 1 of 10
- -
- ); -} - -function StatCard({ title, count, amount, color }: StatCardProps) { - const colors: Record = { - blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border', - yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border', - green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border', - red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border', - }; - - return ( - -

{title}

-

{count}

-

- ${amount.toLocaleString()} -

-
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 0eeadd9..bbb47cd 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -1,472 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { canEditCases, canEditTasks } from '@/components/shared/permissions'; -import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - formatCaseDateTime, - formatPatientName, -} from '@/components/ui/lab/caseDetailUtils'; -import { casesApi } from '@/lib/api/cases'; -import { tasksApi } from '@/lib/api/tasks'; -import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; -import { Button } from '@/components/ui/shared/Button'; -import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import type { - CasesFilterOptions, - LabCaseDetail, - LabCaseListItem, - LabTaskStatus, - PaginatedLabCases, -} from '@/types/cases'; +import { CasesPage } from '@/components/ui/lab/CasesPage'; -const PAGE_SIZE = 20; - -export default function CasesPage() { - const t = useTranslations('cases'); - const tErrors = useTranslations('errors'); - const tCommon = useTranslations('common'); - const { currentOrganization, user } = useAuth(); - const toast = useToast(); - const searchParams = useSearchParams(); - - const [search, setSearch] = useState(''); - const [clinicId, setClinicId] = useState(''); - const [treatmentType, setTreatmentType] = useState(''); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); - const [page, setPage] = useState(1); - - const [cases, setCases] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: PAGE_SIZE, - total: 0, - totalPages: 1, - }); - const [filterOptions, setFilterOptions] = useState({ - clinics: [], - treatmentTypes: [], - }); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - - const [selectedCaseId, setSelectedCaseId] = useState(null); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - const [selectedCase, setSelectedCase] = useState(null); - const [loadingList, setLoadingList] = useState(false); - const [loadingDetail, setLoadingDetail] = useState(false); - const [updatingImportant, setUpdatingImportant] = useState(false); - const [commentCount, setCommentCount] = useState(0); - - const canEdit = canEditCases(currentOrganization); - const canEditComments = canEditTasks(currentOrganization); - const locale = user?.language ?? 'en'; - - const treatmentLabel = useCallback( - (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), - [treatmentCatalog], - ); - - const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( - () => [ - { value: 'IN_PROGRESS', label: t('statusInProgress') }, - { value: 'COMPLETED', label: t('statusCompleted') }, - ], - [t], - ); - - const hasActiveFilters = Boolean( - search.trim() || clinicId || treatmentType || sentFrom || sentTo, - ); - - const loadCases = async (params: { - q: string; - clinicOrganizationId: string; - treatmentType: string; - sentFrom: string; - sentTo: string; - page: number; - }) => { - setLoadingList(true); - toast.setError(''); - try { - const response = await casesApi.list({ - q: params.q.trim() || undefined, - clinicOrganizationId: params.clinicOrganizationId || undefined, - treatmentType: params.treatmentType || undefined, - sentFrom: params.sentFrom || undefined, - sentTo: params.sentTo || undefined, - page: params.page, - limit: PAGE_SIZE, - }); - setCases(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); - } finally { - setLoadingList(false); - } - }; - - const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { - if (!options?.silent) { - setLoadingDetail(true); - } - toast.setError(''); - try { - const response = await casesApi.getOne(caseId); - setSelectedCase(response.data); - } catch (error: unknown) { - toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); - if (!options?.silent) { - setSelectedCase(null); - } - } finally { - if (!options?.silent) { - setLoadingDetail(false); - } - } - }; - - useEffect(() => { - void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); - void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch - }, []); - - useEffect(() => { - const caseIdFromUrl = searchParams.get('caseId'); - if (caseIdFromUrl) { - setSelectedCaseId(caseIdFromUrl); - setMobileDetailOpen(true); - } - }, [searchParams]); - - useEffect(() => { - if (!selectedCaseId) { - setMobileDetailOpen(false); - } - }, [selectedCaseId]); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadCases({ - q: search, - clinicOrganizationId: clinicId, - treatmentType, - sentFrom, - sentTo, - page, - }); - }, search ? 300 : 0); - return () => clearTimeout(timeout); - // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload - }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); - - useEffect(() => { - if (selectedCaseId) { - void loadDetail(selectedCaseId); - void tasksApi - .listComments(selectedCaseId) - .then((r) => setCommentCount(r.data.length)) - .catch(() => setCommentCount(0)); - } else { - setSelectedCase(null); - setCommentCount(0); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes - }, [selectedCaseId]); - - function scrollToComments() { - document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); - } - - const loadCaseAttachmentBlob = useCallback( - (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), - [], - ); - - function clearFilters() { - setSearch(''); - setClinicId(''); - setTreatmentType(''); - setSentFrom(''); - setSentTo(''); - setPage(1); - } - - async function handleCaseImportantToggle(isImportant: boolean) { - if (!selectedCaseId || !canEdit || !selectedCase) return; - - const previousCase = selectedCase; - setSelectedCase({ ...selectedCase, isImportant }); - - setUpdatingImportant(true); - toast.setError(''); - try { - const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); - setSelectedCase(response.data); - } catch (error: unknown) { - setSelectedCase(previousCase); - toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); - } finally { - setUpdatingImportant(false); - } - } - - const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
- { - setSearch(value); - setPage(1); - }} - placeholder={t('searchPlaceholder')} - /> - -
- - - - - - - -
- - {hasActiveFilters ? ( - - ) : null} - -
- {loadingList ? ( -

{tCommon('loading')}

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

{t('emptyList')}

- ) : ( -
    - {cases.map((item) => { - const isActive = item.id === selectedCaseId; - - return ( -
  • - -
  • - ); - })} -
- )} -
- - {pagination.totalPages > 1 ? ( -
- - - {t('pageSummary', { - page: pagination.page, - totalPages: pagination.totalPages, - total: pagination.total, - })} - - -
- ) : null} -
- -
- {mobileDetailOpen && selectedCaseId ? ( - setMobileDetailOpen(false)} /> - ) : null} - {!selectedCaseId ? ( -

{t('selectCaseHint')}

- ) : loadingDetail || !selectedCase ? ( -

{tCommon('loading')}

- ) : ( - void handleCaseImportantToggle(checked)} - headerMetaLines={ -

- {t('fromClinic', { name: selectedCase.clinic.name })} -

- } - commentsSection={ - selectedCaseId ? ( -
- { - const r = await tasksApi.listComments(selectedCaseId); - setCommentCount(r.data.length); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(selectedCaseId, { - body, - visibleToClinic, - }); - setCommentCount((n) => n + 1); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={toast.showError} - /> -
- ) : null - } - /> - )} -
-
- - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index b61de88..f4c5a8b 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -1,602 +1,7 @@ -'use client'; +'use client'; -import { useCallback, useEffect, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useToast } from '@/lib/hooks/useToast'; -import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; -import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; -import { - organizationApi, - type CounterpartItemDto, - type CounterpartSearchResultDto, - type OrganizationInvitationHistoryItemDto, -} from '@/lib/api/organization'; -import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; -import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; -import { OrganizationConnectionsMobileList } from '@/components/ui/organizations/OrganizationConnectionsMobileList'; -import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; -import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; -import { Input } from '@/components/ui/shared/Input'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { getUserFacingError } from '@/components/shared/formatApiError'; +import { OrganizationsPage } from '@/components/ui/organizations/OrganizationsPage'; -function formatOrganizationStatusLabel(status: string): string { - if (!status) return status; - const lower = status.toLowerCase(); - return lower.charAt(0).toUpperCase() + lower.slice(1); -} - -function formatTableDate(value: string): string { - const d = new Date(value); - if (Number.isNaN(d.getTime())) return '\u2014'; - return d.toLocaleDateString(); -} - -type TableMode = 'existing' | 'search'; - -export default function OrganizationsPage() { - const t = useTranslations('organizations'); - const tErrors = useTranslations('errors'); - const tNav = useTranslations('nav'); - const tCommon = useTranslations('common'); - const { currentOrganization } = useAuth(); - const [loading, setLoading] = useState(true); - const toast = useToast(); - const { showError, setError: setToastError } = toast; - - const formatApiMessage = useCallback( - (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), - [tCommon, tErrors], - ); - - 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); - const [searchResults, setSearchResults] = useState([]); - const [pendingConnectionRowId, setPendingConnectionRowId] = useState(null); - const [deleteConnectionRowId, setDeleteConnectionRowId] = useState(null); - - const [items, setItems] = useState([]); - const [manualOrganizationName, setManualOrganizationName] = useState(''); - const [manualOwnerEmail, setManualOwnerEmail] = useState(''); - const [inviteLoading, setInviteLoading] = useState(false); - const [showInviteForm, setShowInviteForm] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); - const [historyLoading, setHistoryLoading] = useState(false); - const [historyItems, setHistoryItems] = useState([]); - const [caseHistoryConnection, setCaseHistoryConnection] = useState( - null, - ); - - const { - copiedId, - copyingInvitationId, - storeInviteLink, - copyInvitationLink, - pruneAcceptedLinks, - } = useOrganizationInviteLinkCopy(currentOrganization?.id); - - const counterpart = - currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab'); - const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs'); - - const existingRows = items; - - async function loadList() { - setLoading(true); - toast.setError(''); - try { - const res = await organizationApi.list(); - setItems(res.data.items); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setLoading(false); - } - } - - useEffect(() => { - void loadList(); - }, []); - - useEffect(() => { - let cancelled = false; - const q = query.trim(); - - if (!q) { - setMode('existing'); - setSearchResults([]); - setShowInviteForm(false); - setSearching(false); - return; - } - - setMode('search'); - setShowInviteForm(false); - setSearching(true); - - const timeout = setTimeout(() => { - void (async () => { - setToastError(''); - try { - const res = await organizationApi.search(q); - if (cancelled) return; - setSearchResults(res.data); - } catch (e) { - if (cancelled) return; - showError(formatApiMessage(e)); - setSearchResults([]); - } finally { - if (!cancelled) setSearching(false); - } - })(); - }, 300); - - return () => { - cancelled = true; - clearTimeout(timeout); - }; - }, [query, formatApiMessage, showError, setToastError]); - - async function submitConnectionRequest(targetOrganizationId: string) { - setPendingConnectionRowId(targetOrganizationId); - toast.setError(''); - try { - await organizationApi.createConnectionRequest(targetOrganizationId); - toast.showSuccess(t('successConnectionSent', { counterpart })); - setSearchResults([]); - setQuery(''); - setMode('existing'); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setPendingConnectionRowId(null); - } - } - - async function sendInvite() { - setInviteLoading(true); - toast.setError(''); - try { - const res = await organizationApi.invite({ - organizationName: manualOrganizationName.trim(), - ownerEmail: manualOwnerEmail.trim(), - }); - storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); - toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() })); - setManualOrganizationName(''); - setManualOwnerEmail(''); - setShowInviteForm(false); - setMode('existing'); - setQuery(''); - setSearchResults([]); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setInviteLoading(false); - } - } - - async function loadInvitationHistory() { - const res = await organizationApi.listInvitations(); - setHistoryItems(res.data.items); - pruneAcceptedLinks(res.data.items); - return res.data.items; - } - - async function openInvitationHistory() { - setHistoryOpen(true); - setHistoryLoading(true); - toast.clear(); - try { - await loadInvitationHistory(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setHistoryLoading(false); - } - } - - async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { - toast.setError(''); - try { - await copyInvitationLink(invitation, { - onRegenerated: async () => { - await loadInvitationHistory(); - }, - }); - toast.showSuccess(t('successLinkCopied')); - } catch (e) { - toast.showError(formatApiMessage(e)); - } - } - - async function handleCopyInvitationFromRow(row: CounterpartItemDto) { - const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); - if (!target) return; - toast.setError(''); - try { - await copyInvitationLink( - { - id: target.id, - organizationName: row.organizationName, - ownerEmail: target.ownerEmail, - status: target.status, - createdAt: row.createdAt, - acceptedAt: target.acceptedAt, - }, - { - onRegenerated: async () => { - await loadList(); - }, - }, - ); - toast.showSuccess(t('successLinkCopied')); - } catch (e) { - toast.showError(formatApiMessage(e)); - } - } - - async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { - setPendingConnectionRowId(connectionId); - toast.setError(''); - try { - await organizationApi.respondToConnectionRequest(connectionId, action); - toast.showSuccess( - action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), - ); - notifyPendingConnectionsChanged(); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setPendingConnectionRowId(null); - } - } - - async function deleteConnection(connectionId: string) { - setDeleteConnectionRowId(connectionId); - toast.setError(''); - try { - await organizationApi.deleteConnection(connectionId); - toast.showSuccess(t('successRemoved')); - await loadList(); - } catch (e) { - toast.showError(formatApiMessage(e)); - } finally { - setDeleteConnectionRowId(null); - } - } - - function clearSearchView() { - setMode('existing'); - setQuery(''); - setSearchResults([]); - setShowInviteForm(false); - } - - if (!currentOrganization) { - return

{t('loadingOrganization')}

; - } - - if (caseHistoryConnection) { - return ( - setCaseHistoryConnection(null)} - /> - ); - } - - return ( -
-
-
-

{tabLabel}

-

{t('subtitle')}

-
- -
- - {!historyOpen && } - - - {t('backToList')} - - ) : undefined - } - /> - - invitationTargetFromConnectionRow(row, currentOrganization.id)} - onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)} - onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)} - onViewCaseHistory={setCaseHistoryConnection} - onDeleteConnection={(rowId) => void deleteConnection(rowId)} - onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)} - onToggleInviteForm={() => setShowInviteForm((v) => !v)} - onManualOrganizationNameChange={setManualOrganizationName} - onManualOwnerEmailChange={setManualOwnerEmail} - onSendInvite={() => void sendInvite()} - labels={{ - loading: tCommon('loadingEllipsis'), - emptyConnections: t('emptyConnections'), - noDirectoryResults: t('noDirectoryResults'), - hideInvitationFields: t('hideInvitationFields'), - sendInvitationLink: t('sendInvitationLink'), - counterpartNameLabel: t('counterpartNameLabel', { counterpart }), - ownerEmailLabel: t('ownerEmailLabel'), - sendInvitation: t('sendInvitation'), - sendRequest: t('sendRequest'), - acceptRequest: t('acceptRequest'), - declineRequest: t('declineRequest'), - viewCaseHistory: t('viewCaseHistory'), - removeConnection: t('removeConnection'), - statusToday: t('statusToday'), - statusFound: t('statusFound'), - }} - /> - -
-
- Invoice ID - - Patient name - - Date - - Service - - Total amount - - Paid - - Status - - Action -
{invoice.id}{invoice.patient}{invoice.date}{invoice.service}${invoice.amount}${invoice.paid} - - {invoice.status} - - - -
- - - - - - - } - body={ - <> - {loading || (mode === 'search' && searching) ? ( - - - - ) : mode === 'existing' ? ( - existingRows.length === 0 ? ( - - - - ) : ( - existingRows.map((row) => { - const canRespond = - row.status === 'PENDING' && - row.requestedByOrganizationId !== null && - row.requestedByOrganizationId !== currentOrganization.id; - const invitationTarget = invitationTargetFromConnectionRow( - row, - currentOrganization.id, - ); - - return ( - - - - - - - - ); - }) - ) - ) : searchResults.length > 0 ? ( - searchResults.map((r) => ( - - - - - - - - )) - ) : ( - - - - )} - - } - /> - - - setHistoryOpen(false)} - loading={historyLoading} - items={historyItems} - copiedId={copiedId} - copyingInvitationId={copyingInvitationId} - onCopy={(invitation) => void handleHistoryCopy(invitation)} - toastMessages={toast.messages} - /> - - ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 9266431..1a49a82 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,165 +1,7 @@ '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 { getUserFacingError } 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'; -import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; +import { PatientsPage } from '@/components/ui/patient/PatientsPage'; -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export default function PatientsPage() { - const t = useTranslations('patients'); - const tErrors = useTranslations('errors'); - 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(getUserFacingError(error, tErrors, 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); - if (response.existing) { - toast.showInfo( - t('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (error: unknown) { - toast.showError(getUserFacingError(error, tErrors, 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} - /> - )} - -
-
- -
- -
- - {selectedPatient ? ( - - ) : null} -
-
-
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 9585e41..658b73c 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,452 +1,7 @@ 'use client'; -import { useCallback, useEffect, 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, useRouter } from '@/i18n/navigation'; -import { useSearchParams } from 'next/navigation'; -import { ChevronDown, Lock } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { authApi } from '@/lib/api/auth'; -import { accountApi } from '@/lib/api/account'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; -import { Toast } from '@/components/ui/shared/Toast'; -import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog'; +import { AccountSettingsPage } from '@/components/ui/settings/AccountSettingsPage'; -type PasswordForm = { - currentPassword: string; - newPassword: string; - confirmPassword: string; -}; - -export default function AccountSettingsPage() { - const t = useTranslations('settings'); - const tErrors = useTranslations('errors'); - const tAuth = useTranslations('auth'); - const tCommon = useTranslations('common'); - const tValidation = useTranslations('validation'); - const { user, currentOrganization, isAuthReady, refreshSession } = useAuth(); - const router = useRouter(); - const searchParams = useSearchParams(); - const isResetFlow = searchParams.get('reset') === '1'; - - const isOwner = Boolean(currentOrganization?.isOwner); - const orgType = currentOrganization?.type; - const showClinicParticipation = isOwner && orgType === 'CLINIC'; - const showLabParticipation = isOwner && orgType === 'LAB'; - - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow); - - const [participationLoading, setParticipationLoading] = useState(false); - const [participatesInTreatments, setParticipatesInTreatments] = useState(false); - const [participatesInTasks, setParticipatesInTasks] = useState(false); - const [workingHoursOpen, setWorkingHoursOpen] = useState(false); - const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false); - const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null); - - const passwordSchema = useMemo( - () => - z - .object({ - currentPassword: z.string(), - newPassword: z - .string() - .min(8, tValidation('passwordMinLength')) - .regex(/[A-Z]/, tValidation('passwordUppercase')) - .regex(/[0-9]/, tValidation('passwordNumber')), - confirmPassword: z.string(), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: tValidation('passwordsDoNotMatch'), - path: ['confirmPassword'], - }) - .superRefine((data, ctx) => { - if (!isResetFlow && !data.currentPassword.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: tValidation('passwordRequired'), - path: ['currentPassword'], - }); - } - }), - [isResetFlow, tValidation], - ); - - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(passwordSchema), - defaultValues: { - currentPassword: '', - newPassword: '', - confirmPassword: '', - }, - }); - - const loadParticipation = useCallback(async () => { - if (!isOwner) return; - try { - const res = await accountApi.getParticipation(); - setParticipatesInTreatments(res.data.participatesInTreatments); - setParticipatesInTasks(res.data.participatesInTasks); - } catch { - /* non-owners or missing org context */ - } - }, [isOwner]); - - useEffect(() => { - if (isAuthReady && !user) { - router.replace('/login'); - } - }, [isAuthReady, user, router]); - - useEffect(() => { - if (isResetFlow) { - setPasswordExpanded(true); - } - }, [isResetFlow]); - - useEffect(() => { - void loadParticipation(); - }, [loadParticipation, currentOrganization?.id]); - - const syncSessionAfterParticipationChange = useCallback(async () => { - await refreshSession(); - }, [refreshSession]); - - const enableClinicParticipation = useCallback( - async (options: { - skipHours: boolean; - hoursPayload?: { - autoRepeatWeekly: boolean; - blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; - }; - }) => { - await accountApi.updateParticipation(true); - if (!options.skipHours && options.hoursPayload) { - await accountApi.upsertMyWorkingHours(options.hoursPayload); - } - setParticipatesInTreatments(true); - await syncSessionAfterParticipationChange(); - setSuccessMessage(t('participateEnabledTreatments')); - }, - [syncSessionAfterParticipationChange, t], - ); - - const enableLabParticipation = async () => { - setParticipationLoading(true); - setError(null); - try { - await accountApi.updateParticipation(true); - setParticipatesInTasks(true); - await syncSessionAfterParticipationChange(); - setSuccessMessage(t('participateEnabledTasks')); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - setParticipatesInTasks(false); - } finally { - setParticipationLoading(false); - } - }; - - const confirmRevokeParticipation = async () => { - if (!pendingRevokeType) return; - setParticipationLoading(true); - setError(null); - try { - await accountApi.updateParticipation(false); - if (pendingRevokeType === 'CLINIC') { - setParticipatesInTreatments(false); - } else { - setParticipatesInTasks(false); - } - await syncSessionAfterParticipationChange(); - setSuccessMessage( - pendingRevokeType === 'CLINIC' - ? t('participateDisabledTreatments') - : t('participateDisabledTasks'), - ); - setRevokeConfirmOpen(false); - setPendingRevokeType(null); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - } finally { - setParticipationLoading(false); - } - }; - - const handleClinicParticipationChange = (checked: boolean) => { - setError(null); - if (checked) { - setWorkingHoursOpen(true); - return; - } - setPendingRevokeType('CLINIC'); - setRevokeConfirmOpen(true); - }; - - const handleWorkingHoursClose = useCallback(() => { - setWorkingHoursOpen(false); - }, []); - - const handleWorkingHoursComplete = useCallback( - async (options: { - skipHours: boolean; - hoursPayload?: { - autoRepeatWeekly: boolean; - blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; - }; - }) => { - setParticipationLoading(true); - setError(null); - try { - await enableClinicParticipation(options); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); - throw err; - } finally { - setParticipationLoading(false); - } - }, - [enableClinicParticipation, t], - ); - - const handleLabParticipationChange = (checked: boolean) => { - setError(null); - if (checked) { - void enableLabParticipation(); - return; - } - setPendingRevokeType('LAB'); - setRevokeConfirmOpen(true); - }; - - const onSubmit = async (data: PasswordForm) => { - try { - setError(null); - setSuccessMessage(null); - setIsSubmitting(true); - - await authApi.changePassword({ - ...(isResetFlow ? {} : { currentPassword: data.currentPassword }), - newPassword: data.newPassword, - }); - - reset(); - setSuccessMessage(t('passwordChanged')); - router.replace('/login'); - } catch (err: unknown) { - setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); - } finally { - setIsSubmitting(false); - } - }; - - const passwordToggleLabels = { - show: tAuth('showPassword'), - hide: tAuth('hidePassword'), - }; - - if (!isAuthReady || !user) { - return ( -

{tCommon('loadingEllipsis')}

- ); - } - - return ( -
-
- - {tCommon('backToApp')} - -

{t('accountTitle')}

-

{t('accountSubtitle')}

-
- - {(showClinicParticipation || showLabParticipation) && ( -
-
-

{t('participationSectionTitle')}

-

{t('participationSectionSubtitle')}

-
- - {showClinicParticipation && ( - - )} - - {showLabParticipation && ( - - )} -
- )} - -
- - - {passwordExpanded && ( -
-

- {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} -

- {isResetFlow && ( -

{t('resetPasswordSubtitle')}

- )} - -
- {!isResetFlow && ( - } - passwordToggleLabels={passwordToggleLabels} - /> - )} - - } - passwordToggleLabels={passwordToggleLabels} - /> - - } - passwordToggleLabels={passwordToggleLabels} - /> - - {error && ( -
-

{error}

-
- )} - - - -
- )} -
- - {error && !passwordExpanded && ( -
-

{error}

-
- )} - - - - {revokeConfirmOpen && ( -
-
-
-

- {t('participateConfirmRevokeTitle')} -

- { - if (participationLoading) return; - setRevokeConfirmOpen(false); - setPendingRevokeType(null); - }} - /> -
-

- {pendingRevokeType === 'CLINIC' - ? t('participateConfirmRevokeBodyTreatments') - : t('participateConfirmRevokeBodyTasks')} -

-
- - -
-
-
- )} - - {successMessage && ( - {successMessage} - )} -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 7888c07..9c19c20 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,204 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -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'; -import { Toast } from '@/components/ui/shared/Toast'; -import type { SubscriptionAlertData } from '@/types/subscription'; +import { SubscriptionsPage } from '@/components/ui/settings/SubscriptionsPage'; -const PLAN_OPTIONS = [ - { 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); - const [selectedPlanId, setSelectedPlanId] = useState(PLAN_OPTIONS[0].id); - const [purchaseNotice, setPurchaseNotice] = useState(null); - - useEffect(() => { - if (currentOrganization && !currentOrganization.isOwner) { - router.replace('/today'); - } - }, [currentOrganization, router]); - - useEffect(() => { - if (!currentOrganization?.isOwner) return; - void authApi.getSubscriptionAlert().then((r) => { - if (r.success) setAlert(r.data); - }); - }, [currentOrganization?.id, currentOrganization?.isOwner]); - - if (!currentOrganization) { - return ( -

{tCommon('loadingEllipsis')}

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

{tCommon('redirecting')}

- ); - } - - const plan = currentOrganization.plan; - const hasActiveSubscription = Boolean(plan); - const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId); - const maxUsers = plan?.maxUsers; - const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; - const seatsUsed = alert?.seatsUsed; - const seatsRemaining = - typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited - ? Math.max(0, maxUsers - seatsUsed) - : null; - const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null; - const planDayTone = - daysUntilPlanEnd == null - ? 'text-text-primary' - : daysUntilPlanEnd > 20 - ? 'text-emerald-400' - : daysUntilPlanEnd >= 10 - ? 'text-amber-300' - : 'text-red-400'; - - return ( -
-
- - {tCommon('backToApp')} - -

{t('subscriptionsTitle')}

-

- {t('subscriptionsSubtitle', { orgName: currentOrganization.name })} -

-
- -
- {!hasActiveSubscription && ( -
-

{t('noSubscriptionNotice')}

-
- )} - -
-
-

{t('currentPlan')}

-

- {plan?.name ?? '—'} -

-
-
-

{t('planPrice')}

-

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

-
-
-

{t('seatsUsed')}

-

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

-
-
-

{t('seatsRemaining')}

-

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

-
-
-

{t('daysRemaining')}

-

- {daysUntilPlanEnd ?? '—'} -

-
-
- - {alert?.showWarning && ( -
- {alert.noActiveSubscription && ( -

{t('noActiveSubscription')}

- )} - {alert.trialExpired && ( -

{t('trialEnded')}

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

- {t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })} -

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

{t('seatsLow')}

- )} -
- )} - -
-

{t('choosePlanIntro')}

-
- {PLAN_OPTIONS.map((option) => { - const selected = selectedPlanId === option.id; - return ( - - ); - })} -
- -
-
- - {purchaseNotice && ( -
-
- {purchaseNotice} -
-
- )} -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 6f4ccef..42d7975 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,1122 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { useRouter } from '@/i18n/navigation'; -import { - firstAccessibleDashboardPath, - canEditStaff, - canViewStaff, -} from '@/components/shared/permissions'; -import { - permissionNamesFromFeatureState, - emptyFeaturePermissionState, - featureStateFromPermissionNames, - featureStateHasTreatmentEdit, - resolveStaffFeatureLabel, - formatAccessSummary, - staffFeatureGroupsForOrgType, - type FeaturePermState, -} from '@/components/staff/staff-permission-form'; -import { - StaffWorkingHoursStep, - createDefaultWorkingHoursState, - workingHoursPayloadFromState, - workingHoursStateFromApi, -} from '@/components/staff/StaffWorkingHoursStep'; -import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours'; -import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react'; -import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; -import { Button } from '@/components/ui/shared/Button'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Input } from '@/components/ui/shared/Input'; -import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList'; -import { useToast } from '@/lib/hooks/useToast'; +import { StaffPage } from '@/components/ui/staff/StaffPage'; -type StoredInviteLink = { - membershipId: string; - email: string; - invitationUrl: string; -}; - -function inviteLinksStorageKey(orgId: string): string { - return `staffInviteLinks:${orgId}`; -} - -function readStoredInviteLinks(orgId: string): Record { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function writeStoredInviteLinks(orgId: string, links: Record) { - if (typeof window === 'undefined') return; - window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); -} - -function canShareStaffInviteLink(member: StaffMemberDto): boolean { - return ( - !member.isOwner && - (member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED') - ); -} - -function canDisableStaff(member: StaffMemberDto): boolean { - return !member.isOwner && member.isActive; -} - -function canEnableStaff(member: StaffMemberDto): boolean { - return !member.isOwner && member.invitationStatus === 'DISABLED'; -} - -function PermissionGrid({ - state, - onChange, - disabled, - organizationType, -}: { - state: FeaturePermState; - onChange: (next: FeaturePermState) => void; - 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({ - ...state, - [editKey]: { read, edit: read ? cur.edit : false }, - }); - }; - - const setEdit = (editKey: string, edit: boolean) => { - const cur = state[editKey] ?? { read: false, edit: false }; - onChange({ - ...state, - [editKey]: { read: edit || cur.read, edit }, - }); - }; - - return ( -
- {staffFeatureGroupsForOrgType(organizationType).map((g) => { - const cell = state[g.edit] ?? { read: false, edit: false }; - return ( -
- - {resolveStaffFeatureLabel(g, organizationType, tFeatures)} - -
- setRead(g.edit, v)} - /> - setEdit(g.edit, v)} - /> -
-
- ); - })} -
- ); -} - -export default function StaffPage() { - const router = useRouter(); - const t = useTranslations('staff'); - const tErrors = useTranslations('errors'); - 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<{ - used: number; - limit: number | null; - unlimited: boolean; - } | null>(null); - const [loading, setLoading] = useState(true); - 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); - const [lastInviteInfo, setLastInviteInfo] = useState<{ - membershipId: string; - name: string; - email: string; - invitationUrl: string | null; - invitationStatus: 'PENDING' | 'ACCEPTED'; - } | null>(null); - 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); - const [enableTarget, setEnableTarget] = useState(null); - const [enablingMembershipId, setEnablingMembershipId] = useState(null); - - const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); - const inviteHasTreatmentEdit = useMemo( - () => - currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms), - [currentOrganization?.type, invitePerms], - ); - const editHasTreatmentEdit = useMemo( - () => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms), - [currentOrganization?.type, editPerms], - ); - const hasActivePlan = Boolean(currentOrganization?.plan); - const atSeatLimit = useMemo(() => { - if (!seats || seats.unlimited) return false; - if (seats.limit == null) return false; - return seats.used >= seats.limit; - }, [seats]); - - const hasAvailableSeat = useMemo(() => { - if (!seats || seats.unlimited) return true; - if (seats.limit == null) return true; - return seats.used < seats.limit; - }, [seats]); - - const load = useCallback(async () => { - toast.setError(''); - setLoading(true); - try { - const res = await staffApi.list(); - setMembers(res.data.members); - setSeats(res.data.seats); - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff'))); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - if (!currentOrganization?.id) return; - setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); - }, [currentOrganization?.id]); - - useEffect(() => { - if (!currentOrganization?.id || loading) return; - - const activeMemberIds = new Set( - members - .filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE') - .map((m) => m.id), - ); - - let changed = false; - const nextLinks: Record = { ...pendingInviteLinks }; - for (const memberId of Object.keys(nextLinks)) { - if (activeMemberIds.has(memberId)) { - delete nextLinks[memberId]; - changed = true; - } - } - if (!changed) return; - - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - }, [currentOrganization?.id, loading, members, pendingInviteLinks]); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - if (!currentOrganization) return; - if (!canViewStaff(currentOrganization)) { - router.replace(firstAccessibleDashboardPath(currentOrganization)); - } - }, [currentOrganization, router]); - - async function copyStaffInviteLink(member: StaffMemberDto) { - if (!canShareStaffInviteLink(member)) return; - - setCopyingInviteMembershipId(member.id); - toast.setError(''); - try { - let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; - if (!invitationUrl || member.invitationStatus === 'EXPIRED') { - const res = await staffApi.getInvitationLink(member.id); - invitationUrl = res.data.invitationUrl; - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [member.id]: { - membershipId: member.id, - email: member.email, - invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - } - await navigator.clipboard.writeText(invitationUrl); - setCopiedInviteMembershipId(member.id); - setTimeout(() => setCopiedInviteMembershipId(null), 1500); - if (member.invitationStatus === 'EXPIRED') { - await load(); - } - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); - } finally { - setCopyingInviteMembershipId(null); - } - } - - function resetInviteForm() { - setInviteStep(1); - setInviteEmail(''); - setInviteName(''); - setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type)); - 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, - email: res.data.email, - invitationUrl: res.data.invitationUrl, - invitationStatus: res.data.invitationStatus, - }); - if (currentOrganization?.id && res.data.invitationUrl) { - const nextLinks = { - ...pendingInviteLinks, - [res.data.membershipId]: { - membershipId: res.data.membershipId, - email: res.data.email, - invitationUrl: res.data.invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - setInviteOpen(false); - resetInviteForm(); - await load(); - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite'))); - } finally { - setInviteLoading(false); - } - } - - async function openEdit(m: StaffMemberDto) { - if (m.isOwner) return; - setEditing(m); - setEditStep(1); - setEditName(m.name); - setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type)); - 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(getUserFacingError(e, tErrors, 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(t('successMemberUpdated')); - setEditing(null); - setEditStep(1); - await load(); - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember'))); - } finally { - setEditLoading(false); - } - } - - function handleDeleteMember() { - toast.showError(t('errorDeleteNotImplemented')); - } - - async function confirmDisableMember() { - if (!disableTarget || !canDisableStaff(disableTarget)) return; - - setDisablingMembershipId(disableTarget.id); - toast.setError(''); - try { - await staffApi.disableMember(disableTarget.id); - toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); - setDisableTarget(null); - await load(); - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember'))); - } finally { - setDisablingMembershipId(null); - } - } - - async function confirmEnableMember() { - if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return; - - setEnablingMembershipId(enableTarget.id); - toast.setError(''); - try { - await staffApi.enableMember(enableTarget.id); - toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name })); - setEnableTarget(null); - await load(); - } catch (e) { - toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember'))); - } finally { - setEnablingMembershipId(null); - } - } - - if (!currentOrganization || !canViewStaff(currentOrganization)) { - return ( -

{t('redirecting')}

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

{t('title')}

-

{t('subtitle')}

-
- -
- - - - {seats && ( -

- {t('seatsLabel')}{' '} - - {seats.used} - {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} - - {!seats.unlimited && atSeatLimit && ( - - {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} - - )} -

- )} - - {lastInviteInfo && ( -
- -

- {t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} - {lastInviteInfo.invitationStatus === 'PENDING' - ? ` ${t('invitedPending')}` - : ` ${t('invitedAccepted')}`} -

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

- {t('inviteLinkHeading')} -

- {lastInviteInfo.invitationUrl && ( - - {lastInviteInfo.invitationUrl} - - )} - -

{t('shareLinkHint')}

-
- )} -
- )} - - {loading ? ( -

{t('loadingTeam')}

- ) : ( - <> - - formatAccessSummary(member.permissions, currentOrganization?.type, tFeatures) - } - canShareInviteLink={canShareStaffInviteLink} - canEnable={canEnableStaff} - canDisable={canDisableStaff} - onCopyInviteLink={(member) => void copyStaffInviteLink(member)} - onEnable={setEnableTarget} - onDisable={setDisableTarget} - onEdit={openEdit} - onDelete={() => handleDeleteMember()} - labels={{ - roleOwner: t('roleOwner'), - roleStaff: t('roleStaff'), - statusActive: t('statusActive'), - statusPending: t('statusPending'), - statusDisabled: t('statusDisabled'), - statusExpired: t('statusExpired'), - allFeatures: t('allFeatures'), - copyInviteLink: t('copyInviteLinkTitle'), - enableMemberTitle: t('enableMemberTitle'), - disableMemberTitle: t('disableMemberTitle'), - editMemberAria: t('editMemberAria'), - deleteMemberAria: t('deleteMemberAria'), - }} - /> -
-
- {t('tableOrganization')} - - {t('tableOwnerEmail')} - - {t('tableDate')} - - {t('tableStatus')} - - {t('tableAction')} -
- {tCommon('loadingEllipsis')} -
- {t('emptyConnections')} -
- {row.organizationName} - {row.ownerEmail} - {formatTableDate(row.createdAt)} - - - {formatConnectionStatusLabel(row, currentOrganization.id)} - - -
- {invitationTarget && ( - void handleCopyInvitationFromRow(row)} - /> - )} - {canRespond && ( - <> - - - - )} - {row.status === 'ACTIVE' && ( - <> - - - - )} -
-
{r.name}{r.owner.email}{t('statusToday')} - {t('statusFound')} - - -
-
-

- {t('noDirectoryResults')} -

-
- -
- {showInviteForm && ( -
- setManualOrganizationName(e.target.value)} - /> - setManualOwnerEmail(e.target.value)} - /> -
- -
-
- )} -
-
- - - - - - - - } - body={ - <> - {members.map((m) => ( - - - - - - - - - ))} - - } - /> - - - )} - - {inviteOpen && ( -
-
-
-
-

- {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 ? ( - - ) : ( - - ) - ) : ( - <> - - - - )} -
-
-
- )} - - {enableTarget && ( -
-
-
-

- {t('enableModalTitle')} -

- { - if (enablingMembershipId) return; - setEnableTarget(null); - }} - /> -
-

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

-
    -
  • {t('enableBullet1')}
  • -
  • {t('enableBullet2')}
  • -
  • {t('enableBullet3')}
  • -
- {!hasAvailableSeat && ( -

{t('noSeatsAvailable')}

- )} -
- - -
-
-
- )} - - {disableTarget && ( -
-
-
-

- {t('disableModalTitle')} -

- { - if (disablingMembershipId) return; - setDisableTarget(null); - }} - /> -
-

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

-
    -
  • {t('disableBullet1')}
  • -
  • {t('disableBullet2')}
  • -
  • {t('disableBullet3')}
  • -
-
- - -
-
-
- )} - - {editing && ( -
-
-
-
-

{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 ? ( - - ) : ( - - ) - ) : ( - - )} -
-
-
- )} - - ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index 9898c9d..d293eb1 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -1,403 +1,7 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { MessageSquare } from 'lucide-react'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { Badge } from '@/components/ui/shared/Badge'; -import { Button } from '@/components/ui/shared/Button'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - labTaskStatusSelectStyle, - labTaskStatusVariant, -} from '@/components/ui/lab/labTaskStatusDisplay'; -import { - formatToothList, - prosthesisTypeBadgeStyle, -} from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { tasksApi } from '@/lib/api/tasks'; -import type { - LabTaskListItem, - LabTaskStatus, - ListLabTasksParams, - PaginatedLabTasks, - TaskSortField, -} from '@/types/cases'; +import { TasksPage } from '@/components/ui/lab/TasksPage'; -const PAGE_SIZE = 50; - -function formatPatientName(patient: { firstName: string; lastName: string }) { - return `${patient.firstName} ${patient.lastName}`.trim(); -} - -export default function TasksPage() { - const t = useTranslations('tasks'); - const tErrors = useTranslations('errors'); - const { currentOrganization, user, isAuthReady } = useAuth(); - const { showError, setError, messages: toastMessages } = useToast(); - - const [tasks, setTasks] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: PAGE_SIZE, - total: 0, - totalPages: 1, - }); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [updatingTaskId, setUpdatingTaskId] = useState(null); - const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(null); - - const [search, setSearch] = useState(''); - const [clinicId, setClinicId] = useState(''); - const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); - const [sortBy, setSortBy] = useState('date'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - - const canView = canViewTasks(currentOrganization); - const canEdit = canEditTasks(currentOrganization); - const locale = user?.language ?? 'en'; - - const tRef = useRef(t); - tRef.current = t; - - const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( - () => [ - { value: 'IN_PROGRESS', label: t('statusInProgress') }, - { value: 'COMPLETED', label: t('statusCompleted') }, - ], - [t], - ); - - const listParams = useMemo((): ListLabTasksParams => { - const params: ListLabTasksParams = { - page, - limit: PAGE_SIZE, - sortBy, - sortDir, - }; - if (search.trim()) params.q = search.trim(); - if (clinicId) params.clinicOrganizationId = clinicId; - if (statusFilter) params.status = statusFilter; - if (sentFrom) params.sentFrom = sentFrom; - if (sentTo) params.sentTo = sentTo; - return params; - }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); - - const clinicOptions = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - map.set(task.clinic.id, task.clinic.name); - } - return [...map.entries()].map(([id, name]) => ({ id, name })); - }, [tasks]); - - const loadTasks = useCallback(async () => { - setLoading(true); - setError(''); - try { - const response = await tasksApi.list(listParams); - setTasks(response.data.items); - setPagination(response.data.pagination); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList'))); - } finally { - setLoading(false); - } - }, [listParams, showError, setError]); - - useEffect(() => { - if (!canView) return; - const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); - return () => clearTimeout(timeout); - }, [canView, loadTasks, search]); - - async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { - if (!canEdit) return; - setUpdatingTaskId(taskId); - setError(''); - try { - await tasksApi.updateStatus(taskId, status); - await loadTasks(); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); - } finally { - setUpdatingTaskId(null); - } - } - - function formatTaskDate(value: string) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(new Date(value)); - } - - const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; - - if (!isAuthReady) { - return
{t('loading')}
; - } - - if (!canView) { - return ( -
-

{t('noPermissionTitle')}

-

{t('noPermissionBody')}

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

{t('title')}

-

{t('subtitle')}

-
- -
- { - setSearch(v); - setPage(1); - }} - placeholder={t('searchPlaceholder')} - /> -
- - - -
-
- -
- {loading && tasks.length === 0 ? ( -

{t('loading')}

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

{t('emptyList')}

- ) : ( -
    - {tasks.map((task, index) => { - const commentsOpen = expandedCommentsTaskId === task.id; - - return ( -
  • -
    -
    -
    -

    - {task.stepOrder}. {task.stepLabel} -

    - {task.isImportant ? ( - - {t('importantBadge')} - - ) : null} -
    -

    - {t('fromClinic', { name: task.clinic.name })} ·{' '} - {formatPatientName(task.patient)} ·{' '} - {t('teethLabel', { teeth: formatToothList(task.teeth) })} -

    -

    - {t('taskDate', { date: formatTaskDate(task.createdAt) })} - {task.lastStatusChangedBy ? ( - <> - · - - {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} - - - ) : null} -

    -
    - -
    - {canEdit ? ( - - ) : ( - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - )} -
    - -
    - {canEdit ? ( - - ) : null} - - {task.prosthesisTypeLabel} - -
    -
    - - {commentsOpen && canEdit ? ( -
    - { - const r = await tasksApi.listComments(task.labCaseId); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(task.labCaseId, { - body, - visibleToClinic, - }); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={showError} - /> -
    - ) : null} -
  • - ); - })} -
- )} -
- - {pagination.totalPages > 1 && ( -
-

- {t('pageSummary', { - page: pagination.page, - totalPages: pagination.totalPages, - total: pagination.total, - })} -

-
- - -
-
- )} - - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 65e938c..9ca7f39 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,80 +1,7 @@ 'use client'; -import { useMemo } from 'react'; -import { useTranslations } from 'next-intl'; -import { Link } from '@/i18n/navigation'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { TodayDashboard } from '@/components/today/TodayDashboard'; -import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; -import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; -import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary'; -import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; +import { TodayPage } from '@/components/ui/today/TodayPage'; -export default function TodayPage() { - const t = useTranslations('today'); - const tErrors = useTranslations('errors'); - const { currentOrganization } = useAuth(); - const orgId = currentOrganization?.id; - const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); - - const showNoSubscriptionNotice = useMemo( - () => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan, - [currentOrganization], - ); - - const sectionErrorMessage = t('sectionLoadError'); - - return ( -
-
-

{t('welcomeBack')}

- {data?.generatedAt && !isInitialLoad ? ( -

- {t('lastUpdated', { - time: new Intl.DateTimeFormat(undefined, { - hour: 'numeric', - minute: '2-digit', - }).format(new Date(data.generatedAt)), - })} -

- ) : null} -
- - {showNoSubscriptionNotice && ( -
-

- {t('noSubscriptionNotice')}{' '} - - {t('choosePlanLink')} - {' '} - {t('noSubscriptionCta')} -

-
- )} - - {error ? ( - void reload()} - isRetrying={loading && Boolean(data)} - /> - ) : null} - - } - > - - -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/appointments/appointmentPurposeStyles.ts similarity index 95% rename from frontend/src/components/ui/appointments/appointmentPurposeStyles.ts rename to frontend/src/components/appointments/appointmentPurposeStyles.ts index 71109e5..245179c 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/appointments/appointmentPurposeStyles.ts @@ -4,7 +4,7 @@ import { treatmentTypeBannerStyle, treatmentTypeLabelFromCatalog, treatmentTypeSwatchStyle, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; /** * Appointment purposes are treatment-type codes. Labels and colors now come from diff --git a/frontend/src/components/ui/lab/caseDetailUtils.ts b/frontend/src/components/lab/caseDetailUtils.ts similarity index 100% rename from frontend/src/components/ui/lab/caseDetailUtils.ts rename to frontend/src/components/lab/caseDetailUtils.ts diff --git a/frontend/src/components/ui/lab/labTaskStatusDisplay.ts b/frontend/src/components/lab/labTaskStatusDisplay.ts similarity index 100% rename from frontend/src/components/ui/lab/labTaskStatusDisplay.ts rename to frontend/src/components/lab/labTaskStatusDisplay.ts diff --git a/frontend/src/components/organizations/connectionStatusVariant.ts b/frontend/src/components/organizations/connectionStatusVariant.ts new file mode 100644 index 0000000..de21080 --- /dev/null +++ b/frontend/src/components/organizations/connectionStatusVariant.ts @@ -0,0 +1,16 @@ +import type { BadgeVariant } from '@/components/ui/shared/Badge'; + +/** Map organization connection / invitation row status to badge variant. */ +export function organizationConnectionStatusVariant(status: string): BadgeVariant { + switch (status) { + case 'ACTIVE': + return 'success'; + case 'PENDING': + return 'warning'; + case 'REJECTED': + case 'EXPIRED': + return 'danger'; + default: + return 'default'; + } +} diff --git a/frontend/src/components/ui/treatment/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts similarity index 100% rename from frontend/src/components/ui/treatment/catalog-type-colors.ts rename to frontend/src/components/shared/catalog-type-colors.ts diff --git a/frontend/src/components/ui/shared/formSelectStyles.ts b/frontend/src/components/shared/formSelectStyles.ts similarity index 100% rename from frontend/src/components/ui/shared/formSelectStyles.ts rename to frontend/src/components/shared/formSelectStyles.ts diff --git a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts b/frontend/src/components/shared/treatmentTypeDisplay.ts similarity index 97% rename from frontend/src/components/ui/treatment/treatmentTypeDisplay.ts rename to frontend/src/components/shared/treatmentTypeDisplay.ts index ff2ca0a..3876993 100644 --- a/frontend/src/components/ui/treatment/treatmentTypeDisplay.ts +++ b/frontend/src/components/shared/treatmentTypeDisplay.ts @@ -3,7 +3,7 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { resolveCatalogTypeColor, TREATMENT_TYPE_COLORS, -} from '@/components/ui/treatment/catalog-type-colors'; +} from '@/components/shared/catalog-type-colors'; /** * Single source of truth for treatment-type colors across the app diff --git a/frontend/src/components/staff/staffPermissions.ts b/frontend/src/components/staff/staffPermissions.ts deleted file mode 100644 index 383c44d..0000000 --- a/frontend/src/components/staff/staffPermissions.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Feature groups for staff invite/edit UI — matches backend seed */ -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' }, -] as const; - -/** Map EDIT key -> { read, edit } for checkbox grid */ -export type FeaturePermState = Record; - -export function emptyFeaturePermissionState(): FeaturePermState { - const s: FeaturePermState = {}; - for (const g of STAFF_FEATURE_GROUPS) { - s[g.edit] = { read: false, edit: false }; - } - return s; -} - -export function featureStateFromPermissionNames(names: string[]): FeaturePermState { - const set = new Set(names); - const s = emptyFeaturePermissionState(); - for (const g of STAFF_FEATURE_GROUPS) { - const hasEdit = set.has(g.edit); - const hasRead = set.has(g.read) || hasEdit; - s[g.edit] = { read: hasRead, edit: hasEdit }; - } - return s; -} - -export function permissionNamesFromFeatureState(state: FeaturePermState): string[] { - const out: string[] = []; - for (const g of STAFF_FEATURE_GROUPS) { - const cell = state[g.edit]; - if (!cell) continue; - if (cell.edit) out.push(g.edit); - else if (cell.read) out.push(g.read); - } - return out; -} diff --git a/frontend/src/components/today/chart-theme.ts b/frontend/src/components/today/chart-theme.ts index 50bef8c..8356f78 100644 --- a/frontend/src/components/today/chart-theme.ts +++ b/frontend/src/components/today/chart-theme.ts @@ -1,4 +1,4 @@ -import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors'; +import { CATALOG_PALETTE_COLORS } from '@/components/shared/catalog-type-colors'; /** Chart series colors — same palette as treatment / prosthesis catalog types. */ export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS; diff --git a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts similarity index 95% rename from frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts rename to frontend/src/components/treatment/prosthesisTypeDisplay.ts index 278c83e..4f46f07 100644 --- a/frontend/src/components/ui/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -3,7 +3,7 @@ import { PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, resolveCatalogTypeColor, -} from '@/components/ui/treatment/catalog-type-colors'; +} from '@/components/shared/catalog-type-colors'; /** * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group diff --git a/frontend/src/components/ui/treatment/toothPathModel.ts b/frontend/src/components/treatment/toothPathModel.ts similarity index 100% rename from frontend/src/components/ui/treatment/toothPathModel.ts rename to frontend/src/components/treatment/toothPathModel.ts diff --git a/frontend/src/components/ui/treatment/treatmentStatusStyles.ts b/frontend/src/components/treatment/treatmentStatusStyles.ts similarity index 100% rename from frontend/src/components/ui/treatment/treatmentStatusStyles.ts rename to frontend/src/components/treatment/treatmentStatusStyles.ts diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index a6670a1..d122b7a 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -11,7 +11,7 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { DROPDOWN_OPTION_BG, treatmentTypeColor, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 04b4efd..9b6763d 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -6,7 +6,7 @@ import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { purposeBannerStyle, purposeLabel, -} from '@/components/ui/appointments/appointmentPurposeStyles'; +} from '@/components/appointments/appointmentPurposeStyles'; import type { AppointmentRecord } from '@/types/appointment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 9d07db9..becbe99 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -18,7 +18,7 @@ import { findOverlapCluster, lanePositionStyles, } from '@/components/appointments/appointmentOverlapLayout'; -import { purposeBannerStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeBannerStyle } from '@/components/appointments/appointmentPurposeStyles'; import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover'; import { formatMobileForDisplay } from '@/lib/phone'; import { startOfLocalDay } from '@/components/appointments/appointmentTime'; diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 041a2e6..aa471be 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { purposeSwatchStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeSwatchStyle } from '@/components/appointments/appointmentPurposeStyles'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface AppointmentScheduleLegendProps { diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx new file mode 100644 index 0000000..6dc3a7e --- /dev/null +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -0,0 +1,373 @@ +'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 { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; +import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import type { CreatePatientInput, Patient } from '@/types/patient'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; +import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; +import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; +import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { useToast } from '@/lib/hooks/useToast'; +import type { AppointmentPurpose } from '@/types/appointment'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export function AppointmentsPage() { + const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); + const tPatients = useTranslations('patients'); + const { currentOrganization } = useAuth(); + const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); + + const [providers, setProviders] = useState([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + 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 [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); + const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); + + + const canManageAppointments = canEditAppointments(currentOrganization); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); + + const scheduleLoadGen = useRef(0); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + const loadSchedule = useCallback(async () => { + if (!currentOrganization?.id) { + return; + } + const gen = ++scheduleLoadGen.current; + setLoadingSchedule(true); + toast.setError(''); + try { + const range = getLocalDayIsoRange(scheduleDate); + const [pRes, aRes] = await Promise.all([ + appointmentsApi.columnProviders(scheduleDate), + appointmentsApi.list(range), + ]); + if (gen !== scheduleLoadGen.current) { + return; + } + setProviders(pRes.data); + setAppointments(aRes.data); + } catch (err: unknown) { + if (gen !== scheduleLoadGen.current) { + return; + } + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); + } finally { + if (gen === scheduleLoadGen.current) { + setLoadingSchedule(false); + } + } + }, [currentOrganization?.id, scheduleDate, t]); + + useEffect(() => { + void loadSchedule(); + }, [loadSchedule]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data)) + .catch(() => {}); + }, []); + + useEffect(() => { + const t = setTimeout(() => { + void loadPatientsSearch(search); + }, 300); + return () => clearTimeout(t); + }, [search]); + + async function loadPatientsSearch(q: string) { + if (!currentOrganization) { + return; + } + setLoadingPatients(true); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + if (selectedPatient) { + const stillThere = items.find((p) => p.id === selectedPatient.id); + if (stillThere) { + setSelectedPatient(stillThere); + } + } + } catch { + setPatients([]); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatientsSearch(search); + setSelectedPatient(response.data); + if (response.existing) { + toast.showInfo( + tPatients('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + if (!selectedPatient) { + toast.showInfo(t('infoSelectPatient')); + return; + } + setBookingStartMinute(startMinute); + setBookingProviderId(providerUserId); + setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + 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; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) { + setSavingAppointment(true); + toast.setError(''); + try { + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); + } finally { + setSavingAppointment(false); + } + } + + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } + if (!window.confirm(t('confirmRemove'))) { + return; + } + setDeletingAppointment(true); + toast.setError(''); + try { + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(t('successRemoved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); + } finally { + setDeletingAppointment(false); + } + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + + +
+
+ { + if (!canEditPatients) { + return; + } + setPatientForm(EMPTY_PATIENT_FORM); + setIsCreateOpen(true); + }} + /> + +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> + + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + +
+ ); +} diff --git a/frontend/src/components/ui/billing/BillingPage.tsx b/frontend/src/components/ui/billing/BillingPage.tsx new file mode 100644 index 0000000..729da84 --- /dev/null +++ b/frontend/src/components/ui/billing/BillingPage.tsx @@ -0,0 +1,296 @@ +// src/app/(dashboard)/billing/page.tsx +'use client'; + +import { useMemo, useState } from 'react'; +import { Pencil } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Card } from '@/components/ui/shared/Card'; +import { Table } from '@/components/ui/shared/Table'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { hasPermission } from '@/components/shared/permissions'; + +type InvoiceStatus = 'paid' | 'unpaid' | 'overdue'; + +type Invoice = { + id: string; + patient: string; + date: string; + service: string; + amount: number; + paid: number; + status: InvoiceStatus; +}; + +const invoices: Invoice[] = [ + { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, + { id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' }, + { id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' }, +]; + +const statusColors = { + paid: 'success', + unpaid: 'warning', + overdue: 'danger', +} as const; + +const statusFilters = ['all', 'paid', 'unpaid', 'overdue'] as const; + +type StatCardColor = 'blue' | 'yellow' | 'green' | 'red'; + +interface StatCardProps { + title: string; + count: number; + amount: number; + color: StatCardColor; +} + +export function BillingPage() { + const { currentOrganization } = useAuth(); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState<(typeof statusFilters)[number]>('all'); + const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT'); + + const stats = { + total: { count: 235, amount: 80900 }, + unpaid: { count: 30, amount: 2800 }, + paid: { count: 190, amount: 80900 }, + overdue: { count: 235, amount: 80900 }, + }; + + const filteredInvoices = useMemo(() => { + const query = search.trim().toLowerCase(); + + return invoices.filter((invoice) => { + const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter; + const matchesSearch = + !query || + invoice.patient.toLowerCase().includes(query) || + invoice.id.toLowerCase().includes(query) || + invoice.service.toLowerCase().includes(query); + + return matchesStatus && matchesSearch; + }); + }, [search, statusFilter]); + + return ( +
+
+

Billing

+ +
+ +
+ + + + +
+ + + {statusFilters.map((status) => ( + + ))} + + )} + /> + +
+ {filteredInvoices.length === 0 ? ( +
No invoices match your filters.
+ ) : ( + filteredInvoices.map((invoice) => ( + + )) + )} + +
+ +
+
{t('tableName')}{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableAccess')} - {t('tableAction')} -
{m.name}{m.email} - {m.isOwner ? ( - {t('roleOwner')} - ) : ( - {t('roleStaff')} - )} - - {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - {t('statusActive')} - ) : m.invitationStatus === 'PENDING' ? ( - {t('statusPending')} - ) : m.invitationStatus === 'DISABLED' ? ( - {t('statusDisabled')} - ) : ( - {t('statusExpired')} - )} - - {m.isOwner ? ( - {t('allFeatures')} - ) : ( - - {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} - - )} - - {!m.isOwner && ( -
- {canShareStaffInviteLink(m) && ( - - )} - {canEnableStaff(m) && ( - - )} - {canDisableStaff(m) && ( - - )} - - -
- )} -
+ + + + + + + + + + } + body={ + <> + {filteredInvoices.map((invoice) => ( + + + + + + + + + + + ))} + + } + footer={} + /> + + + ); +} + +function InvoiceMobileCard({ + invoice, + canEditBilling, +}: { + invoice: Invoice; + canEditBilling: boolean; +}) { + const remaining = invoice.amount - invoice.paid; + + return ( + +
+
+

{invoice.patient}

+

{invoice.id}

+
+ + {invoice.status} + +
+ +
+ {invoice.service} + · + {invoice.date} +
+ +
+
+

Total

+

${invoice.amount}

+
+
+

Paid

+

${invoice.paid}

+
+
+

Due

+

${remaining}

+
+
+ +
+ +
+
+ ); +} + +function InvoiceEditButton({ canEditBilling }: { canEditBilling: boolean }) { + return ( + + ); +} + +function InvoicePagination({ className = '' }: { className?: string }) { + return ( +
+ +
Page 1 of 10
+ +
+ ); +} + +function StatCard({ title, count, amount, color }: StatCardProps) { + const colors: Record = { + blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border', + yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border', + green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border', + red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border', + }; + + return ( + +

{title}

+

{count}

+

+ ${amount.toLocaleString()} +

+
+ ); +} diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index f970ee0..99d0ab5 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -9,17 +9,17 @@ import { Checkbox } from '@/components/ui/shared/Checkbox'; import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; -import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay'; +import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { formatToothList, prosthesisTypeBadgeStyle, -} from '@/components/ui/treatment/prosthesisTypeDisplay'; +} from '@/components/treatment/prosthesisTypeDisplay'; import { buildCaseProsthesisRows, formatCaseDateTime, formatPatientName, latestCaseAttachment, -} from '@/components/ui/lab/caseDetailUtils'; +} from '@/components/lab/caseDetailUtils'; import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index acc4513..ea51b15 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; -import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; import type { FdiToothId } from '@/types/treatment'; export interface CaseToothChartDetail { diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx new file mode 100644 index 0000000..2ce1136 --- /dev/null +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -0,0 +1,472 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { canEditCases, canEditTasks } from '@/components/shared/permissions'; +import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + formatCaseDateTime, + formatPatientName, +} from '@/components/lab/caseDetailUtils'; +import { casesApi } from '@/lib/api/cases'; +import { tasksApi } from '@/lib/api/tasks'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import { Button } from '@/components/ui/shared/Button'; +import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { + CasesFilterOptions, + LabCaseDetail, + LabCaseListItem, + LabTaskStatus, + PaginatedLabCases, +} from '@/types/cases'; + +const PAGE_SIZE = 20; + +export function CasesPage() { + const t = useTranslations('cases'); + const tErrors = useTranslations('errors'); + const tCommon = useTranslations('common'); + const { currentOrganization, user } = useAuth(); + const toast = useToast(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [treatmentType, setTreatmentType] = useState(''); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [page, setPage] = useState(1); + + const [cases, setCases] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [filterOptions, setFilterOptions] = useState({ + clinics: [], + treatmentTypes: [], + }); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + + const [selectedCaseId, setSelectedCaseId] = useState(null); + const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [selectedCase, setSelectedCase] = useState(null); + const [loadingList, setLoadingList] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [updatingImportant, setUpdatingImportant] = useState(false); + const [commentCount, setCommentCount] = useState(0); + + const canEdit = canEditCases(currentOrganization); + const canEditComments = canEditTasks(currentOrganization); + const locale = user?.language ?? 'en'; + + const treatmentLabel = useCallback( + (type: string) => treatmentTypeLabelFromCatalog(type, treatmentCatalog), + [treatmentCatalog], + ); + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const hasActiveFilters = Boolean( + search.trim() || clinicId || treatmentType || sentFrom || sentTo, + ); + + const loadCases = async (params: { + q: string; + clinicOrganizationId: string; + treatmentType: string; + sentFrom: string; + sentTo: string; + page: number; + }) => { + setLoadingList(true); + toast.setError(''); + try { + const response = await casesApi.list({ + q: params.q.trim() || undefined, + clinicOrganizationId: params.clinicOrganizationId || undefined, + treatmentType: params.treatmentType || undefined, + sentFrom: params.sentFrom || undefined, + sentTo: params.sentTo || undefined, + page: params.page, + limit: PAGE_SIZE, + }); + setCases(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); + } finally { + setLoadingList(false); + } + }; + + const loadDetail = async (caseId: string, options?: { silent?: boolean }) => { + if (!options?.silent) { + setLoadingDetail(true); + } + toast.setError(''); + try { + const response = await casesApi.getOne(caseId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); + if (!options?.silent) { + setSelectedCase(null); + } + } finally { + if (!options?.silent) { + setLoadingDetail(false); + } + } + }; + + useEffect(() => { + void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); + void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch + }, []); + + useEffect(() => { + const caseIdFromUrl = searchParams.get('caseId'); + if (caseIdFromUrl) { + setSelectedCaseId(caseIdFromUrl); + setMobileDetailOpen(true); + } + }, [searchParams]); + + useEffect(() => { + if (!selectedCaseId) { + setMobileDetailOpen(false); + } + }, [selectedCaseId]); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadCases({ + q: search, + clinicOrganizationId: clinicId, + treatmentType, + sentFrom, + sentTo, + page, + }); + }, search ? 300 : 0); + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search + filter reload + }, [search, clinicId, treatmentType, sentFrom, sentTo, page]); + + useEffect(() => { + if (selectedCaseId) { + void loadDetail(selectedCaseId); + void tasksApi + .listComments(selectedCaseId) + .then((r) => setCommentCount(r.data.length)) + .catch(() => setCommentCount(0)); + } else { + setSelectedCase(null); + setCommentCount(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes + }, [selectedCaseId]); + + function scrollToComments() { + document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' }); + } + + const loadCaseAttachmentBlob = useCallback( + (caseId: string, attachmentId: string) => casesApi.getAttachmentFileBlob(caseId, attachmentId), + [], + ); + + function clearFilters() { + setSearch(''); + setClinicId(''); + setTreatmentType(''); + setSentFrom(''); + setSentTo(''); + setPage(1); + } + + async function handleCaseImportantToggle(isImportant: boolean) { + if (!selectedCaseId || !canEdit || !selectedCase) return; + + const previousCase = selectedCase; + setSelectedCase({ ...selectedCase, isImportant }); + + setUpdatingImportant(true); + toast.setError(''); + try { + const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); + setSelectedCase(response.data); + } catch (error: unknown) { + setSelectedCase(previousCase); + toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); + } finally { + setUpdatingImportant(false); + } + } + + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+ { + setSearch(value); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> + +
+ + + + + + + +
+ + {hasActiveFilters ? ( + + ) : null} + +
+ {loadingList ? ( +

{tCommon('loading')}

+ ) : cases.length === 0 ? ( +

{t('emptyList')}

+ ) : ( +
    + {cases.map((item) => { + const isActive = item.id === selectedCaseId; + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {pagination.totalPages > 1 ? ( +
+ + + {t('pageSummary', { + page: pagination.page, + totalPages: pagination.totalPages, + total: pagination.total, + })} + + +
+ ) : null} +
+ +
+ {mobileDetailOpen && selectedCaseId ? ( + setMobileDetailOpen(false)} /> + ) : null} + {!selectedCaseId ? ( +

{t('selectCaseHint')}

+ ) : loadingDetail || !selectedCase ? ( +

{tCommon('loading')}

+ ) : ( + void handleCaseImportantToggle(checked)} + headerMetaLines={ +

+ {t('fromClinic', { name: selectedCase.clinic.name })} +

+ } + commentsSection={ + selectedCaseId ? ( +
+ { + const r = await tasksApi.listComments(selectedCaseId); + setCommentCount(r.data.length); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(selectedCaseId, { + body, + visibleToClinic, + }); + setCommentCount((n) => n + 1); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={toast.showError} + /> +
+ ) : null + } + /> + )} +
+
+ + +
+ ); +} diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx new file mode 100644 index 0000000..d154d18 --- /dev/null +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -0,0 +1,403 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + labTaskStatusSelectStyle, + labTaskStatusVariant, +} from '@/components/lab/labTaskStatusDisplay'; +import { + formatToothList, + prosthesisTypeBadgeStyle, +} from '@/components/treatment/prosthesisTypeDisplay'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { tasksApi } from '@/lib/api/tasks'; +import type { + LabTaskListItem, + LabTaskStatus, + ListLabTasksParams, + PaginatedLabTasks, + TaskSortField, +} from '@/types/cases'; + +const PAGE_SIZE = 50; + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +export function TasksPage() { + const t = useTranslations('tasks'); + const tErrors = useTranslations('errors'); + const { currentOrganization, user, isAuthReady } = useAuth(); + const { showError, setError, messages: toastMessages } = useToast(); + + const [tasks, setTasks] = useState([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState(null); + + const [search, setSearch] = useState(''); + const [clinicId, setClinicId] = useState(''); + const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); + const [sentFrom, setSentFrom] = useState(''); + const [sentTo, setSentTo] = useState(''); + const [sortBy, setSortBy] = useState('date'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + + const canView = canViewTasks(currentOrganization); + const canEdit = canEditTasks(currentOrganization); + const locale = user?.language ?? 'en'; + + const tRef = useRef(t); + tRef.current = t; + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: t('statusInProgress') }, + { value: 'COMPLETED', label: t('statusCompleted') }, + ], + [t], + ); + + const listParams = useMemo((): ListLabTasksParams => { + const params: ListLabTasksParams = { + page, + limit: PAGE_SIZE, + sortBy, + sortDir, + }; + if (search.trim()) params.q = search.trim(); + if (clinicId) params.clinicOrganizationId = clinicId; + if (statusFilter) params.status = statusFilter; + if (sentFrom) params.sentFrom = sentFrom; + if (sentTo) params.sentTo = sentTo; + return params; + }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); + + const clinicOptions = useMemo(() => { + const map = new Map(); + for (const task of tasks) { + map.set(task.clinic.id, task.clinic.name); + } + return [...map.entries()].map(([id, name]) => ({ id, name })); + }, [tasks]); + + const loadTasks = useCallback(async () => { + setLoading(true); + setError(''); + try { + const response = await tasksApi.list(listParams); + setTasks(response.data.items); + setPagination(response.data.pagination); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList'))); + } finally { + setLoading(false); + } + }, [listParams, showError, setError]); + + useEffect(() => { + if (!canView) return; + const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0); + return () => clearTimeout(timeout); + }, [canView, loadTasks, search]); + + async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { + if (!canEdit) return; + setUpdatingTaskId(taskId); + setError(''); + try { + await tasksApi.updateStatus(taskId, status); + await loadTasks(); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + } + + function formatTaskDate(value: string) { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(value)); + } + + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; + + if (!isAuthReady) { + return
{t('loading')}
; + } + + if (!canView) { + return ( +
+

{t('noPermissionTitle')}

+

{t('noPermissionBody')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ { + setSearch(v); + setPage(1); + }} + placeholder={t('searchPlaceholder')} + /> +
+ + + +
+
+ +
+ {loading && tasks.length === 0 ? ( +

{t('loading')}

+ ) : tasks.length === 0 ? ( +

{t('emptyList')}

+ ) : ( +
    + {tasks.map((task, index) => { + const commentsOpen = expandedCommentsTaskId === task.id; + + return ( +
  • +
    +
    +
    +

    + {task.stepOrder}. {task.stepLabel} +

    + {task.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
    +

    + {t('fromClinic', { name: task.clinic.name })} ·{' '} + {formatPatientName(task.patient)} ·{' '} + {t('teethLabel', { teeth: formatToothList(task.teeth) })} +

    +

    + {t('taskDate', { date: formatTaskDate(task.createdAt) })} + {task.lastStatusChangedBy ? ( + <> + · + + {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} + + + ) : null} +

    +
    + +
    + {canEdit ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + + )} +
    + +
    + {canEdit ? ( + + ) : null} + + {task.prosthesisTypeLabel} + +
    +
    + + {commentsOpen && canEdit ? ( +
    + { + const r = await tasksApi.listComments(task.labCaseId); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(task.labCaseId, { + body, + visibleToClinic, + }); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={showError} + /> +
    + ) : null} +
  • + ); + })} +
+ )} +
+ + {pagination.totalPages > 1 && ( +
+

+ {t('pageSummary', { + page: pagination.page, + totalPages: pagination.totalPages, + total: pagination.total, + })} +

+
+ + +
+
+ )} + + +
+ ); +} diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index fa057cf..16f4832 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -8,7 +8,7 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; import { organizationApi } from '@/lib/api/organization'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { SearchBar } from '@/components/ui/shared/SearchBar'; @@ -18,7 +18,7 @@ import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { formatCaseDateTime, formatPatientName, -} from '@/components/ui/lab/caseDetailUtils'; +} from '@/components/lab/caseDetailUtils'; import { treatmentsApi } from '@/lib/api/treatments'; import { casesApi } from '@/lib/api/cases'; import type { CounterpartItemDto } from '@/lib/api/organization'; diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index 3563226..fb1b244 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -9,7 +9,8 @@ import { } from '@/components/ui/shared/ResponsiveDialog'; import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; import { Table } from '@/components/ui/shared/Table'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; diff --git a/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx index 95e1f21..16ed0e3 100644 --- a/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx +++ b/frontend/src/components/ui/organizations/OrganizationConnectionsMobileList.tsx @@ -5,7 +5,8 @@ import type { CounterpartItemDto, CounterpartSearchResultDto, } from '@/lib/api/organization'; -import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge'; +import { Badge } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; import { Button } from '@/components/ui/shared/Button'; import { Card } from '@/components/ui/shared/Card'; import { Input } from '@/components/ui/shared/Input'; diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx new file mode 100644 index 0000000..6272ccc --- /dev/null +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -0,0 +1,603 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useToast } from '@/lib/hooks/useToast'; +import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount'; +import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; +import { + organizationApi, + type CounterpartItemDto, + type CounterpartSearchResultDto, + type OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; +import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks'; +import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; +import { OrganizationConnectionsMobileList } from '@/components/ui/organizations/OrganizationConnectionsMobileList'; +import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; +import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge } from '@/components/ui/shared/Badge'; +import { organizationConnectionStatusVariant } from '@/components/organizations/connectionStatusVariant'; +import { Input } from '@/components/ui/shared/Input'; +import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { Table } from '@/components/ui/shared/Table'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { getUserFacingError } from '@/components/shared/formatApiError'; + +function formatOrganizationStatusLabel(status: string): string { + if (!status) return status; + const lower = status.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +function formatTableDate(value: string): string { + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '\u2014'; + return d.toLocaleDateString(); +} + +type TableMode = 'existing' | 'search'; + +export function OrganizationsPage() { + const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); + const tNav = useTranslations('nav'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const [loading, setLoading] = useState(true); + const toast = useToast(); + const { showError, setError: setToastError } = toast; + + const formatApiMessage = useCallback( + (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), + [tCommon, tErrors], + ); + + 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); + const [searchResults, setSearchResults] = useState([]); + const [pendingConnectionRowId, setPendingConnectionRowId] = useState(null); + const [deleteConnectionRowId, setDeleteConnectionRowId] = useState(null); + + const [items, setItems] = useState([]); + const [manualOrganizationName, setManualOrganizationName] = useState(''); + const [manualOwnerEmail, setManualOwnerEmail] = useState(''); + const [inviteLoading, setInviteLoading] = useState(false); + const [showInviteForm, setShowInviteForm] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyItems, setHistoryItems] = useState([]); + const [caseHistoryConnection, setCaseHistoryConnection] = useState( + null, + ); + + const { + copiedId, + copyingInvitationId, + storeInviteLink, + copyInvitationLink, + pruneAcceptedLinks, + } = useOrganizationInviteLinkCopy(currentOrganization?.id); + + const counterpart = + currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab'); + const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs'); + + const existingRows = items; + + async function loadList() { + setLoading(true); + toast.setError(''); + try { + const res = await organizationApi.list(); + setItems(res.data.items); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadList(); + }, []); + + useEffect(() => { + let cancelled = false; + const q = query.trim(); + + if (!q) { + setMode('existing'); + setSearchResults([]); + setShowInviteForm(false); + setSearching(false); + return; + } + + setMode('search'); + setShowInviteForm(false); + setSearching(true); + + const timeout = setTimeout(() => { + void (async () => { + setToastError(''); + try { + const res = await organizationApi.search(q); + if (cancelled) return; + setSearchResults(res.data); + } catch (e) { + if (cancelled) return; + showError(formatApiMessage(e)); + setSearchResults([]); + } finally { + if (!cancelled) setSearching(false); + } + })(); + }, 300); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [query, formatApiMessage, showError, setToastError]); + + async function submitConnectionRequest(targetOrganizationId: string) { + setPendingConnectionRowId(targetOrganizationId); + toast.setError(''); + try { + await organizationApi.createConnectionRequest(targetOrganizationId); + toast.showSuccess(t('successConnectionSent', { counterpart })); + setSearchResults([]); + setQuery(''); + setMode('existing'); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setPendingConnectionRowId(null); + } + } + + async function sendInvite() { + setInviteLoading(true); + toast.setError(''); + try { + const res = await organizationApi.invite({ + organizationName: manualOrganizationName.trim(), + ownerEmail: manualOwnerEmail.trim(), + }); + storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); + toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() })); + setManualOrganizationName(''); + setManualOwnerEmail(''); + setShowInviteForm(false); + setMode('existing'); + setQuery(''); + setSearchResults([]); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setInviteLoading(false); + } + } + + async function loadInvitationHistory() { + const res = await organizationApi.listInvitations(); + setHistoryItems(res.data.items); + pruneAcceptedLinks(res.data.items); + return res.data.items; + } + + async function openInvitationHistory() { + setHistoryOpen(true); + setHistoryLoading(true); + toast.clear(); + try { + await loadInvitationHistory(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setHistoryLoading(false); + } + } + + async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { + toast.setError(''); + try { + await copyInvitationLink(invitation, { + onRegenerated: async () => { + await loadInvitationHistory(); + }, + }); + toast.showSuccess(t('successLinkCopied')); + } catch (e) { + toast.showError(formatApiMessage(e)); + } + } + + async function handleCopyInvitationFromRow(row: CounterpartItemDto) { + const target = invitationTargetFromConnectionRow(row, currentOrganization!.id); + if (!target) return; + toast.setError(''); + try { + await copyInvitationLink( + { + id: target.id, + organizationName: row.organizationName, + ownerEmail: target.ownerEmail, + status: target.status, + createdAt: row.createdAt, + acceptedAt: target.acceptedAt, + }, + { + onRegenerated: async () => { + await loadList(); + }, + }, + ); + toast.showSuccess(t('successLinkCopied')); + } catch (e) { + toast.showError(formatApiMessage(e)); + } + } + + async function respondToPendingConnection(connectionId: string, action: 'ACCEPT' | 'REJECT') { + setPendingConnectionRowId(connectionId); + toast.setError(''); + try { + await organizationApi.respondToConnectionRequest(connectionId, action); + toast.showSuccess( + action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), + ); + notifyPendingConnectionsChanged(); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setPendingConnectionRowId(null); + } + } + + async function deleteConnection(connectionId: string) { + setDeleteConnectionRowId(connectionId); + toast.setError(''); + try { + await organizationApi.deleteConnection(connectionId); + toast.showSuccess(t('successRemoved')); + await loadList(); + } catch (e) { + toast.showError(formatApiMessage(e)); + } finally { + setDeleteConnectionRowId(null); + } + } + + function clearSearchView() { + setMode('existing'); + setQuery(''); + setSearchResults([]); + setShowInviteForm(false); + } + + if (!currentOrganization) { + return

{t('loadingOrganization')}

; + } + + if (caseHistoryConnection) { + return ( + setCaseHistoryConnection(null)} + /> + ); + } + + return ( +
+
+
+

{tabLabel}

+

{t('subtitle')}

+
+ +
+ + {!historyOpen && } + + + {t('backToList')} + + ) : undefined + } + /> + + invitationTargetFromConnectionRow(row, currentOrganization.id)} + onCopyInvitation={(row) => void handleCopyInvitationFromRow(row)} + onRespond={(rowId, action) => void respondToPendingConnection(rowId, action)} + onViewCaseHistory={setCaseHistoryConnection} + onDeleteConnection={(rowId) => void deleteConnection(rowId)} + onSendConnectionRequest={(orgId) => void submitConnectionRequest(orgId)} + onToggleInviteForm={() => setShowInviteForm((v) => !v)} + onManualOrganizationNameChange={setManualOrganizationName} + onManualOwnerEmailChange={setManualOwnerEmail} + onSendInvite={() => void sendInvite()} + labels={{ + loading: tCommon('loadingEllipsis'), + emptyConnections: t('emptyConnections'), + noDirectoryResults: t('noDirectoryResults'), + hideInvitationFields: t('hideInvitationFields'), + sendInvitationLink: t('sendInvitationLink'), + counterpartNameLabel: t('counterpartNameLabel', { counterpart }), + ownerEmailLabel: t('ownerEmailLabel'), + sendInvitation: t('sendInvitation'), + sendRequest: t('sendRequest'), + acceptRequest: t('acceptRequest'), + declineRequest: t('declineRequest'), + viewCaseHistory: t('viewCaseHistory'), + removeConnection: t('removeConnection'), + statusToday: t('statusToday'), + statusFound: t('statusFound'), + }} + /> + +
+
+ Invoice ID + + Patient name + + Date + + Service + + Total amount + + Paid + + Status + + Action +
{invoice.id}{invoice.patient}{invoice.date}{invoice.service}${invoice.amount}${invoice.paid} + + {invoice.status} + + + +
+ + + + + + + } + body={ + <> + {loading || (mode === 'search' && searching) ? ( + + + + ) : mode === 'existing' ? ( + existingRows.length === 0 ? ( + + + + ) : ( + existingRows.map((row) => { + const canRespond = + row.status === 'PENDING' && + row.requestedByOrganizationId !== null && + row.requestedByOrganizationId !== currentOrganization.id; + const invitationTarget = invitationTargetFromConnectionRow( + row, + currentOrganization.id, + ); + + return ( + + + + + + + + ); + }) + ) + ) : searchResults.length > 0 ? ( + searchResults.map((r) => ( + + + + + + + + )) + ) : ( + + + + )} + + } + /> + + + setHistoryOpen(false)} + loading={historyLoading} + items={historyItems} + copiedId={copiedId} + copyingInvitationId={copyingInvitationId} + onCopy={(invitation) => void handleHistoryCopy(invitation)} + toastMessages={toast.messages} + /> + + ); +} diff --git a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx index 93d86c1..b1825ee 100644 --- a/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx +++ b/frontend/src/components/ui/patient/PatientAppointmentHistory.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { formatTimeForInput } from '@/components/appointments/appointmentTime'; -import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; import { patientsApi } from '@/lib/api/patients'; diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx new file mode 100644 index 0000000..132e425 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientsPage.tsx @@ -0,0 +1,165 @@ +'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 { getUserFacingError } 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'; +import { PatientAppointmentHistory } from '@/components/ui/patient/PatientAppointmentHistory'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + mobile: '', + email: '', +}; + +export function PatientsPage() { + const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); + 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(getUserFacingError(error, tErrors, 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); + if (response.existing) { + toast.showInfo( + t('patientAlreadyExists', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } else { + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, 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} + /> + )} + +
+
+ +
+ +
+ + {selectedPatient ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/ui/settings/AccountSettingsPage.tsx b/frontend/src/components/ui/settings/AccountSettingsPage.tsx new file mode 100644 index 0000000..244392e --- /dev/null +++ b/frontend/src/components/ui/settings/AccountSettingsPage.tsx @@ -0,0 +1,452 @@ +'use client'; + +import { useCallback, useEffect, 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, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; +import { ChevronDown, Lock } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import { accountApi } from '@/lib/api/account'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { Toast } from '@/components/ui/shared/Toast'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { OwnerWorkingHoursDialog } from '@/components/ui/settings/OwnerWorkingHoursDialog'; + +type PasswordForm = { + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; + +export function AccountSettingsPage() { + const t = useTranslations('settings'); + const tErrors = useTranslations('errors'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { user, currentOrganization, isAuthReady, refreshSession } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isResetFlow = searchParams.get('reset') === '1'; + + const isOwner = Boolean(currentOrganization?.isOwner); + const orgType = currentOrganization?.type; + const showClinicParticipation = isOwner && orgType === 'CLINIC'; + const showLabParticipation = isOwner && orgType === 'LAB'; + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [passwordExpanded, setPasswordExpanded] = useState(isResetFlow); + + const [participationLoading, setParticipationLoading] = useState(false); + const [participatesInTreatments, setParticipatesInTreatments] = useState(false); + const [participatesInTasks, setParticipatesInTasks] = useState(false); + const [workingHoursOpen, setWorkingHoursOpen] = useState(false); + const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false); + const [pendingRevokeType, setPendingRevokeType] = useState<'CLINIC' | 'LAB' | null>(null); + + const passwordSchema = useMemo( + () => + z + .object({ + currentPassword: z.string(), + newPassword: z + .string() + .min(8, tValidation('passwordMinLength')) + .regex(/[A-Z]/, tValidation('passwordUppercase')) + .regex(/[0-9]/, tValidation('passwordNumber')), + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: tValidation('passwordsDoNotMatch'), + path: ['confirmPassword'], + }) + .superRefine((data, ctx) => { + if (!isResetFlow && !data.currentPassword.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tValidation('passwordRequired'), + path: ['currentPassword'], + }); + } + }), + [isResetFlow, tValidation], + ); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }); + + const loadParticipation = useCallback(async () => { + if (!isOwner) return; + try { + const res = await accountApi.getParticipation(); + setParticipatesInTreatments(res.data.participatesInTreatments); + setParticipatesInTasks(res.data.participatesInTasks); + } catch { + /* non-owners or missing org context */ + } + }, [isOwner]); + + useEffect(() => { + if (isAuthReady && !user) { + router.replace('/login'); + } + }, [isAuthReady, user, router]); + + useEffect(() => { + if (isResetFlow) { + setPasswordExpanded(true); + } + }, [isResetFlow]); + + useEffect(() => { + void loadParticipation(); + }, [loadParticipation, currentOrganization?.id]); + + const syncSessionAfterParticipationChange = useCallback(async () => { + await refreshSession(); + }, [refreshSession]); + + const enableClinicParticipation = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + await accountApi.updateParticipation(true); + if (!options.skipHours && options.hoursPayload) { + await accountApi.upsertMyWorkingHours(options.hoursPayload); + } + setParticipatesInTreatments(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTreatments')); + }, + [syncSessionAfterParticipationChange, t], + ); + + const enableLabParticipation = async () => { + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(true); + setParticipatesInTasks(true); + await syncSessionAfterParticipationChange(); + setSuccessMessage(t('participateEnabledTasks')); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + setParticipatesInTasks(false); + } finally { + setParticipationLoading(false); + } + }; + + const confirmRevokeParticipation = async () => { + if (!pendingRevokeType) return; + setParticipationLoading(true); + setError(null); + try { + await accountApi.updateParticipation(false); + if (pendingRevokeType === 'CLINIC') { + setParticipatesInTreatments(false); + } else { + setParticipatesInTasks(false); + } + await syncSessionAfterParticipationChange(); + setSuccessMessage( + pendingRevokeType === 'CLINIC' + ? t('participateDisabledTreatments') + : t('participateDisabledTasks'), + ); + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + } finally { + setParticipationLoading(false); + } + }; + + const handleClinicParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + setWorkingHoursOpen(true); + return; + } + setPendingRevokeType('CLINIC'); + setRevokeConfirmOpen(true); + }; + + const handleWorkingHoursClose = useCallback(() => { + setWorkingHoursOpen(false); + }, []); + + const handleWorkingHoursComplete = useCallback( + async (options: { + skipHours: boolean; + hoursPayload?: { + autoRepeatWeekly: boolean; + blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[]; + }; + }) => { + setParticipationLoading(true); + setError(null); + try { + await enableClinicParticipation(options); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); + throw err; + } finally { + setParticipationLoading(false); + } + }, + [enableClinicParticipation, t], + ); + + const handleLabParticipationChange = (checked: boolean) => { + setError(null); + if (checked) { + void enableLabParticipation(); + return; + } + setPendingRevokeType('LAB'); + setRevokeConfirmOpen(true); + }; + + const onSubmit = async (data: PasswordForm) => { + try { + setError(null); + setSuccessMessage(null); + setIsSubmitting(true); + + await authApi.changePassword({ + ...(isResetFlow ? {} : { currentPassword: data.currentPassword }), + newPassword: data.newPassword, + }); + + reset(); + setSuccessMessage(t('passwordChanged')); + router.replace('/login'); + } catch (err: unknown) { + setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); + } finally { + setIsSubmitting(false); + } + }; + + const passwordToggleLabels = { + show: tAuth('showPassword'), + hide: tAuth('hidePassword'), + }; + + if (!isAuthReady || !user) { + return ( +

{tCommon('loadingEllipsis')}

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

{t('accountTitle')}

+

{t('accountSubtitle')}

+
+ + {(showClinicParticipation || showLabParticipation) && ( +
+
+

{t('participationSectionTitle')}

+

{t('participationSectionSubtitle')}

+
+ + {showClinicParticipation && ( + + )} + + {showLabParticipation && ( + + )} +
+ )} + +
+ + + {passwordExpanded && ( +
+

+ {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} +

+ {isResetFlow && ( +

{t('resetPasswordSubtitle')}

+ )} + +
+ {!isResetFlow && ( + } + passwordToggleLabels={passwordToggleLabels} + /> + )} + + } + passwordToggleLabels={passwordToggleLabels} + /> + + } + passwordToggleLabels={passwordToggleLabels} + /> + + {error && ( +
+

{error}

+
+ )} + + + +
+ )} +
+ + {error && !passwordExpanded && ( +
+

{error}

+
+ )} + + + + {revokeConfirmOpen && ( +
+
+
+

+ {t('participateConfirmRevokeTitle')} +

+ { + if (participationLoading) return; + setRevokeConfirmOpen(false); + setPendingRevokeType(null); + }} + /> +
+

+ {pendingRevokeType === 'CLINIC' + ? t('participateConfirmRevokeBodyTreatments') + : t('participateConfirmRevokeBodyTasks')} +

+
+ + +
+
+
+ )} + + {successMessage && ( + {successMessage} + )} +
+ ); +} diff --git a/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx b/frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx similarity index 98% rename from frontend/src/components/settings/OwnerWorkingHoursDialog.tsx rename to frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx index 537076b..6105d1b 100644 --- a/frontend/src/components/settings/OwnerWorkingHoursDialog.tsx +++ b/frontend/src/components/ui/settings/OwnerWorkingHoursDialog.tsx @@ -10,7 +10,7 @@ import { useWorkingHoursForm, workingHoursPayloadFromState, workingHoursStateFromApi, -} from '@/components/staff/StaffWorkingHoursStep'; +} from '@/components/ui/staff/StaffWorkingHoursStep'; import { accountApi } from '@/lib/api/account'; type OwnerWorkingHoursDialogProps = { diff --git a/frontend/src/components/ui/settings/SubscriptionsPage.tsx b/frontend/src/components/ui/settings/SubscriptionsPage.tsx new file mode 100644 index 0000000..a6f9cb1 --- /dev/null +++ b/frontend/src/components/ui/settings/SubscriptionsPage.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useEffect, useState } from 'react'; +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'; +import { Toast } from '@/components/ui/shared/Toast'; +import type { SubscriptionAlertData } from '@/types/subscription'; + +const PLAN_OPTIONS = [ + { 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 function SubscriptionsPage() { + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const router = useRouter(); + const [alert, setAlert] = useState(null); + const [selectedPlanId, setSelectedPlanId] = useState(PLAN_OPTIONS[0].id); + const [purchaseNotice, setPurchaseNotice] = useState(null); + + useEffect(() => { + if (currentOrganization && !currentOrganization.isOwner) { + router.replace('/today'); + } + }, [currentOrganization, router]); + + useEffect(() => { + if (!currentOrganization?.isOwner) return; + void authApi.getSubscriptionAlert().then((r) => { + if (r.success) setAlert(r.data); + }); + }, [currentOrganization?.id, currentOrganization?.isOwner]); + + if (!currentOrganization) { + return ( +

{tCommon('loadingEllipsis')}

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

{tCommon('redirecting')}

+ ); + } + + const plan = currentOrganization.plan; + const hasActiveSubscription = Boolean(plan); + const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId); + const maxUsers = plan?.maxUsers; + const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; + const seatsUsed = alert?.seatsUsed; + const seatsRemaining = + typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited + ? Math.max(0, maxUsers - seatsUsed) + : null; + const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null; + const planDayTone = + daysUntilPlanEnd == null + ? 'text-text-primary' + : daysUntilPlanEnd > 20 + ? 'text-emerald-400' + : daysUntilPlanEnd >= 10 + ? 'text-amber-300' + : 'text-red-400'; + + return ( +
+
+ + {tCommon('backToApp')} + +

{t('subscriptionsTitle')}

+

+ {t('subscriptionsSubtitle', { orgName: currentOrganization.name })} +

+
+ +
+ {!hasActiveSubscription && ( +
+

{t('noSubscriptionNotice')}

+
+ )} + +
+
+

{t('currentPlan')}

+

+ {plan?.name ?? '—'} +

+
+
+

{t('planPrice')}

+

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

+
+
+

{t('seatsUsed')}

+

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

+
+
+

{t('seatsRemaining')}

+

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

+
+
+

{t('daysRemaining')}

+

+ {daysUntilPlanEnd ?? '—'} +

+
+
+ + {alert?.showWarning && ( +
+ {alert.noActiveSubscription && ( +

{t('noActiveSubscription')}

+ )} + {alert.trialExpired && ( +

{t('trialEnded')}

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

+ {t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })} +

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

{t('seatsLow')}

+ )} +
+ )} + +
+

{t('choosePlanIntro')}

+
+ {PLAN_OPTIONS.map((option) => { + const selected = selectedPlanId === option.id; + return ( + + ); + })} +
+ +
+
+ + {purchaseNotice && ( +
+
+ {purchaseNotice} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/shared/Badge.tsx b/frontend/src/components/ui/shared/Badge.tsx index 23077f9..04268f3 100644 --- a/frontend/src/components/ui/shared/Badge.tsx +++ b/frontend/src/components/ui/shared/Badge.tsx @@ -57,18 +57,3 @@ export function Badge({ ); } - -/** Map organization connection / invitation row status to badge variant. */ -export function organizationConnectionStatusVariant(status: string): BadgeVariant { - switch (status) { - case 'ACTIVE': - return 'success'; - case 'PENDING': - return 'warning'; - case 'REJECTED': - case 'EXPIRED': - return 'danger'; - default: - return 'default'; - } -} diff --git a/frontend/src/components/staff/StaffMembersMobileList.tsx b/frontend/src/components/ui/staff/StaffMembersMobileList.tsx similarity index 100% rename from frontend/src/components/staff/StaffMembersMobileList.tsx rename to frontend/src/components/ui/staff/StaffMembersMobileList.tsx diff --git a/frontend/src/components/ui/staff/StaffPage.tsx b/frontend/src/components/ui/staff/StaffPage.tsx new file mode 100644 index 0000000..b8b92d2 --- /dev/null +++ b/frontend/src/components/ui/staff/StaffPage.tsx @@ -0,0 +1,1122 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; +import { + firstAccessibleDashboardPath, + canEditStaff, + canViewStaff, +} from '@/components/shared/permissions'; +import { + permissionNamesFromFeatureState, + emptyFeaturePermissionState, + featureStateFromPermissionNames, + featureStateHasTreatmentEdit, + resolveStaffFeatureLabel, + formatAccessSummary, + staffFeatureGroupsForOrgType, + type FeaturePermState, +} from '@/components/staff/staff-permission-form'; +import { + StaffWorkingHoursStep, + createDefaultWorkingHoursState, + workingHoursPayloadFromState, + workingHoursStateFromApi, +} from '@/components/ui/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'; +import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; +import { Button } from '@/components/ui/shared/Button'; +import { Badge } from '@/components/ui/shared/Badge'; +import { Input } from '@/components/ui/shared/Input'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { Table } from '@/components/ui/shared/Table'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList'; +import { useToast } from '@/lib/hooks/useToast'; + +type StoredInviteLink = { + membershipId: string; + email: string; + invitationUrl: string; +}; + +function inviteLinksStorageKey(orgId: string): string { + return `staffInviteLinks:${orgId}`; +} + +function readStoredInviteLinks(orgId: string): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeStoredInviteLinks(orgId: string, links: Record) { + if (typeof window === 'undefined') return; + window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); +} + +function canShareStaffInviteLink(member: StaffMemberDto): boolean { + return ( + !member.isOwner && + (member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED') + ); +} + +function canDisableStaff(member: StaffMemberDto): boolean { + return !member.isOwner && member.isActive; +} + +function canEnableStaff(member: StaffMemberDto): boolean { + return !member.isOwner && member.invitationStatus === 'DISABLED'; +} + +function PermissionGrid({ + state, + onChange, + disabled, + organizationType, +}: { + state: FeaturePermState; + onChange: (next: FeaturePermState) => void; + 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({ + ...state, + [editKey]: { read, edit: read ? cur.edit : false }, + }); + }; + + const setEdit = (editKey: string, edit: boolean) => { + const cur = state[editKey] ?? { read: false, edit: false }; + onChange({ + ...state, + [editKey]: { read: edit || cur.read, edit }, + }); + }; + + return ( +
+ {staffFeatureGroupsForOrgType(organizationType).map((g) => { + const cell = state[g.edit] ?? { read: false, edit: false }; + return ( +
+ + {resolveStaffFeatureLabel(g, organizationType, tFeatures)} + +
+ setRead(g.edit, v)} + /> + setEdit(g.edit, v)} + /> +
+
+ ); + })} +
+ ); +} + +export function StaffPage() { + const router = useRouter(); + const t = useTranslations('staff'); + const tErrors = useTranslations('errors'); + 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<{ + used: number; + limit: number | null; + unlimited: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + 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); + const [lastInviteInfo, setLastInviteInfo] = useState<{ + membershipId: string; + name: string; + email: string; + invitationUrl: string | null; + invitationStatus: 'PENDING' | 'ACCEPTED'; + } | null>(null); + 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); + const [enableTarget, setEnableTarget] = useState(null); + const [enablingMembershipId, setEnablingMembershipId] = useState(null); + + const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); + const inviteHasTreatmentEdit = useMemo( + () => + currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms), + [currentOrganization?.type, invitePerms], + ); + const editHasTreatmentEdit = useMemo( + () => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms), + [currentOrganization?.type, editPerms], + ); + const hasActivePlan = Boolean(currentOrganization?.plan); + const atSeatLimit = useMemo(() => { + if (!seats || seats.unlimited) return false; + if (seats.limit == null) return false; + return seats.used >= seats.limit; + }, [seats]); + + const hasAvailableSeat = useMemo(() => { + if (!seats || seats.unlimited) return true; + if (seats.limit == null) return true; + return seats.used < seats.limit; + }, [seats]); + + const load = useCallback(async () => { + toast.setError(''); + setLoading(true); + try { + const res = await staffApi.list(); + setMembers(res.data.members); + setSeats(res.data.seats); + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff'))); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + if (!currentOrganization?.id) return; + setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); + }, [currentOrganization?.id]); + + useEffect(() => { + if (!currentOrganization?.id || loading) return; + + const activeMemberIds = new Set( + members + .filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE') + .map((m) => m.id), + ); + + let changed = false; + const nextLinks: Record = { ...pendingInviteLinks }; + for (const memberId of Object.keys(nextLinks)) { + if (activeMemberIds.has(memberId)) { + delete nextLinks[memberId]; + changed = true; + } + } + if (!changed) return; + + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + }, [currentOrganization?.id, loading, members, pendingInviteLinks]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!currentOrganization) return; + if (!canViewStaff(currentOrganization)) { + router.replace(firstAccessibleDashboardPath(currentOrganization)); + } + }, [currentOrganization, router]); + + async function copyStaffInviteLink(member: StaffMemberDto) { + if (!canShareStaffInviteLink(member)) return; + + setCopyingInviteMembershipId(member.id); + toast.setError(''); + try { + let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; + if (!invitationUrl || member.invitationStatus === 'EXPIRED') { + const res = await staffApi.getInvitationLink(member.id); + invitationUrl = res.data.invitationUrl; + if (currentOrganization?.id) { + const nextLinks = { + ...pendingInviteLinks, + [member.id]: { + membershipId: member.id, + email: member.email, + invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } + } + await navigator.clipboard.writeText(invitationUrl); + setCopiedInviteMembershipId(member.id); + setTimeout(() => setCopiedInviteMembershipId(null), 1500); + if (member.invitationStatus === 'EXPIRED') { + await load(); + } + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); + } finally { + setCopyingInviteMembershipId(null); + } + } + + function resetInviteForm() { + setInviteStep(1); + setInviteEmail(''); + setInviteName(''); + setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type)); + 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, + email: res.data.email, + invitationUrl: res.data.invitationUrl, + invitationStatus: res.data.invitationStatus, + }); + if (currentOrganization?.id && res.data.invitationUrl) { + const nextLinks = { + ...pendingInviteLinks, + [res.data.membershipId]: { + membershipId: res.data.membershipId, + email: res.data.email, + invitationUrl: res.data.invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } + setInviteOpen(false); + resetInviteForm(); + await load(); + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite'))); + } finally { + setInviteLoading(false); + } + } + + async function openEdit(m: StaffMemberDto) { + if (m.isOwner) return; + setEditing(m); + setEditStep(1); + setEditName(m.name); + setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type)); + 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(getUserFacingError(e, tErrors, 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(t('successMemberUpdated')); + setEditing(null); + setEditStep(1); + await load(); + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember'))); + } finally { + setEditLoading(false); + } + } + + function handleDeleteMember() { + toast.showError(t('errorDeleteNotImplemented')); + } + + async function confirmDisableMember() { + if (!disableTarget || !canDisableStaff(disableTarget)) return; + + setDisablingMembershipId(disableTarget.id); + toast.setError(''); + try { + await staffApi.disableMember(disableTarget.id); + toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); + setDisableTarget(null); + await load(); + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember'))); + } finally { + setDisablingMembershipId(null); + } + } + + async function confirmEnableMember() { + if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return; + + setEnablingMembershipId(enableTarget.id); + toast.setError(''); + try { + await staffApi.enableMember(enableTarget.id); + toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name })); + setEnableTarget(null); + await load(); + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember'))); + } finally { + setEnablingMembershipId(null); + } + } + + if (!currentOrganization || !canViewStaff(currentOrganization)) { + return ( +

{t('redirecting')}

+ ); + } + + return ( +
+
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+ + + + {seats && ( +

+ {t('seatsLabel')}{' '} + + {seats.used} + {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} + + {!seats.unlimited && atSeatLimit && ( + + {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} + + )} +

+ )} + + {lastInviteInfo && ( +
+ +

+ {t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} + {lastInviteInfo.invitationStatus === 'PENDING' + ? ` ${t('invitedPending')}` + : ` ${t('invitedAccepted')}`} +

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

+ {t('inviteLinkHeading')} +

+ {lastInviteInfo.invitationUrl && ( + + {lastInviteInfo.invitationUrl} + + )} + +

{t('shareLinkHint')}

+
+ )} +
+ )} + + {loading ? ( +

{t('loadingTeam')}

+ ) : ( + <> + + formatAccessSummary(member.permissions, currentOrganization?.type, tFeatures) + } + canShareInviteLink={canShareStaffInviteLink} + canEnable={canEnableStaff} + canDisable={canDisableStaff} + onCopyInviteLink={(member) => void copyStaffInviteLink(member)} + onEnable={setEnableTarget} + onDisable={setDisableTarget} + onEdit={openEdit} + onDelete={() => handleDeleteMember()} + labels={{ + roleOwner: t('roleOwner'), + roleStaff: t('roleStaff'), + statusActive: t('statusActive'), + statusPending: t('statusPending'), + statusDisabled: t('statusDisabled'), + statusExpired: t('statusExpired'), + allFeatures: t('allFeatures'), + copyInviteLink: t('copyInviteLinkTitle'), + enableMemberTitle: t('enableMemberTitle'), + disableMemberTitle: t('disableMemberTitle'), + editMemberAria: t('editMemberAria'), + deleteMemberAria: t('deleteMemberAria'), + }} + /> +
+
+ {t('tableOrganization')} + + {t('tableOwnerEmail')} + + {t('tableDate')} + + {t('tableStatus')} + + {t('tableAction')} +
+ {tCommon('loadingEllipsis')} +
+ {t('emptyConnections')} +
+ {row.organizationName} + {row.ownerEmail} + {formatTableDate(row.createdAt)} + + + {formatConnectionStatusLabel(row, currentOrganization.id)} + + +
+ {invitationTarget && ( + void handleCopyInvitationFromRow(row)} + /> + )} + {canRespond && ( + <> + + + + )} + {row.status === 'ACTIVE' && ( + <> + + + + )} +
+
{r.name}{r.owner.email}{t('statusToday')} + {t('statusFound')} + + +
+
+

+ {t('noDirectoryResults')} +

+
+ +
+ {showInviteForm && ( +
+ setManualOrganizationName(e.target.value)} + /> + setManualOwnerEmail(e.target.value)} + /> +
+ +
+
+ )} +
+
+ + + + + + + + } + body={ + <> + {members.map((m) => ( + + + + + + + + + ))} + + } + /> + + + )} + + {inviteOpen && ( +
+
+
+
+

+ {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 ? ( + + ) : ( + + ) + ) : ( + <> + + + + )} +
+
+
+ )} + + {enableTarget && ( +
+
+
+

+ {t('enableModalTitle')} +

+ { + if (enablingMembershipId) return; + setEnableTarget(null); + }} + /> +
+

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

+
    +
  • {t('enableBullet1')}
  • +
  • {t('enableBullet2')}
  • +
  • {t('enableBullet3')}
  • +
+ {!hasAvailableSeat && ( +

{t('noSeatsAvailable')}

+ )} +
+ + +
+
+
+ )} + + {disableTarget && ( +
+
+
+

+ {t('disableModalTitle')} +

+ { + if (disablingMembershipId) return; + setDisableTarget(null); + }} + /> +
+

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

+
    +
  • {t('disableBullet1')}
  • +
  • {t('disableBullet2')}
  • +
  • {t('disableBullet3')}
  • +
+
+ + +
+
+
+ )} + + {editing && ( +
+
+
+
+

{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/components/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx similarity index 97% rename from frontend/src/components/staff/StaffWorkingHoursStep.tsx rename to frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx index 524176e..297119b 100644 --- a/frontend/src/components/staff/StaffWorkingHoursStep.tsx +++ b/frontend/src/components/ui/staff/StaffWorkingHoursStep.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; +import { WorkingHoursEditor } from '@/components/ui/staff/WorkingHoursEditor'; import { blocksFromEditorDays, editorDaysFromBlocks, diff --git a/frontend/src/components/staff/WorkingHoursEditor.tsx b/frontend/src/components/ui/staff/WorkingHoursEditor.tsx similarity index 100% rename from frontend/src/components/staff/WorkingHoursEditor.tsx rename to frontend/src/components/ui/staff/WorkingHoursEditor.tsx diff --git a/frontend/src/components/today/ChartCard.tsx b/frontend/src/components/ui/today/ChartCard.tsx similarity index 97% rename from frontend/src/components/today/ChartCard.tsx rename to frontend/src/components/ui/today/ChartCard.tsx index aa7d6e1..ba7dd83 100644 --- a/frontend/src/components/today/ChartCard.tsx +++ b/frontend/src/components/ui/today/ChartCard.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react'; import { Card } from '@/components/ui/shared/Card'; -import { ChartCardSkeleton } from '@/components/today/TodaySkeleton'; +import { ChartCardSkeleton } from '@/components/ui/today/TodaySkeleton'; interface ChartCardProps { title: string; diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/ui/today/KpiCard.tsx similarity index 100% rename from frontend/src/components/today/KpiCard.tsx rename to frontend/src/components/ui/today/KpiCard.tsx diff --git a/frontend/src/components/today/TodayAreaChart.tsx b/frontend/src/components/ui/today/TodayAreaChart.tsx similarity index 96% rename from frontend/src/components/today/TodayAreaChart.tsx rename to frontend/src/components/ui/today/TodayAreaChart.tsx index f3d7822..bf8d448 100644 --- a/frontend/src/components/today/TodayAreaChart.tsx +++ b/frontend/src/components/ui/today/TodayAreaChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { TODAY_CHART_AXIS_COLOR, diff --git a/frontend/src/components/today/TodayBarChart.tsx b/frontend/src/components/ui/today/TodayBarChart.tsx similarity index 97% rename from frontend/src/components/today/TodayBarChart.tsx rename to frontend/src/components/ui/today/TodayBarChart.tsx index db701e6..d7dbf7b 100644 --- a/frontend/src/components/today/TodayBarChart.tsx +++ b/frontend/src/components/ui/today/TodayBarChart.tsx @@ -11,7 +11,7 @@ import { YAxis, } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COLORS, diff --git a/frontend/src/components/today/TodayChartFrame.tsx b/frontend/src/components/ui/today/TodayChartFrame.tsx similarity index 100% rename from frontend/src/components/today/TodayChartFrame.tsx rename to frontend/src/components/ui/today/TodayChartFrame.tsx diff --git a/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx b/frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx similarity index 95% rename from frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx rename to frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx index 243f696..8f425a6 100644 --- a/frontend/src/components/today/TodayCompletionGaugeKpiCard.tsx +++ b/frontend/src/components/ui/today/TodayCompletionGaugeKpiCard.tsx @@ -4,7 +4,7 @@ import type { LucideIcon } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme'; -import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import { TodayRadialGaugeChart } from '@/components/ui/today/TodayRadialGaugeChart'; import type { TodayCompletionGauge } from '@/types/today'; export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge { diff --git a/frontend/src/components/today/TodayDashboard.tsx b/frontend/src/components/ui/today/TodayDashboard.tsx similarity index 94% rename from frontend/src/components/today/TodayDashboard.tsx rename to frontend/src/components/ui/today/TodayDashboard.tsx index 52423b8..c953b34 100644 --- a/frontend/src/components/today/TodayDashboard.tsx +++ b/frontend/src/components/ui/today/TodayDashboard.tsx @@ -13,38 +13,38 @@ import { canViewTasks, canViewTreatment, } from '@/components/shared/permissions'; -import { KpiCard } from '@/components/today/KpiCard'; -import { ChartCard } from '@/components/today/ChartCard'; -import { TodayAreaChart } from '@/components/today/TodayAreaChart'; -import { TodayBarChart } from '@/components/today/TodayBarChart'; +import { KpiCard } from '@/components/ui/today/KpiCard'; +import { ChartCard } from '@/components/ui/today/ChartCard'; +import { TodayAreaChart } from '@/components/ui/today/TodayAreaChart'; +import { TodayBarChart } from '@/components/ui/today/TodayBarChart'; import { mapWeekChartBuckets, useTodayDayLabelFormatter, } from '@/components/today/chart-day-labels'; -import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid'; -import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart'; -import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart'; -import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart'; +import { TodayDashboardGrid } from '@/components/ui/today/TodayDashboardGrid'; +import { TodayDonutChart, TodayDonutChartLegend } from '@/components/ui/today/TodayDonutChart'; +import { TodayHorizontalBarChart } from '@/components/ui/today/TodayHorizontalBarChart'; +import { TodayPartnerCasesStackedBarChart } from '@/components/ui/today/TodayPartnerCasesStackedBarChart'; import { Package, Stethoscope, type LucideIcon } from 'lucide-react'; -import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard'; +import { TodayCompletionGaugeKpiCard } from '@/components/ui/today/TodayCompletionGaugeKpiCard'; import { mapLabTaskActivityChartData, TodayLabTaskActivityChart, -} from '@/components/today/TodayLabTaskActivityChart'; -import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard'; -import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments'; +} from '@/components/ui/today/TodayLabTaskActivityChart'; +import { TodaySubscriptionKpiCard } from '@/components/ui/today/TodaySubscriptionKpiCard'; +import { TodayUpcomingAppointments } from '@/components/ui/today/TodayUpcomingAppointments'; import { ChartCardSkeleton, KpiCardSkeleton, ListRowSkeleton, -} from '@/components/today/TodaySkeleton'; +} from '@/components/ui/today/TodaySkeleton'; import { TODAY_DASHBOARD_LAYOUT, type TodayDashboardCell, } from '@/components/today/today-dashboard-layout'; import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; -import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import type { TodayCompletionGauge, TodaySubscriptionSnapshot, diff --git a/frontend/src/components/today/TodayDashboardGrid.tsx b/frontend/src/components/ui/today/TodayDashboardGrid.tsx similarity index 100% rename from frontend/src/components/today/TodayDashboardGrid.tsx rename to frontend/src/components/ui/today/TodayDashboardGrid.tsx diff --git a/frontend/src/components/today/TodayDonutChart.tsx b/frontend/src/components/ui/today/TodayDonutChart.tsx similarity index 98% rename from frontend/src/components/today/TodayDonutChart.tsx rename to frontend/src/components/ui/today/TodayDonutChart.tsx index 302cbf3..4b67315 100644 --- a/frontend/src/components/today/TodayDonutChart.tsx +++ b/frontend/src/components/ui/today/TodayDonutChart.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from 'react'; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import type { TodayChartBucket } from '@/types/today'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { chartRankColor, TODAY_CHART_TOOLTIP_STYLE, diff --git a/frontend/src/components/today/TodayHorizontalBarChart.tsx b/frontend/src/components/ui/today/TodayHorizontalBarChart.tsx similarity index 96% rename from frontend/src/components/today/TodayHorizontalBarChart.tsx rename to frontend/src/components/ui/today/TodayHorizontalBarChart.tsx index 8e70812..a8334fd 100644 --- a/frontend/src/components/today/TodayHorizontalBarChart.tsx +++ b/frontend/src/components/ui/today/TodayHorizontalBarChart.tsx @@ -10,7 +10,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import type { TodayChartBucket } from '@/types/today'; import { chartRankColor, diff --git a/frontend/src/components/today/TodayLabTaskActivityChart.tsx b/frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx similarity index 98% rename from frontend/src/components/today/TodayLabTaskActivityChart.tsx rename to frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx index b76f9d7..2c5d9d2 100644 --- a/frontend/src/components/today/TodayLabTaskActivityChart.tsx +++ b/frontend/src/components/ui/today/TodayLabTaskActivityChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COMPLETED_COLOR, diff --git a/frontend/src/components/today/TodayLoadErrorBanner.tsx b/frontend/src/components/ui/today/TodayLoadErrorBanner.tsx similarity index 100% rename from frontend/src/components/today/TodayLoadErrorBanner.tsx rename to frontend/src/components/ui/today/TodayLoadErrorBanner.tsx diff --git a/frontend/src/components/ui/today/TodayPage.tsx b/frontend/src/components/ui/today/TodayPage.tsx new file mode 100644 index 0000000..da0d6c2 --- /dev/null +++ b/frontend/src/components/ui/today/TodayPage.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { TodayDashboard } from '@/components/ui/today/TodayDashboard'; +import { TodayLoadErrorBanner } from '@/components/ui/today/TodayLoadErrorBanner'; +import { TodaySectionErrorFallback } from '@/components/ui/today/TodaySectionErrorFallback'; +import { TodayWidgetErrorBoundary } from '@/components/ui/today/TodayWidgetErrorBoundary'; +import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; + +export function TodayPage() { + const t = useTranslations('today'); + const tErrors = useTranslations('errors'); + const { currentOrganization } = useAuth(); + const orgId = currentOrganization?.id; + const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); + + const showNoSubscriptionNotice = useMemo( + () => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan, + [currentOrganization], + ); + + const sectionErrorMessage = t('sectionLoadError'); + + return ( +
+
+

{t('welcomeBack')}

+ {data?.generatedAt && !isInitialLoad ? ( +

+ {t('lastUpdated', { + time: new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }).format(new Date(data.generatedAt)), + })} +

+ ) : null} +
+ + {showNoSubscriptionNotice && ( +
+

+ {t('noSubscriptionNotice')}{' '} + + {t('choosePlanLink')} + {' '} + {t('noSubscriptionCta')} +

+
+ )} + + {error ? ( + void reload()} + isRetrying={loading && Boolean(data)} + /> + ) : null} + + } + > + + +
+ ); +} diff --git a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx b/frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx similarity index 97% rename from frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx rename to frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx index 230e010..59ff12c 100644 --- a/frontend/src/components/today/TodayPartnerCasesStackedBarChart.tsx +++ b/frontend/src/components/ui/today/TodayPartnerCasesStackedBarChart.tsx @@ -9,7 +9,7 @@ import { XAxis, YAxis, } from 'recharts'; -import { TodayChartFrame } from '@/components/today/TodayChartFrame'; +import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame'; import { TODAY_CHART_AXIS_COLOR, TODAY_CHART_COMPLETED_COLOR, diff --git a/frontend/src/components/today/TodayRadialGaugeChart.tsx b/frontend/src/components/ui/today/TodayRadialGaugeChart.tsx similarity index 100% rename from frontend/src/components/today/TodayRadialGaugeChart.tsx rename to frontend/src/components/ui/today/TodayRadialGaugeChart.tsx diff --git a/frontend/src/components/today/TodaySectionErrorFallback.tsx b/frontend/src/components/ui/today/TodaySectionErrorFallback.tsx similarity index 100% rename from frontend/src/components/today/TodaySectionErrorFallback.tsx rename to frontend/src/components/ui/today/TodaySectionErrorFallback.tsx diff --git a/frontend/src/components/today/TodaySkeleton.tsx b/frontend/src/components/ui/today/TodaySkeleton.tsx similarity index 100% rename from frontend/src/components/today/TodaySkeleton.tsx rename to frontend/src/components/ui/today/TodaySkeleton.tsx diff --git a/frontend/src/components/today/TodaySubscriptionKpiCard.tsx b/frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx similarity index 97% rename from frontend/src/components/today/TodaySubscriptionKpiCard.tsx rename to frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx index ca0fce1..d60a330 100644 --- a/frontend/src/components/today/TodaySubscriptionKpiCard.tsx +++ b/frontend/src/components/ui/today/TodaySubscriptionKpiCard.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl'; import { CreditCard } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; -import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart'; +import { TodayRadialGaugeChart } from '@/components/ui/today/TodayRadialGaugeChart'; import type { TodaySubscriptionSnapshot } from '@/types/today'; interface TodaySubscriptionKpiCardProps { diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/ui/today/TodayUpcomingAppointments.tsx similarity index 95% rename from frontend/src/components/today/TodayUpcomingAppointments.tsx rename to frontend/src/components/ui/today/TodayUpcomingAppointments.tsx index 0f92981..42c228e 100644 --- a/frontend/src/components/today/TodayUpcomingAppointments.tsx +++ b/frontend/src/components/ui/today/TodayUpcomingAppointments.tsx @@ -6,13 +6,13 @@ import { ChevronRight } from 'lucide-react'; import { Link } from '@/i18n/navigation'; import { Card } from '@/components/ui/shared/Card'; import { formatTimeForInput } from '@/components/appointments/appointmentTime'; -import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { purposeLabel } from '@/components/appointments/appointmentPurposeStyles'; import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import { ListRowSkeleton } from '@/components/today/TodaySkeleton'; +import { ListRowSkeleton } from '@/components/ui/today/TodaySkeleton'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TodaySummaryActions } from '@/types/today'; diff --git a/frontend/src/components/today/TodayWidgetErrorBoundary.tsx b/frontend/src/components/ui/today/TodayWidgetErrorBoundary.tsx similarity index 100% rename from frontend/src/components/today/TodayWidgetErrorBoundary.tsx rename to frontend/src/components/ui/today/TodayWidgetErrorBoundary.tsx diff --git a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx index 6473585..0faec15 100644 --- a/frontend/src/components/ui/treatment/AppointmentsStrip.tsx +++ b/frontend/src/components/ui/treatment/AppointmentsStrip.tsx @@ -8,7 +8,7 @@ import { startOfLocalDay } from '@/components/appointments/appointmentTime'; import { treatmentTypeBannerStyle, treatmentTypeLabelFromCatalog, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { TreatmentAppointment } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx index ddff044..4d4e8c2 100644 --- a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx +++ b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; -import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/ui/treatment/treatmentStatusStyles'; +import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; interface DetailLabSendBadgeProps { diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 215ac78..2595104 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -5,11 +5,11 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Dropdown } from '@/components/ui/shared/Dropdown'; -import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import { treatmentsApi } from '@/lib/api/treatments'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; diff --git a/frontend/src/components/ui/treatment/ToothGlyph.tsx b/frontend/src/components/ui/treatment/ToothGlyph.tsx index bf8ae17..2e0f7be 100644 --- a/frontend/src/components/ui/treatment/ToothGlyph.tsx +++ b/frontend/src/components/ui/treatment/ToothGlyph.tsx @@ -3,7 +3,7 @@ import { memo, type ReactNode } from 'react'; import type { FdiToothId } from '@/types/treatment'; import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta'; -import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel'; +import { getToothPathModel, type ToothPathModel } from '@/components/treatment/toothPathModel'; interface ToothGlyphProps { fdi: FdiToothId; diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx index 1b617ac..e016801 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx @@ -3,7 +3,7 @@ import { useTranslations } from 'next-intl'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 085e565..f7a33bb 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -8,10 +8,10 @@ import { autosaveStatusClass, labPendingBannerClass, labSentBannerClass, -} from '@/components/ui/treatment/treatmentStatusStyles'; +} from '@/components/treatment/treatmentStatusStyles'; import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; interface TreatmentDetailsEditorProps { details: TreatmentDetailDraft[]; diff --git a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx index 606190b..540c7ad 100644 --- a/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx +++ b/frontend/src/components/ui/treatment/TreatmentHistoryDetailLine.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; -import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { PastTreatmentDetail } from '@/types/treatment'; diff --git a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx index 61b5b2a..213dcc5 100644 --- a/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx +++ b/frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx @@ -3,7 +3,7 @@ import { formatCodeAsLabel, treatmentTypeBannerStyle, -} from '@/components/ui/treatment/treatmentTypeDisplay'; +} from '@/components/shared/treatmentTypeDisplay'; interface TreatmentTypeBadgeProps { type: string; diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index f8fc2c1..7ccdc3b 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -10,7 +10,7 @@ import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPan import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; import { ToastStack } from '@/components/ui/shared/Toast'; -import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay'; +import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { addCalendarDays, compareLocalDayStart, -- 2.53.0.windows.1 From 540a33972322afff47b855a5ebff80237136363f Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 21:16:38 +0330 Subject: [PATCH 07/24] improvement: today toogle added to ScheduleDatePicker component. --- frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + .../ui/shared/ScheduleDayPicker.tsx | 33 ++++++++++++++++--- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f0d67e6..cd85421 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -826,6 +826,7 @@ "previousDay": "Previous day", "nextDay": "Next day", "chooseDate": "Choose schedule date", + "today": "Today", "year": "Year", "month": "Month", "day": "Day", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 5c56946..aa79c80 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -827,6 +827,7 @@ "previousDay": "روز قبل", "nextDay": "روز بعد", "chooseDate": "انتخاب تاریخ برنامه", + "today": "امروز", "year": "سال", "month": "ماه", "day": "روز", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 8abac2d..e5759bd 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -827,6 +827,7 @@ "previousDay": "Vorige dag", "nextDay": "Volgende dag", "chooseDate": "Kies roosterdatum", + "today": "Vandaag", "year": "Jaar", "month": "Maand", "day": "Dag", diff --git a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx index 2316703..09f67ef 100644 --- a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx @@ -3,12 +3,15 @@ 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'; +import { addCalendarDays, compareLocalDayStart, startOfLocalDay } from '@/components/appointments/appointmentTime'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; interface ScheduleDayPickerProps { value: Date; onChange: (day: Date) => void; label?: string; + /** Show a "Today" toggle that jumps to the current local day when enabled. Default true. */ + showTodayToggle?: boolean; } const MONTH_KEYS = [ @@ -56,13 +59,20 @@ 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 }: ScheduleDayPickerProps) { +export function ScheduleDayPicker({ + value, + onChange, + label, + showTodayToggle = true, +}: ScheduleDayPickerProps) { const t = useTranslations('schedule'); const panelId = useId(); const rootRef = useRef(null); const [panelOpen, setPanelOpen] = useState(false); const normalizedValue = startOfLocalDay(value); + const today = startOfLocalDay(new Date()); + const isTodaySelected = compareLocalDayStart(normalizedValue, today) === 0; const resolvedLabel = label ?? t('defaultLabel'); const labelText = normalizedValue.toLocaleDateString(undefined, { @@ -111,7 +121,22 @@ export function ScheduleDayPicker({ value, onChange, label }: ScheduleDayPickerP return (
-

{resolvedLabel}

+
+

{resolvedLabel}

+ {showTodayToggle ? ( + { + if (checked) { + onChange(today); + setPanelOpen(false); + } + }} + label={t('today')} + className="shrink-0" + /> + ) : null} +
-
+ + ); } diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index 6dc3a7e..e138f53 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -17,7 +17,6 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { useToast } from '@/lib/hooks/useToast'; import type { AppointmentPurpose } from '@/types/appointment'; import { getUserFacingError } from '@/components/shared/formatApiError'; @@ -286,8 +285,6 @@ export function AppointmentsPage() {

{t('subtitle')}

- -
-
); } diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index d154d18..005f8f3 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { MessageSquare } from 'lucide-react'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; @@ -397,7 +396,6 @@ export function TasksPage() { )} - ); } diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index 16f4832..af0de87 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -12,7 +12,6 @@ import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentType import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { CaseDetailPanel, CaseTaskProgressBar } from '@/components/ui/lab/CaseDetailPanel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { @@ -210,8 +209,6 @@ export function ConnectionCaseHistoryContent({

- -
void; - /** Same page-level toasts, rendered at top of dialog while it is open. */ - toastMessages?: ToastMessages; }; export function InvitationHistoryDialog({ @@ -40,7 +37,6 @@ export function InvitationHistoryDialog({ copiedId, copyingInvitationId, onCopy, - toastMessages, }: InvitationHistoryDialogProps) { const t = useTranslations('organizations'); @@ -72,8 +68,6 @@ export function InvitationHistoryDialog({
- {toastMessages && } - {loading ? (

{t('loadingHistory')}

) : items.length === 0 ? ( diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx index 6272ccc..3ddbfb2 100644 --- a/frontend/src/components/ui/organizations/OrganizationsPage.tsx +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -24,7 +24,6 @@ import { organizationConnectionStatusVariant } from '@/components/organizations/ import { Input } from '@/components/ui/shared/Input'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { getUserFacingError } from '@/components/shared/formatApiError'; function formatOrganizationStatusLabel(status: string): string { @@ -327,8 +326,6 @@ export function OrganizationsPage() { - {!historyOpen && } - void handleHistoryCopy(invitation)} - toastMessages={toast.messages} /> ); diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx index 132e425..cf14834 100644 --- a/frontend/src/components/ui/patient/PatientsPage.tsx +++ b/frontend/src/components/ui/patient/PatientsPage.tsx @@ -3,7 +3,6 @@ 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 { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -125,8 +124,6 @@ export function PatientsPage() { - - {isCreateOpen && ( , string> = { - top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none', - bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none', + top: 'fixed top-4 left-0 right-0 z-[100] px-4 pointer-events-none', + bottom: + 'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none', }; /** @@ -87,7 +91,7 @@ export function ToastViewport({ return (
-
{stack}
+
{stack}
); } diff --git a/frontend/src/components/ui/shared/ToastProvider.tsx b/frontend/src/components/ui/shared/ToastProvider.tsx new file mode 100644 index 0000000..d98e8d0 --- /dev/null +++ b/frontend/src/components/ui/shared/ToastProvider.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { + ToastStack, + type ToastMessages, + type ToastViewportPosition, +} from '@/components/ui/shared/Toast'; + +const DEFAULT_DURATION_MS = 4000; + +export type UseToastOptions = { + successMs?: number; + errorMs?: number; + infoMs?: number; + defaultMs?: number; +}; + +export type ToastContextValue = { + error: string; + success: string; + info: string; + defaultMessage: string; + setError: (message: string) => void; + setSuccess: (message: string) => void; + setInfo: (message: string) => void; + setDefaultMessage: (message: string) => void; + showError: (message: string) => void; + showSuccess: (message: string) => void; + showInfo: (message: string) => void; + showDefault: (message: string) => void; + clear: () => void; + messages: ToastMessages; +}; + +const ToastContext = createContext(null); + +function useToastState(options: UseToastOptions = {}): ToastContextValue { + const successMs = options.successMs ?? DEFAULT_DURATION_MS; + const errorMs = options.errorMs ?? DEFAULT_DURATION_MS; + const infoMs = options.infoMs ?? DEFAULT_DURATION_MS; + const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS; + + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [info, setInfo] = useState(''); + const [defaultMessage, setDefaultMessage] = useState(''); + + useEffect(() => { + if (!success) return; + const id = setTimeout(() => setSuccess(''), successMs); + return () => clearTimeout(id); + }, [success, successMs]); + + useEffect(() => { + if (!error) return; + const id = setTimeout(() => setError(''), errorMs); + return () => clearTimeout(id); + }, [error, errorMs]); + + useEffect(() => { + if (!info) return; + const id = setTimeout(() => setInfo(''), infoMs); + return () => clearTimeout(id); + }, [info, infoMs]); + + useEffect(() => { + if (!defaultMessage) return; + const id = setTimeout(() => setDefaultMessage(''), defaultMs); + return () => clearTimeout(id); + }, [defaultMessage, defaultMs]); + + const clear = useCallback(() => { + setError(''); + setSuccess(''); + setInfo(''); + setDefaultMessage(''); + }, []); + + const showError = useCallback((message: string) => { + setSuccess(''); + setInfo(''); + setDefaultMessage(''); + setError(message); + }, []); + + const showSuccess = useCallback((message: string) => { + setError(''); + setInfo(''); + setDefaultMessage(''); + setSuccess(message); + }, []); + + const showInfo = useCallback((message: string) => { + setError(''); + setSuccess(''); + setDefaultMessage(''); + setInfo(message); + }, []); + + const showDefault = useCallback((message: string) => { + setError(''); + setSuccess(''); + setInfo(''); + setDefaultMessage(message); + }, []); + + const messages: ToastMessages = useMemo( + () => ({ error, success, info, default: defaultMessage }), + [error, success, info, defaultMessage], + ); + + return useMemo( + () => ({ + error, + success, + info, + defaultMessage, + setError, + setSuccess, + setInfo, + setDefaultMessage, + showError, + showSuccess, + showInfo, + showDefault, + clear, + messages, + }), + [ + error, + success, + info, + defaultMessage, + showError, + showSuccess, + showInfo, + showDefault, + clear, + messages, + ], + ); +} + +type ToastProviderProps = { + children: ReactNode; + /** Fixed viewport position. Default `bottom` — visible when scrolled and above dialogs. */ + position?: Exclude; +}; + +function GlobalToastHost({ + messages, + position, +}: { + messages: ToastMessages; + position: Exclude; +}) { + const hasMessage = Boolean( + messages.error || messages.success || messages.info || messages.default, + ); + + if (!hasMessage) { + return null; + } + + const positionClass = + position === 'bottom' + ? 'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none' + : 'fixed top-4 left-0 right-0 z-[100] px-4 pt-[max(1rem,env(safe-area-inset-top))] pointer-events-none'; + + return createPortal( +
+
+ +
+
, + document.body, + ); +} + +export function ToastProvider({ children, position = 'bottom' }: ToastProviderProps) { + const toast = useToastState(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + {children} + {mounted ? : null} + + ); +} + +/** Global app toasts — requires `` ancestor (dashboard layout). */ +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within ToastProvider'); + } + return context; +} diff --git a/frontend/src/components/ui/staff/StaffPage.tsx b/frontend/src/components/ui/staff/StaffPage.tsx index b8b92d2..0e19b7b 100644 --- a/frontend/src/components/ui/staff/StaffPage.tsx +++ b/frontend/src/components/ui/staff/StaffPage.tsx @@ -34,7 +34,6 @@ import { Badge } from '@/components/ui/shared/Badge'; import { Input } from '@/components/ui/shared/Input'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Table } from '@/components/ui/shared/Table'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { StaffMembersMobileList } from '@/components/ui/staff/StaffMembersMobileList'; import { useToast } from '@/lib/hooks/useToast'; @@ -523,8 +522,6 @@ export function StaffPage() { - - {seats && (

{t('seatsLabel')}{' '} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 7ccdc3b..c2b1827 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -9,7 +9,6 @@ import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatc import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel'; import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor'; import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard'; -import { ToastStack } from '@/components/ui/shared/Toast'; import { treatmentTypeLabelFromCatalog, treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { addCalendarDays, @@ -1139,8 +1138,6 @@ export function TreatmentWorkspace({

- - setStripHidden((s) => !s)} diff --git a/frontend/src/lib/hooks/useToast.ts b/frontend/src/lib/hooks/useToast.ts index def906c..0601094 100644 --- a/frontend/src/lib/hooks/useToast.ts +++ b/frontend/src/lib/hooks/useToast.ts @@ -1,103 +1,2 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import type { ToastMessages } from '@/components/ui/shared/Toast'; - -const DEFAULT_DURATION_MS = 4000; - -export type UseToastOptions = { - successMs?: number; - errorMs?: number; - infoMs?: number; - defaultMs?: number; -}; - -export function useToast(options: UseToastOptions = {}) { - const successMs = options.successMs ?? DEFAULT_DURATION_MS; - const errorMs = options.errorMs ?? DEFAULT_DURATION_MS; - const infoMs = options.infoMs ?? DEFAULT_DURATION_MS; - const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS; - - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const [info, setInfo] = useState(''); - const [defaultMessage, setDefaultMessage] = useState(''); - - useEffect(() => { - if (!success) return; - const id = setTimeout(() => setSuccess(''), successMs); - return () => clearTimeout(id); - }, [success, successMs]); - - useEffect(() => { - if (!error) return; - const id = setTimeout(() => setError(''), errorMs); - return () => clearTimeout(id); - }, [error, errorMs]); - - useEffect(() => { - if (!info) return; - const id = setTimeout(() => setInfo(''), infoMs); - return () => clearTimeout(id); - }, [info, infoMs]); - - useEffect(() => { - if (!defaultMessage) return; - const id = setTimeout(() => setDefaultMessage(''), defaultMs); - return () => clearTimeout(id); - }, [defaultMessage, defaultMs]); - - const clear = useCallback(() => { - setError(''); - setSuccess(''); - setInfo(''); - setDefaultMessage(''); - }, []); - - const showError = useCallback((message: string) => { - setSuccess(''); - setInfo(''); - setDefaultMessage(''); - setError(message); - }, []); - - const showSuccess = useCallback((message: string) => { - setError(''); - setInfo(''); - setDefaultMessage(''); - setSuccess(message); - }, []); - - const showInfo = useCallback((message: string) => { - setError(''); - setSuccess(''); - setDefaultMessage(''); - setInfo(message); - }, []); - - const showDefault = useCallback((message: string) => { - setError(''); - setSuccess(''); - setInfo(''); - setDefaultMessage(message); - }, []); - - const messages: ToastMessages = { error, success, info, default: defaultMessage }; - - return { - error, - success, - info, - defaultMessage, - setError, - setSuccess, - setInfo, - setDefaultMessage, - showError, - showSuccess, - showInfo, - showDefault, - clear, - messages, - }; -} +export { ToastProvider, useToast } from '@/components/ui/shared/ToastProvider'; +export type { ToastContextValue, UseToastOptions } from '@/components/ui/shared/ToastProvider'; -- 2.53.0.windows.1 From abf0371a5b62383b0aa41497f5eb49f022808dc3 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 22:07:11 +0330 Subject: [PATCH 09/24] improvement: appointment dialog UX improved. --- backend/src/common/errors/error-codes.ts | 1 + .../appointments/appointments.service.ts | 21 +- .../patients/dto/create-patient.dto.ts | 9 + frontend/messages/en.json | 4 + frontend/messages/fa.json | 4 + frontend/messages/nl.json | 4 + .../appointments/appointmentTime.ts | 19 + .../appointments/AppointmentBookingModal.tsx | 640 +++++++++-------- .../appointments/AppointmentScheduleGrid.tsx | 10 +- .../ui/appointments/AppointmentsPage.tsx | 658 ++++++++---------- .../AppointmentsPatientSearch.tsx | 94 --- .../ui/patient/CreatePatientModal.tsx | 29 +- .../ui/patient/PatientSearchCombobox.tsx | 136 ++++ .../components/ui/patient/PatientsPage.tsx | 13 + .../components/ui/shared/TimeStepInput.tsx | 69 ++ frontend/src/lib/api/patients.ts | 7 +- .../src/lib/hooks/usePatientSearchQuery.ts | 45 ++ frontend/src/types/appointment.ts | 2 + 18 files changed, 1006 insertions(+), 759 deletions(-) delete mode 100644 frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx create mode 100644 frontend/src/components/ui/patient/PatientSearchCombobox.tsx create mode 100644 frontend/src/components/ui/shared/TimeStepInput.tsx create mode 100644 frontend/src/lib/hooks/usePatientSearchQuery.ts diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b3ed3fc..d1413d5 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -61,6 +61,7 @@ export const ErrorCode = { NOT_FOUND: 'NOT_FOUND', CONFLICT: 'CONFLICT', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', + APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', BAD_REQUEST: 'BAD_REQUEST', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 5b3e3c8..7af2525 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -104,11 +104,18 @@ export class AppointmentsService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true }, }, + treatment: { select: { id: true } }, }, orderBy: [{ startAt: 'asc' }], }); - return { success: true, data: items }; + return { + success: true, + data: items.map(({ treatment, ...appointment }) => ({ + ...appointment, + hasTreatment: Boolean(treatment), + })), + }; } async create( @@ -202,6 +209,18 @@ export class AppointmentsService { const providerUserId = dto.providerUserId ?? existing.providerUserId; const purpose = dto.purpose ?? existing.purpose; + if (dto.patientId && dto.patientId !== existing.patientId) { + const linkedTreatment = await this.prisma.treatment.findUnique({ + where: { appointmentId: id }, + select: { id: true }, + }); + if (linkedTreatment) { + throw new BadRequestException( + 'Cannot change the patient while a treatment is linked to this appointment', + ); + } + } + this.treatmentCatalog.assertKnownTreatmentType(purpose); await this.ensurePatientInOrg(patientId, organizationId); diff --git a/backend/src/modules/patients/dto/create-patient.dto.ts b/backend/src/modules/patients/dto/create-patient.dto.ts index 21a2ae7..ec14ba0 100644 --- a/backend/src/modules/patients/dto/create-patient.dto.ts +++ b/backend/src/modules/patients/dto/create-patient.dto.ts @@ -1,6 +1,14 @@ +import { Transform } from 'class-transformer'; import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { ErrorCode } from '../../../common/errors'; +function emptyStringToUndefined({ value }: { value: unknown }): unknown { + if (typeof value === 'string' && value.trim() === '') { + return undefined; + } + return value; +} + export class CreatePatientDto { @IsString() @MinLength(1, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) @@ -18,6 +26,7 @@ export class CreatePatientDto { mobile: string; @IsOptional() + @Transform(emptyStringToUndefined) @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email?: string; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cd85421..c9bd7c3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -546,6 +546,9 @@ "endLabel": "End", "purposeLabel": "Purpose", "errorSelectPatient": "Select a patient first.", + "patientSearchPlaceholder": "Search patients by name, phone, or email", + "patientSearchEmpty": "No patients match this search.", + "patientLockedHint": "Patient cannot be changed because a treatment is linked to this appointment.", "errorEndAfterStart": "End time must be after start time.", "errorPastSchedule": "Cannot schedule in the past.", "errorPastViewOnly": "Past appointments are view-only.", @@ -901,6 +904,7 @@ "NOT_FOUND": "The requested item was not found.", "CONFLICT": "This action conflicts with existing data.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", + "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index aa79c80..2d81f21 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -547,6 +547,9 @@ "endLabel": "پایان", "purposeLabel": "هدف", "errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.", + "patientSearchPlaceholder": "جستجوی بیمار بر اساس نام، تلفن یا ایمیل", + "patientSearchEmpty": "بیماری با این جستجو یافت نشد.", + "patientLockedHint": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان تغییر بیمار وجود ندارد.", "errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.", "errorPastSchedule": "نمی‌توان در گذشته زمان‌بندی کرد.", "errorPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", @@ -902,6 +905,7 @@ "NOT_FOUND": "مورد درخواستی یافت نشد.", "CONFLICT": "این عمل با داده‌های موجود در تضاد است.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", + "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index e5759bd..a4d98ed 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -547,6 +547,9 @@ "endLabel": "Einde", "purposeLabel": "Doel", "errorSelectPatient": "Selecteer eerst een patiënt.", + "patientSearchPlaceholder": "Zoek patiënten op naam, telefoon of e-mail", + "patientSearchEmpty": "Geen patiënten gevonden voor deze zoekopdracht.", + "patientLockedHint": "Patiënt kan niet worden gewijzigd omdat er een behandeling aan deze afspraak is gekoppeld.", "errorEndAfterStart": "Eindtijd moet na de starttijd liggen.", "errorPastSchedule": "Kan niet in het verleden plannen.", "errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", @@ -902,6 +905,7 @@ "NOT_FOUND": "Het gevraagde item is niet gevonden.", "CONFLICT": "Deze actie conflicteert met bestaande gegevens.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", + "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/appointments/appointmentTime.ts b/frontend/src/components/appointments/appointmentTime.ts index 19abbe7..5010c6f 100644 --- a/frontend/src/components/appointments/appointmentTime.ts +++ b/frontend/src/components/appointments/appointmentTime.ts @@ -61,3 +61,22 @@ export function compareLocalDayStart(a: Date, b: Date): number { const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime(); return ta - tb; } + +export function parseTimeInputToMinutes(time: string): number { + const [h, m] = time.split(':').map(Number); + if (!Number.isFinite(h) || !Number.isFinite(m)) { + return 0; + } + return h * 60 + m; +} + +export function minutesToTimeInput(minutes: number): string { + const clamped = Math.max(0, Math.min(24 * 60 - 1, minutes)); + const h = Math.floor(clamped / 60); + const m = clamped % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +export function adjustTimeInput(time: string, deltaMinutes: number): string { + return minutesToTimeInput(parseTimeInputToMinutes(time) + deltaMinutes); +} diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index d122b7a..27d4fcd 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,286 +1,354 @@ -'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 { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog'; -import { Dropdown } from '@/components/ui/shared/Dropdown'; -import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { - DROPDOWN_OPTION_BG, - treatmentTypeColor, -} from '@/components/shared/treatmentTypeDisplay'; -import type { Patient } from '@/types/patient'; -import { - combineLocalDateAndTime, - compareLocalDayStart, - formatTimeForInput, - isSameLocalCalendarDay, -} from '@/components/appointments/appointmentTime'; - -interface AppointmentBookingModalProps { - open: boolean; - scheduleDate: Date; - patient: Patient | undefined; - providerUserId: string | null; - providerName: string; - initialStartMinute: number; - onClose: () => void; - onSubmit: (payload: { - patientId: string; - providerUserId: string; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) => Promise; - treatmentCatalog: TreatmentCatalogEntry[]; - editingAppointment?: AppointmentRecord | null; - loading?: boolean; - canDelete?: boolean; - onDelete?: () => void | Promise; - deleting?: boolean; -} - -export function AppointmentBookingModal({ - open, - scheduleDate, - patient, - providerUserId, - providerName, - initialStartMinute, - onClose, - onSubmit, - treatmentCatalog, - editingAppointment = null, - loading = false, - canDelete = false, - onDelete, - deleting = false, -}: AppointmentBookingModalProps) { - const t = useTranslations('appointments'); - const tCommon = useTranslations('common'); - const tPatients = useTranslations('patients'); - - const defaultPurpose = treatmentCatalog[0]?.code ?? ''; - - const [startTime, setStartTime] = useState('09:00'); - const [endTime, setEndTime] = useState('10:00'); - const [purpose, setPurpose] = useState(defaultPurpose); - const [error, setError] = useState(''); - const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); - const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); - - useEffect(() => { - if (!open) { - return; - } - if (editingAppointment) { - const start = new Date(editingAppointment.startAt); - const end = new Date(editingAppointment.endAt); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose(editingAppointment.purpose || defaultPurpose); - } else { - const start = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - 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(), - Math.floor(endMinute / 60), - endMinute % 60, - 0, - 0, - ); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose(defaultPurpose); - } - setError(''); - }, [open, scheduleDate, initialStartMinute, editingAppointment, defaultPurpose]); - - if (!open || !providerUserId) { - return null; - } - - const inputClass = - 'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35'; - - async function handleSubmit() { - setError(''); - if (!providerUserId) { - return; - } - if (!editingAppointment && !patient) { - setError(t('errorSelectPatient')); - return; - } - - const startAt = combineLocalDateAndTime(scheduleDate, startTime); - const endAt = combineLocalDateAndTime(scheduleDate, endTime); - - if (endAt <= startAt) { - setError(t('errorEndAfterStart')); - return; - } - - const now = new Date(); - if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { - setError(t('errorPastSchedule')); - return; - } - - const today = new Date(); - if (compareLocalDayStart(scheduleDate, today) < 0) { - setError(t('errorPastViewOnly')); - return; - } - - const effectivePatientId = editingAppointment?.patientId ?? patient?.id; - const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId; - if (!effectivePatientId || !effectiveProviderId) { - setError(t('errorMissingDetails')); - return; - } - - await onSubmit({ - patientId: effectivePatientId, - providerUserId: effectiveProviderId, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - purpose, - }); - } - - return ( - - -
-

- {editingAppointment ? t('editTitle') : t('newTitle')} -

- -
- -

- {t('providerLabel')}{' '} - {providerName} -

- -
- -

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

-
- -
-
- - setStartTime(e.target.value)} - /> -
-
- - setEndTime(e.target.value)} - /> -
-
- - setPurpose(e.target.value)} - style={{ color: purposeTextColor }} - > - {treatmentCatalog.map((entry, index) => ( - - ))} - - - {error &&

{error}

} - -
- {editingAppointment && canDelete && onDelete ? ( - - ) : null} -
- - -
-
-
-
- ); -} +'use client'; + +import { useEffect, useId, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { ResponsiveDialogOverlay, ResponsiveDialogPanel } from '@/components/ui/shared/ResponsiveDialog'; +import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { TimeStepInput } from '@/components/ui/shared/TimeStepInput'; +import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { + DROPDOWN_OPTION_BG, + treatmentTypeColor, +} from '@/components/shared/treatmentTypeDisplay'; +import type { Patient } from '@/types/patient'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { + combineLocalDateAndTime, + compareLocalDayStart, + formatTimeForInput, + isSameLocalCalendarDay, +} from '@/components/appointments/appointmentTime'; + +const DEFAULT_DURATION_MINUTES = 30; + +interface AppointmentBookingModalProps { + open: boolean; + scheduleDate: Date; + /** Pre-selected patient from the page sidebar (optional). */ + initialPatient?: Patient; + onPatientChange?: (patient: Patient | undefined) => void; + canAddPatient?: boolean; + onAddPatient?: () => void; + providerUserId: string | null; + providerName: string; + initialStartMinute: number; + onClose: () => void; + onSubmit: (payload: { + patientId: string; + providerUserId: string; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) => Promise; + treatmentCatalog: TreatmentCatalogEntry[]; + editingAppointment?: AppointmentRecord | null; + loading?: boolean; + canDelete?: boolean; + onDelete?: () => void | Promise; + deleting?: boolean; +} + +function patientFromRecord( + patient: AppointmentRecord['patient'], +): Pick { + return { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + mobile: patient.mobile, + }; +} + +export function AppointmentBookingModal({ + open, + scheduleDate, + initialPatient, + onPatientChange, + canAddPatient = false, + onAddPatient, + providerUserId, + providerName, + initialStartMinute, + onClose, + onSubmit, + treatmentCatalog, + editingAppointment = null, + loading = false, + canDelete = false, + onDelete, + deleting = false, +}: AppointmentBookingModalProps) { + const t = useTranslations('appointments'); + const tCommon = useTranslations('common'); + const startInputId = useId(); + const endInputId = useId(); + + const defaultPurpose = treatmentCatalog[0]?.code ?? ''; + + const [startTime, setStartTime] = useState('09:00'); + const [endTime, setEndTime] = useState('09:30'); + const [purpose, setPurpose] = useState(defaultPurpose); + const [selectedPatient, setSelectedPatient] = useState< + Pick | null + >(null); + const [error, setError] = useState(''); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(open); + + const patientLocked = Boolean(editingAppointment?.hasTreatment); + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === purpose); + const purposeTextColor = treatmentTypeColor(purpose, purposeIndex < 0 ? 0 : purposeIndex); + + useEffect(() => { + if (!open) { + return; + } + setSearch(''); + if (editingAppointment) { + const start = new Date(editingAppointment.startAt); + const end = new Date(editingAppointment.endAt); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose(editingAppointment.purpose || defaultPurpose); + setSelectedPatient(patientFromRecord(editingAppointment.patient)); + } else { + const start = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + Math.floor(initialStartMinute / 60), + initialStartMinute % 60, + 0, + 0, + ); + const endMinute = Math.min( + initialStartMinute + DEFAULT_DURATION_MINUTES, + 24 * 60 - 1, + ); + const end = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + Math.floor(endMinute / 60), + endMinute % 60, + 0, + 0, + ); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose(defaultPurpose); + setSelectedPatient( + initialPatient + ? { + id: initialPatient.id, + firstName: initialPatient.firstName, + lastName: initialPatient.lastName, + mobile: initialPatient.mobile, + } + : null, + ); + } + setError(''); + }, [ + open, + scheduleDate, + initialStartMinute, + editingAppointment?.id, + defaultPurpose, + initialPatient?.id, + setSearch, + ]); + + if (!open || !providerUserId) { + return null; + } + + function selectPatient(patient: Patient) { + if (patientLocked) { + return; + } + const next = { + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + mobile: patient.mobile, + }; + setSelectedPatient(next); + onPatientChange?.(patient); + if (error === t('errorSelectPatient')) { + setError(''); + } + } + + async function handleSubmit() { + setError(''); + if (!providerUserId) { + return; + } + if (!selectedPatient?.id) { + setError(t('errorSelectPatient')); + return; + } + + const startAt = combineLocalDateAndTime(scheduleDate, startTime); + const endAt = combineLocalDateAndTime(scheduleDate, endTime); + + if (endAt <= startAt) { + setError(t('errorEndAfterStart')); + return; + } + + const now = new Date(); + if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { + setError(t('errorPastSchedule')); + return; + } + + const today = new Date(); + if (compareLocalDayStart(scheduleDate, today) < 0) { + setError(t('errorPastViewOnly')); + return; + } + + await onSubmit({ + patientId: selectedPatient.id, + providerUserId, + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + purpose, + }); + } + + const selectedForDisplay = selectedPatient + ? ({ + ...selectedPatient, + isActive: true, + createdAt: '', + updatedAt: '', + } satisfies Patient) + : null; + + return ( + + +
+

+ {editingAppointment ? t('editTitle') : t('newTitle')} +

+ +
+ +

+ {t('providerLabel')}{' '} + {providerName} +

+ +
+ + + +
+ +
+ + +
+ + setPurpose(e.target.value)} + style={{ color: purposeTextColor }} + > + {treatmentCatalog.map((entry, index) => ( + + ))} + + + {error &&

{error}

} + +
+ {editingAppointment && canDelete && onDelete ? ( + + ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index becbe99..ba3b7cd 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -326,12 +326,12 @@ export function AppointmentScheduleGrid({ e.currentTarget, ) } - className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ + className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${ outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : '' } ${ isUnderOneHour ? 'items-center justify-center px-0.5 py-0' - : 'flex-col justify-start gap-0.5 px-1 py-0.5' + : 'flex-col items-center justify-center gap-0.5 px-1 py-0.5' }`} style={{ top: pos.top, @@ -343,19 +343,19 @@ export function AppointmentScheduleGrid({ title={bannerTitle} > {patientName} {!isUnderOneHour && apt.patient.mobile && lane.laneCount === 1 && ( - + {formatMobileForDisplay(apt.patient.mobile)} )} {!isUnderOneHour && clusterSize > 1 && ( - + {t('overlapping', { count: clusterSize })} )} diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index e138f53..89e9886 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -1,370 +1,288 @@ -'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 { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; -import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; -import type { CreatePatientInput, Patient } from '@/types/patient'; -import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; -import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; -import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; -import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; -import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; -import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; -import { useToast } from '@/lib/hooks/useToast'; -import type { AppointmentPurpose } from '@/types/appointment'; -import { getUserFacingError } from '@/components/shared/formatApiError'; -import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - mobile: '', - email: '', -}; - -export function AppointmentsPage() { - const t = useTranslations('appointments'); - const tErrors = useTranslations('errors'); - const tPatients = useTranslations('patients'); - const { currentOrganization } = useAuth(); - const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); - - const [providers, setProviders] = useState([]); - const [appointments, setAppointments] = useState([]); - const [treatmentCatalog, setTreatmentCatalog] = useState([]); - const [loadingSchedule, setLoadingSchedule] = useState(false); - 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 [bookingOpen, setBookingOpen] = useState(false); - const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); - const [bookingProviderId, setBookingProviderId] = useState(null); - const [bookingProviderName, setBookingProviderName] = useState(''); - const [editingAppointmentId, setEditingAppointmentId] = useState(null); - const [savingAppointment, setSavingAppointment] = useState(false); - const [deletingAppointment, setDeletingAppointment] = useState(false); - - - const canManageAppointments = canEditAppointments(currentOrganization); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const todayStart = useMemo(() => startOfLocalDay(new Date()), []); - const isViewingPastDay = useMemo( - () => compareLocalDayStart(scheduleDate, todayStart) < 0, - [scheduleDate, todayStart], - ); - const activeEditingAppointment = useMemo( - () => appointments.find((a) => a.id === editingAppointmentId) ?? null, - [appointments, editingAppointmentId], - ); - - const scheduleLoadGen = useRef(0); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - const loadSchedule = useCallback(async () => { - if (!currentOrganization?.id) { - return; - } - const gen = ++scheduleLoadGen.current; - setLoadingSchedule(true); - toast.setError(''); - try { - const range = getLocalDayIsoRange(scheduleDate); - const [pRes, aRes] = await Promise.all([ - appointmentsApi.columnProviders(scheduleDate), - appointmentsApi.list(range), - ]); - if (gen !== scheduleLoadGen.current) { - return; - } - setProviders(pRes.data); - setAppointments(aRes.data); - } catch (err: unknown) { - if (gen !== scheduleLoadGen.current) { - return; - } - toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); - } finally { - if (gen === scheduleLoadGen.current) { - setLoadingSchedule(false); - } - } - }, [currentOrganization?.id, scheduleDate, t]); - - useEffect(() => { - void loadSchedule(); - }, [loadSchedule]); - - useEffect(() => { - void treatmentCatalogApi - .list('appointment') - .then((r) => setTreatmentCatalog(r.data)) - .catch(() => {}); - }, []); - - useEffect(() => { - const t = setTimeout(() => { - void loadPatientsSearch(search); - }, 300); - return () => clearTimeout(t); - }, [search]); - - async function loadPatientsSearch(q: string) { - if (!currentOrganization) { - return; - } - setLoadingPatients(true); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - if (selectedPatient) { - const stillThere = items.find((p) => p.id === selectedPatient.id); - if (stillThere) { - setSelectedPatient(stillThere); - } - } - } catch { - setPatients([]); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatientsSearch(search); - setSelectedPatient(response.data); - if (response.existing) { - toast.showInfo( - tPatients('patientAlreadyExists', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } else { - toast.showSuccess( - t('successPatientSaved', { - firstName: response.data.firstName, - lastName: response.data.lastName, - }), - ); - } - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); - } finally { - setSavingPatient(false); - } - } - - function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - if (!selectedPatient) { - toast.showInfo(t('infoSelectPatient')); - return; - } - setBookingStartMinute(startMinute); - setBookingProviderId(providerUserId); - setBookingProviderName(providerName); - setEditingAppointmentId(null); - setBookingOpen(true); - } - - function handleAppointmentClick(appointment: AppointmentRecord) { - if (!canManageAppointments) { - return; - } - if (isViewingPastDay) { - toast.showInfo(t('infoPastViewOnly')); - return; - } - const provider = providers.find((p) => p.userId === appointment.providerUserId); - 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; - startAt: string; - endAt: string; - purpose: AppointmentPurpose; - }) { - setSavingAppointment(true); - toast.setError(''); - try { - if (activeEditingAppointment) { - await appointmentsApi.update(activeEditingAppointment.id, payload); - } else { - await appointmentsApi.create(payload); - } - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError( - getUserFacingError( - err, - tErrors, - activeEditingAppointment ? t('errorUpdate') : t('errorSave'), - ), - ); - } finally { - setSavingAppointment(false); - } - } - - async function handleDeleteEditingAppointment() { - if (!activeEditingAppointment) { - return; - } - if (!window.confirm(t('confirmRemove'))) { - return; - } - setDeletingAppointment(true); - toast.setError(''); - try { - await appointmentsApi.remove(activeEditingAppointment.id); - setBookingOpen(false); - setEditingAppointmentId(null); - toast.showSuccess(t('successRemoved')); - await loadSchedule(); - } catch (err: unknown) { - toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); - } finally { - setDeletingAppointment(false); - } - } - - return ( -
-
-

{t('title')}

-

{t('subtitle')}

-
- -
-
- { - if (!canEditPatients) { - return; - } - setPatientForm(EMPTY_PATIENT_FORM); - setIsCreateOpen(true); - }} - /> - -
- -
- - -
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- - handleSlotClick(startMinute, uid, name)} - onAppointmentClick={(apt) => handleAppointmentClick(apt)} - onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} - /> -
-
- - { - setBookingOpen(false); - setEditingAppointmentId(null); - }} - onSubmit={handleSaveAppointment} - loading={savingAppointment} - canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} - onDelete={() => void handleDeleteEditingAppointment()} - deleting={deletingAppointment} - /> - - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - -
- ); -} +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; +import { appointmentsApi } from '@/lib/api/appointments'; +import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; +import { canEditAppointments, hasPermission } from '@/components/shared/permissions'; +import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; +import type { Patient } from '@/types/patient'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal'; +import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid'; +import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; +import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; +import { useToast } from '@/lib/hooks/useToast'; +import type { AppointmentPurpose } from '@/types/appointment'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime'; + +export function AppointmentsPage() { + const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); + const router = useRouter(); + const { currentOrganization } = useAuth(); + const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); + + const [providers, setProviders] = useState([]); + const [appointments, setAppointments] = useState([]); + const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [loadingSchedule, setLoadingSchedule] = useState(false); + const toast = useToast(); + + const { search, setSearch, patients, loading: loadingPatients } = usePatientSearchQuery(); + const [selectedPatient, setSelectedPatient] = useState(); + + const [bookingOpen, setBookingOpen] = useState(false); + const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60); + const [bookingProviderId, setBookingProviderId] = useState(null); + const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); + const [savingAppointment, setSavingAppointment] = useState(false); + const [deletingAppointment, setDeletingAppointment] = useState(false); + + const canManageAppointments = canEditAppointments(currentOrganization); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); + + const scheduleLoadGen = useRef(0); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + const navigateToAddPatient = useCallback(() => { + if (!canEditPatients) { + return; + } + router.push('/patients?action=create'); + }, [canEditPatients, router]); + + const loadSchedule = useCallback(async () => { + if (!currentOrganization?.id) { + return; + } + const gen = ++scheduleLoadGen.current; + setLoadingSchedule(true); + toast.setError(''); + try { + const range = getLocalDayIsoRange(scheduleDate); + const [pRes, aRes] = await Promise.all([ + appointmentsApi.columnProviders(scheduleDate), + appointmentsApi.list(range), + ]); + if (gen !== scheduleLoadGen.current) { + return; + } + setProviders(pRes.data ?? []); + setAppointments(aRes.data ?? []); + } catch (err: unknown) { + if (gen !== scheduleLoadGen.current) { + return; + } + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); + } finally { + if (gen === scheduleLoadGen.current) { + setLoadingSchedule(false); + } + } + }, [currentOrganization?.id, scheduleDate, t, tErrors, toast]); + + useEffect(() => { + void loadSchedule(); + }, [loadSchedule]); + + useEffect(() => { + void treatmentCatalogApi + .list('appointment') + .then((r) => setTreatmentCatalog(r.data ?? [])) + .catch(() => {}); + }, []); + + function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + setBookingStartMinute(startMinute); + setBookingProviderId(providerUserId); + setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (!canManageAppointments) { + return; + } + if (isViewingPastDay) { + toast.showInfo(t('infoPastViewOnly')); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + 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; + startAt: string; + endAt: string; + purpose: AppointmentPurpose; + }) { + setSavingAppointment(true); + toast.setError(''); + try { + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); + } finally { + setSavingAppointment(false); + } + } + + async function handleDeleteEditingAppointment() { + if (!activeEditingAppointment) { + return; + } + if (!window.confirm(t('confirmRemove'))) { + return; + } + setDeletingAppointment(true); + toast.setError(''); + try { + await appointmentsApi.remove(activeEditingAppointment.id); + setBookingOpen(false); + setEditingAppointmentId(null); + toast.showSuccess(t('successRemoved')); + await loadSchedule(); + } catch (err: unknown) { + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); + } finally { + setDeletingAppointment(false); + } + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ +
+
+
+ +
+ +
+ +
+ + +
+ setScheduleDate(startOfLocalDay(d))} + /> + {loadingSchedule && ( +

{t('loadingSchedule')}

+ )} +
+ + handleSlotClick(startMinute, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} + onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)} + /> +
+
+ + { + setBookingOpen(false); + setEditingAppointmentId(null); + }} + onSubmit={handleSaveAppointment} + loading={savingAppointment} + canDelete={canManageAppointments && !isViewingPastDay && !!activeEditingAppointment} + onDelete={() => void handleDeleteEditingAppointment()} + deleting={deletingAppointment} + /> +
+ ); +} diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx deleted file mode 100644 index a238353..0000000 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'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 { formatMobileForDisplay } from '@/lib/phone'; -import type { Patient } from '@/types/patient'; - -interface AppointmentsPatientSearchProps { - search: string; - onSearchChange: (value: string) => void; - patients: Patient[]; - selectedPatientId?: string; - onSelectPatient: (patient: Patient) => void; - loading?: boolean; - canAddPatient: boolean; - onAddPatient: () => void; -} - -export function AppointmentsPatientSearch({ - search, - onSearchChange, - patients, - selectedPatientId, - onSelectPatient, - loading = false, - canAddPatient, - onAddPatient, -}: AppointmentsPatientSearchProps) { - const t = useTranslations('appointments'); - const tPatients = useTranslations('patients'); - - const trimmed = search.trim(); - const showAddForEmptyResults = - trimmed.length > 0 && !loading && patients.length === 0; - - return ( -
-
-
- onSearchChange(e.target.value)} - icon={} - /> -
- {showAddForEmptyResults && ( - - )} -
- -
- {loading &&

{t('searching')}

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

{t('searchHint')}

- )} - - {patients.map((patient) => { - const isSelected = selectedPatientId === patient.id; - return ( - - ); - })} -
-
- ); -} diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index edeebde..72a6406 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -85,6 +85,29 @@ function CreatePatientFormFields({ [formData.firstName, formData.lastName, formData.mobile], ); + const mobileDisplayError = useMemo(() => { + if (fieldErrors.mobile) { + return fieldErrors.mobile; + } + const raw = formData.mobile?.trim() ?? ''; + if (!raw) { + if (formData.firstName?.trim() && formData.lastName?.trim()) { + return tValidation('mobileRequired'); + } + return undefined; + } + if (!isValidMobile(normalizeMobile(formData.mobile) ?? '')) { + return tValidation('mobileInvalid'); + } + return undefined; + }, [ + fieldErrors.mobile, + formData.mobile, + formData.firstName, + formData.lastName, + tValidation, + ]); + return ( <>

{t('requiredFieldsHint')}

@@ -125,11 +148,13 @@ function CreatePatientFormFields({ }} placeholder={t('mobilePlaceholder')} required - error={fieldErrors.mobile} + error={mobileDisplayError} /> { onChange({ email: e.target.value }); diff --git a/frontend/src/components/ui/patient/PatientSearchCombobox.tsx b/frontend/src/components/ui/patient/PatientSearchCombobox.tsx new file mode 100644 index 0000000..ad25a28 --- /dev/null +++ b/frontend/src/components/ui/patient/PatientSearchCombobox.tsx @@ -0,0 +1,136 @@ +'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 { formatMobileForDisplay } from '@/lib/phone'; +import type { Patient } from '@/types/patient'; + +interface PatientSearchComboboxProps { + search: string; + onSearchChange: (value: string) => void; + patients: Patient[]; + loading?: boolean; + selectedPatient?: Patient | null; + onSelectPatient: (patient: Patient) => void; + /** When false, sidebar uses an external summary card instead. */ + showInlineSummary?: boolean; + canAddPatient?: boolean; + onAddPatient?: () => void; + placeholder?: string; + idleHint?: string; + emptyResultsMessage?: string; + noPermissionMessage?: string; + readOnly?: boolean; + readOnlyHint?: string; +} + +export function PatientSearchCombobox({ + search, + onSearchChange, + patients, + loading = false, + selectedPatient, + onSelectPatient, + showInlineSummary = false, + canAddPatient = false, + onAddPatient, + placeholder, + idleHint, + emptyResultsMessage, + noPermissionMessage, + readOnly = false, + readOnlyHint, +}: PatientSearchComboboxProps) { + const tPatients = useTranslations('patients'); + const trimmed = search.trim(); + const showResults = !readOnly && trimmed.length > 0; + + function handleSelect(patient: Patient) { + onSelectPatient(patient); + onSearchChange(''); + } + + if (readOnly) { + return ( +
+

+ {selectedPatient + ? `${selectedPatient.firstName} ${selectedPatient.lastName}` + : tPatients('emptyValue')} +

+ {readOnlyHint ?

{readOnlyHint}

: null} +
+ ); + } + + return ( +
+ onSearchChange(e.target.value)} + icon={} + /> + + {!trimmed && !selectedPatient && idleHint ? ( +

{idleHint}

+ ) : null} + + {showResults ? ( +
+ {loading ? ( +

{tPatients('loadingPatients')}

+ ) : patients.length === 0 ? ( +
+

+ {emptyResultsMessage ?? tPatients('noResults')} +

+ {canAddPatient && onAddPatient ? ( + + ) : noPermissionMessage ? ( +

{noPermissionMessage}

+ ) : null} +
+ ) : ( +
+ {patients.map((patient) => ( + + ))} +
+ )} +
+ ) : null} + + {showInlineSummary && selectedPatient ? ( +
+

+ {selectedPatient.firstName} {selectedPatient.lastName} +

+

+ {formatMobileForDisplay(selectedPatient.mobile) || + selectedPatient.email || + tPatients('noContact')} +

+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/patient/PatientsPage.tsx b/frontend/src/components/ui/patient/PatientsPage.tsx index cf14834..bf37645 100644 --- a/frontend/src/components/ui/patient/PatientsPage.tsx +++ b/frontend/src/components/ui/patient/PatientsPage.tsx @@ -1,7 +1,9 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; +import { useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/shared/Button'; import { patientsApi } from '@/lib/api/patients'; import { getUserFacingError } from '@/components/shared/formatApiError'; @@ -25,6 +27,8 @@ export function PatientsPage() { const t = useTranslations('patients'); const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const router = useRouter(); const { currentOrganization } = useAuth(); const toast = useToast(); const [search, setSearch] = useState(''); @@ -55,6 +59,15 @@ export function PatientsPage() { void loadPatients(''); }, []); + useEffect(() => { + if (searchParams.get('action') !== 'create' || !canEditPatients) { + return; + } + setPatientForm(EMPTY_PATIENT_FORM); + setIsCreateOpen(true); + router.replace('/patients'); + }, [searchParams, canEditPatients, router]); + async function loadPatients(q: string) { setLoadingPatients(true); toast.setError(''); diff --git a/frontend/src/components/ui/shared/TimeStepInput.tsx b/frontend/src/components/ui/shared/TimeStepInput.tsx new file mode 100644 index 0000000..480beb4 --- /dev/null +++ b/frontend/src/components/ui/shared/TimeStepInput.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { adjustTimeInput } from '@/components/appointments/appointmentTime'; + +interface TimeStepInputProps { + id?: string; + label: string; + value: string; + onChange: (value: string) => void; + stepMinutes?: number; + disabled?: boolean; +} + +const inputClassName = + 'min-w-0 flex-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm text-center tabular-nums focus:outline-none focus:ring-2 focus:ring-primary/35'; + +export function TimeStepInput({ + id, + label, + value, + onChange, + stepMinutes = 5, + disabled = false, +}: TimeStepInputProps) { + const step = (delta: number) => { + if (disabled) return; + onChange(adjustTimeInput(value, delta * stepMinutes)); + }; + + return ( +
+ +
+
+ + +
+ onChange(e.target.value)} + /> +
+
+ ); +} diff --git a/frontend/src/lib/api/patients.ts b/frontend/src/lib/api/patients.ts index a4ea0f6..13f690d 100644 --- a/frontend/src/lib/api/patients.ts +++ b/frontend/src/lib/api/patients.ts @@ -14,7 +14,12 @@ export const patientsApi = { }, create: async (data: CreatePatientInput): Promise => { - const response = await apiClient.post('/patients', data); + const { email, ...rest } = data; + const body = { + ...rest, + ...(email?.trim() ? { email: email.trim() } : {}), + }; + const response = await apiClient.post('/patients', body); return response.data; }, diff --git a/frontend/src/lib/hooks/usePatientSearchQuery.ts b/frontend/src/lib/hooks/usePatientSearchQuery.ts new file mode 100644 index 0000000..1f0c251 --- /dev/null +++ b/frontend/src/lib/hooks/usePatientSearchQuery.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { patientsApi } from '@/lib/api/patients'; +import { useAuth } from '@/lib/hooks/useAuth'; +import type { Patient } from '@/types/patient'; + +/** Debounced patient search — only queries when `search` is non-empty. */ +export function usePatientSearchQuery(enabled = true) { + const { currentOrganization } = useAuth(); + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!enabled || !currentOrganization) { + return; + } + + const trimmed = search.trim(); + if (!trimmed) { + setPatients([]); + setLoading(false); + return; + } + + const timeout = setTimeout(() => { + void (async () => { + setLoading(true); + try { + const response = await patientsApi.list({ q: trimmed, page: 1, limit: 25 }); + setPatients(response.data.items ?? []); + } catch { + setPatients([]); + } finally { + setLoading(false); + } + })(); + }, 300); + + return () => clearTimeout(timeout); + }, [search, enabled, currentOrganization]); + + return { search, setSearch, patients, loading }; +} diff --git a/frontend/src/types/appointment.ts b/frontend/src/types/appointment.ts index debb4d5..5b5b9f7 100644 --- a/frontend/src/types/appointment.ts +++ b/frontend/src/types/appointment.ts @@ -23,4 +23,6 @@ export interface AppointmentRecord { endAt: string; purpose: string; patient: Pick; + /** True when a treatment record is linked to this appointment. */ + hasTreatment?: boolean; } -- 2.53.0.windows.1 From f28cd06615061b51cb5fbdf215d666945bb400a6 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 01:03:26 +0330 Subject: [PATCH 10/24] improvement: treatment UX fully overhauled. --- .cursor/rules/treatment-workspace.mdc | 40 ++ .cursor/skills/treatment-workspace/SKILL.md | 190 +++++++++ AGENTS.md | 8 + .../modules/treatments/treatments.service.ts | 31 +- frontend/messages/en.json | 26 +- frontend/messages/fa.json | 26 +- frontend/messages/nl.json | 26 +- .../src/app/[locale]/(dashboard)/layout.tsx | 2 +- .../src/components/shared/scrollWithinMain.ts | 28 ++ .../treatment/labDispatchAttention.ts | 67 +++ .../treatment/treatmentDetailRules.ts | 106 +++++ .../treatment/treatmentHistoryFilters.ts | 49 +++ .../treatment/treatmentStatusStyles.ts | 3 + .../ui/organizations/OrganizationsPage.tsx | 12 + .../DetailLabCaseCommentsSection.tsx | 46 +++ .../ui/treatment/DetailLabSendBadge.tsx | 3 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 62 ++- .../treatment/LabDispatchAttentionPanel.tsx | 106 +++++ .../ui/treatment/LabShipmentBlockedNotice.tsx | 20 + .../LinkedOrganizationSearchCombobox.tsx | 109 +++++ .../ui/treatment/PastTreatmentsPanel.tsx | 131 +++++- .../treatment/TreatmentDetailSummaryRow.tsx | 15 +- .../ui/treatment/TreatmentDetailsEditor.tsx | 39 +- .../treatment/TreatmentHistoryDetailLine.tsx | 13 +- .../ui/treatment/TreatmentPreviewCard.tsx | 32 +- .../ui/treatment/TreatmentWorkspace.tsx | 385 ++++++++++++------ frontend/src/types/treatment.ts | 7 + 27 files changed, 1370 insertions(+), 212 deletions(-) create mode 100644 .cursor/rules/treatment-workspace.mdc create mode 100644 .cursor/skills/treatment-workspace/SKILL.md create mode 100644 frontend/src/components/shared/scrollWithinMain.ts create mode 100644 frontend/src/components/treatment/labDispatchAttention.ts create mode 100644 frontend/src/components/treatment/treatmentDetailRules.ts create mode 100644 frontend/src/components/treatment/treatmentHistoryFilters.ts create mode 100644 frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx create mode 100644 frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx create mode 100644 frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx create mode 100644 frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc new file mode 100644 index 0000000..fbe4e17 --- /dev/null +++ b/.cursor/rules/treatment-workspace.mdc @@ -0,0 +1,40 @@ +--- + +description: Treatment workspace — preview vs form, history, load flow, lab dispatch + +globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/** + +alwaysApply: false + +--- + + + +# Treatment workspace + + + +- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. + +- **Current draft** preview: heading “Current draft”; no load button while editing live. + +- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. + +- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. + +- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. No new fetch params. + +- **Lab shipment block:** prosthesis/lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. + +- **Lab case comments:** `DetailLabCaseCommentsSection` under detail when sent and lab tasks not all complete (`taskProgress` from API). + +- **Today checkbox:** unchecked when viewing a non-today day; checking Today unlocks selection and auto-picks nearest appointment. + +- **Lab search:** `LinkedOrganizationSearchCombobox` — select from results; invite lab via `/organizations?action=invite-lab` when permitted. + +- **Scroll:** use `scrollWithinMainScrollContainer`; shared `Checkbox` only. + + + +Full map: `.cursor/skills/treatment-workspace/SKILL.md` + diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md new file mode 100644 index 0000000..9b5ad29 --- /dev/null +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -0,0 +1,190 @@ +--- + +name: dyolink-treatment-workspace + +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. + +--- + + + +# Treatment workspace + + + +Main orchestrator: `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` + +Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmentId=`). + + + +## Layout (top → bottom) + + + +1. **Appointments strip** — `AppointmentsStrip.tsx` + `ScheduleDayPicker.tsx` (Today checkbox) + `pickAutoAppointment()` in `components/shared/treatmentSelection.ts` + +2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft) + +3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) + +4. **Editor** — `TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx` + + + +## Two layers of state (critical) + + + +| Layer | State | Updated when | + +|-------|--------|--------------| + +| **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only | + +| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate | + + + +**Browse mode:** banner + “Load into workspace” / “Back to current draft”. No Open button on preview card. + + + +**Lab dispatch attention:** `LabDispatchAttentionPanel` lists lab-dependent unsent details (current draft + user history). “Go to dispatch” / “Load & dispatch”. + + + +## History API + + + +`GET /treatments/patients/:id/history` returns saved treatments for **that patient** (not the whole day’s schedule). Non-owners see plans where `Treatment.providerUserId` or linked `Appointment.providerUserId` matches the logged-in user; org owners see all saved plans for the patient. New saves set `Treatment.providerUserId` to the logged-in clinician. + + + +## History filters (client-side only) + + + +`PastTreatmentsPanel` filters **already-fetched** history — no extra API params. + +- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`. +- **Date** — filter on `treatmentAt` matching that local calendar day. +- When **not shipped** is on and workspace is live (not browsing), prepend a synthetic **current draft** row (`id: 'current-draft'`) if it has pending lab-dependent details. + +Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. + + + +## Lab shipment without teeth + + + +Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. + +- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`). +- `handleAddLabCase` shows toast with `labShipmentBlockedBody`. +- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth). + + + +## Lab case comments on details + + + +Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed): + +- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`. +- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints. +- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`. + + + +## Appointments default selection + + + +On today: in-progress slot first, else nearest start time to `now`. Other days: first appointment. Re-runs every 60s on today unless `selectionLocked`. Frontend filters appointments to `providerUserId === userId`. + + + +**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. + + + +## Lab org search (dispatch) + + + +`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. + + + +Pattern mirrors `PatientSearchCombobox` in appointments. + + + +## Scroll + + + +Use `scrollWithinMainScrollContainer()` (not raw `scrollIntoView`) when jumping to lab dispatch panel — dashboard `
` is the scroll container; document scroll conflicts with `.app-web-bg { overflow: hidden }`. + + + +Use shared `Checkbox` (not native ``) to avoid focus-driven scroll jumps. + + + +## Backend APIs + + + +| Endpoint | Purpose | + +|----------|---------| + +| `GET /appointments?from&to` | Strip | + +| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) | + +| `GET /treatments/appointments/:id/draft` | Load form on appointment select | + +| `PUT .../draft`, `PUT .../lab-cases` | Autosave (600ms debounce) | + + + +Draft writes require provider match (`ensureAppointmentProvider`) unless org owner. + + + +## Edit gating + + + +```typescript + +canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' + +``` + + + +## Optional fields + + + +- `@IsOptional()` email: use `@Transform` empty string → `undefined` before `@IsEmail` (see patients DTO). + +- Form validation → inline errors; transient feedback → global `useToast()` via `ToastProvider`. + + + +## When changing history scope + + + +Filter in **backend** `listPatientHistory` on patient + org; provider scoping for non-owners. History is **per selected patient**, not per day or all appointments on the strip. + +**UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering. + diff --git a/AGENTS.md b/AGENTS.md index a229352..62e2675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,13 @@ frontend/src/ **Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. +**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. + +**Treatment lab rules (quick ref):** +- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. +- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. +- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). + ## Backend layout ``` @@ -59,6 +66,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index c749b9b..8f11505 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LinkStatus } from '@prisma/client'; +import { LabTaskStatus, LinkStatus } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -36,6 +36,7 @@ const treatmentInclude = { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, + tasks: { select: { id: true, status: true } }, }, }, }, @@ -64,6 +65,7 @@ const treatmentInclude = { }, }, }, + tasks: { select: { id: true, status: true } }, }, }, }; @@ -124,14 +126,25 @@ export class TreatmentsService { await this.assertCanReadTreatment(actorUserId, organizationId); await this.ensurePatientExists(patientId); + const membership = await this.getMembership(actorUserId, organizationId); + const isOwner = membership?.isOwner ?? false; + const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, details: { some: {} }, + ...(isOwner + ? {} + : { + OR: [ + { providerUserId: actorUserId }, + { appointment: { is: { providerUserId: actorUserId } } }, + ], + }), }, include: treatmentInclude, - orderBy: [{ treatmentAt: 'desc' }], + orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }], take: Math.min(Math.max(limit, 1), 100), }); @@ -205,7 +218,7 @@ export class TreatmentsService { title, treatmentAt: appointment.startAt, patientId: appointment.patientId, - providerUserId: appointment.providerUserId, + providerUserId: actorUserId, }, }) : await tx.treatment.create({ @@ -213,7 +226,7 @@ export class TreatmentsService { organizationId, patientId: appointment.patientId, appointmentId: appointment.id, - providerUserId: appointment.providerUserId, + providerUserId: actorUserId, title, treatmentAt: appointment.startAt, }, @@ -566,6 +579,7 @@ export class TreatmentsService { include: { organization: { select: { id: true, name: true } } }, }, toothProsthesis: true, + tasks: { select: { id: true, status: true } }, }, }); @@ -758,10 +772,12 @@ export class TreatmentsService { sentAt: Date; organization?: { id: string; name: string }; }>; + tasks?: Array<{ id: string; status: LabTaskStatus }>; }; } | null; }) { const labCase = d.labCaseLink?.labCase; + const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []); return { id: d.id, clientId: d.clientKey ?? d.id, @@ -772,6 +788,7 @@ export class TreatmentsService { labCaseId: labCase?.id ?? null, sentAt: labCase?.sentAt?.toISOString() ?? null, destinationOrganizationId: labCase?.destinationOrganizationId ?? null, + taskProgress, sends: labCase?.sends.map((s) => ({ organizationId: s.organizationId, @@ -846,6 +863,12 @@ export class TreatmentsService { }; } + private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) { + const total = tasks.length; + const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length; + return { completed, total }; + } + private mapAttachment(a: { id: string; fileName: string; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c9bd7c3..fc13a73 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -618,6 +618,8 @@ "comments": "Comments", "commentsPlaceholder": "Write clinical notes for this case…", "treatmentType": "Treatment type", + "treatmentTypePlaceholder": "Select treatment type…", + "treatmentTypeNotSelected": "Type not selected", "typeConsultation": "consultation", "typeFilling": "filling", "typeEndo": "endo", @@ -630,6 +632,8 @@ "searchOrgsPlaceholder": "Search active organizations...", "recent": "Recent:", "noOrgMatch": "No active organization matches your search.", + "inviteLab": "Invite a lab", + "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", @@ -663,10 +667,30 @@ "saveStatusError": "Could not save — check your connection", "sendSavesFirst": "Sending is per case and saves first automatically.", "historyTitle": "Previous treatments", - "historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.", + "historyPatientScope": "Previous treatments for {patientName}", + "historyCurrentAppointment": "This appointment", + "historySubtitle": "Click a past plan to preview it, then load it into the workspace if needed.", + "historyFilterNotShipped": "Not shipped to lab", + "historyFilterDate": "Date", + "historyClearFilters": "Remove filters", + "historyFilterEmpty": "No treatments match these filters.", + "labShipmentBlockedTitle": "Lab shipment not available yet", + "labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.", + "labCaseCommentsTitle": "Lab case comments", + "labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.", "loadingHistory": "Loading history…", "historyEmpty": "No other treatments recorded for this patient yet.", "historyDetailLabel": "Detail {n} · {type}", + "previewCurrentDraft": "Current draft", + "previewBrowsingTitle": "Previewing saved plan", + "browseBanner": "Viewing {date} — this plan is not loaded in the editor yet.", + "loadIntoWorkspace": "Load into workspace", + "backToCurrentDraft": "Back to current draft", + "labAttentionTitle": "Lab dispatch needed", + "labAttentionSubtitle": "These lab-dependent details have not been sent to a lab yet.", + "labAttentionCurrentDraft": "Current appointment", + "labAttentionGoDispatch": "Go to dispatch", + "labAttentionLoadDispatch": "Load & dispatch", "previewTitle": "Treatment preview", "openTreatment": "Open", "selectAppointment": "Select an appointment to preview its treatment.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 2d81f21..c9ed74f 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -619,6 +619,8 @@ "comments": "نظرات", "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", "treatmentType": "نوع درمان", + "treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…", + "treatmentTypeNotSelected": "نوع انتخاب نشده", "typeConsultation": "مشاوره", "typeFilling": "پر کردن", "typeEndo": "درمان ریشه", @@ -631,6 +633,8 @@ "searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...", "recent": "اخیر:", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", + "inviteLab": "دعوت از آزمایشگاه", + "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", @@ -664,10 +668,30 @@ "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید", "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", "historyTitle": "درمان‌های قبلی", - "historySubtitle": "برای پیش‌نمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیش‌نمایش برای بارگذاری در فضای کاری استفاده کنید.", + "historyPatientScope": "درمان‌های قبلی برای {patientName}", + "historyCurrentAppointment": "این نوبت", + "historySubtitle": "برای پیش‌نمایش روی یک طرح قبلی کلیک کنید، سپس در صورت نیاز آن را در فضای کار بارگذاری کنید.", + "historyFilterNotShipped": "ارسال‌نشده به لابراتوار", + "historyFilterDate": "تاریخ", + "historyClearFilters": "حذف فیلترها", + "historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.", + "labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست", + "labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.", + "labCaseCommentsTitle": "نظرات پرونده لابراتوار", + "labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.", "loadingHistory": "در حال بارگذاری تاریخچه...", "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.", "historyDetailLabel": "جزئیات {n} · {type}", + "previewCurrentDraft": "پیش‌نویس فعلی", + "previewBrowsingTitle": "پیش‌نمایش طرح ذخیره‌شده", + "browseBanner": "در حال مشاهده {date} — این طرح هنوز در ویرایشگر بارگذاری نشده است.", + "loadIntoWorkspace": "بارگذاری در فضای کاری", + "backToCurrentDraft": "بازگشت به پیش‌نویس فعلی", + "labAttentionTitle": "نیاز به ارسال به لابراتوار", + "labAttentionSubtitle": "این جزئیات وابسته به لاب هنوز به لابراتوار ارسال نشده‌اند.", + "labAttentionCurrentDraft": "نوبت فعلی", + "labAttentionGoDispatch": "رفتن به ارسال", + "labAttentionLoadDispatch": "بارگذاری و ارسال", "previewTitle": "پیش‌نمایش درمان", "openTreatment": "باز کردن", "selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a4d98ed..f9bf5fd 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -619,6 +619,8 @@ "comments": "Opmerkingen", "commentsPlaceholder": "Schrijf klinische notities voor deze case...", "treatmentType": "Behandeltype", + "treatmentTypePlaceholder": "Selecteer behandeltype…", + "treatmentTypeNotSelected": "Type niet geselecteerd", "typeConsultation": "consult", "typeFilling": "vulling", "typeEndo": "endo", @@ -631,6 +633,8 @@ "searchOrgsPlaceholder": "Zoek actieve organisaties...", "recent": "Recent:", "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", + "inviteLab": "Lab uitnodigen", + "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", @@ -664,10 +668,30 @@ "saveStatusError": "Opslaan mislukt — controleer uw verbinding", "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", "historyTitle": "Eerdere behandelingen", - "historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.", + "historyPatientScope": "Eerdere behandelingen voor {patientName}", + "historyCurrentAppointment": "Deze afspraak", + "historySubtitle": "Klik op een eerdere planning voor een voorbeeld; laad deze indien nodig in de werkruimte.", + "historyFilterNotShipped": "Niet naar lab verzonden", + "historyFilterDate": "Datum", + "historyClearFilters": "Filters verwijderen", + "historyFilterEmpty": "Geen behandelingen komen overeen met deze filters.", + "labShipmentBlockedTitle": "Labverzending nog niet beschikbaar", + "labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.", + "labCaseCommentsTitle": "Opmerkingen labcase", + "labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.", "loadingHistory": "Geschiedenis laden...", "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.", "historyDetailLabel": "Detail {n} · {type}", + "previewCurrentDraft": "Huidig concept", + "previewBrowsingTitle": "Opgeslagen planning bekijken", + "browseBanner": "U bekijkt {date} — deze planning is nog niet in de editor geladen.", + "loadIntoWorkspace": "In werkruimte laden", + "backToCurrentDraft": "Terug naar huidig concept", + "labAttentionTitle": "Labverzending nodig", + "labAttentionSubtitle": "Deze lab-afhankelijke details zijn nog niet naar een lab verzonden.", + "labAttentionCurrentDraft": "Huidige afspraak", + "labAttentionGoDispatch": "Naar verzending", + "labAttentionLoadDispatch": "Laden & verzenden", "previewTitle": "Behandelvoorbeeld", "openTreatment": "Openen", "selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.", diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index b6d836b..d16b2c1 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -90,7 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod onOpenSidebar={() => setSidebarOpen(true)} /> -
+
{children}
diff --git a/frontend/src/components/shared/scrollWithinMain.ts b/frontend/src/components/shared/scrollWithinMain.ts new file mode 100644 index 0000000..012d80c --- /dev/null +++ b/frontend/src/components/shared/scrollWithinMain.ts @@ -0,0 +1,28 @@ +/** + * Scroll an element into view using the dashboard `
` scroll container only. + * Avoids document-level scrollIntoView, which conflicts with `.app-web-bg { overflow: hidden }`. + */ +export function scrollWithinMainScrollContainer( + element: HTMLElement | null, + options?: { behavior?: ScrollBehavior; padding?: number }, +): void { + if (!element) return; + + const behavior = options?.behavior ?? 'smooth'; + const padding = options?.padding ?? 16; + const main = element.closest('main'); + + if (!main) { + element.scrollIntoView({ behavior, block: 'nearest' }); + return; + } + + const mainRect = main.getBoundingClientRect(); + const elRect = element.getBoundingClientRect(); + + if (elRect.top < mainRect.top + padding) { + main.scrollBy({ top: elRect.top - mainRect.top - padding, behavior }); + } else if (elRect.bottom > mainRect.bottom - padding) { + main.scrollBy({ top: elRect.bottom - mainRect.bottom + padding, behavior }); + } +} diff --git a/frontend/src/components/treatment/labDispatchAttention.ts b/frontend/src/components/treatment/labDispatchAttention.ts new file mode 100644 index 0000000..45afe3d --- /dev/null +++ b/frontend/src/components/treatment/labDispatchAttention.ts @@ -0,0 +1,67 @@ +import type { PastTreatment, PastTreatmentDetail } from '@/types/treatment'; +import { isDetailEligibleForLabAttention } from '@/components/treatment/treatmentDetailRules'; + +export type LabDispatchAttentionItem = { + key: string; + treatmentId: string; + appointmentId: string; + treatmentAt: string; + detailClientId: string; + detailNumber: number; + detail: PastTreatmentDetail; + isCurrentDraft: boolean; +}; + +function pushPendingDetails( + items: LabDispatchAttentionItem[], + treatment: PastTreatment, + labDependentCodes: Set, + isCurrentDraft: boolean, +) { + if (!treatment.appointmentId) { + return; + } + + treatment.details.forEach((detail, index) => { + if ( + !isDetailEligibleForLabAttention(detail, labDependentCodes, isCurrentDraft) + ) { + return; + } + items.push({ + key: `${treatment.id}-${detail.clientId ?? detail.id}`, + treatmentId: treatment.id, + appointmentId: treatment.appointmentId!, + treatmentAt: treatment.treatmentAt, + detailClientId: detail.clientId ?? detail.id, + detailNumber: index + 1, + detail, + isCurrentDraft, + }); + }); +} + +/** Lab-dependent details that still need a lab send (current draft + saved history). */ +export function collectLabDispatchAttention( + labDependentCodes: Set, + currentDraft: PastTreatment | null, + history: PastTreatment[], + currentAppointmentId: string | null, +): LabDispatchAttentionItem[] { + const items: LabDispatchAttentionItem[] = []; + + if (currentDraft) { + pushPendingDetails(items, currentDraft, labDependentCodes, true); + } + + for (const treatment of history) { + if (currentAppointmentId && treatment.appointmentId === currentAppointmentId) { + continue; + } + pushPendingDetails(items, treatment, labDependentCodes, false); + } + + return items.sort( + (a, b) => new Date(b.treatmentAt).getTime() - new Date(a.treatmentAt).getTime(), + ); +} diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts new file mode 100644 index 0000000..db8ff79 --- /dev/null +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -0,0 +1,106 @@ +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { TreatmentDetailDraft } from '@/types/treatment'; + +export type LabCaseTaskProgress = { + completed: number; + total: number; +}; + +type DetailLike = { + id?: string; + treatmentType: string; + teeth: readonly string[]; + comment?: string; + sentAt?: string | null; + labCaseId?: string | null; + taskProgress?: LabCaseTaskProgress | null; +}; +export function isAppointmentOnlyPurpose( + purpose: string | undefined, + catalog: TreatmentCatalogEntry[], +): boolean { + if (!purpose) return false; + const entry = catalog.find((e) => e.code === purpose); + return entry ? !entry.availableInTreatment : false; +} + +/** Default detail type from appointment purpose — only when purpose is a real treatment type. */ +export function defaultTreatmentTypeForAppointment( + purpose: string | undefined, + catalog: TreatmentCatalogEntry[], +): string | undefined { + if (!purpose || isAppointmentOnlyPurpose(purpose, catalog)) { + return undefined; + } + const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment); + if (treatmentOptions.some((entry) => entry.code === purpose)) { + return purpose; + } + return undefined; +} + +export function isDetailTypeSelected(detail: Pick): boolean { + return Boolean(detail.treatmentType?.trim()); +} + +export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean { + return ( + !isDetailTypeSelected(detail) && + detail.teeth.length === 0 && + !detail.comment.trim() + ); +} + +export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean { + return details.length > 0 && details.every((d) => isDetailTypeSelected(d)); +} + +/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */ +export function isDetailReadyForLabDispatch( + detail: DetailLike, + labDependentCodes: Set, +): boolean { + return ( + Boolean(detail.id) && + isDetailTypeSelected(detail) && + labDependentCodes.has(detail.treatmentType) && + detail.teeth.length > 0 + ); +} + +export function isDetailEligibleForLabAttention( + detail: DetailLike, + labDependentCodes: Set, + isCurrentDraft: boolean, +): boolean { + if (detail.sentAt || !isDetailTypeSelected(detail)) return false; + if (!labDependentCodes.has(detail.treatmentType)) return false; + if (detail.teeth.length === 0) return false; + if (isCurrentDraft && !detail.id) return false; + return true; +} + +/** Saved lab-dependent line with prosthesis type but no teeth — can save, cannot ship. */ +export function isLabDependentDetailMissingTeeth( + detail: DetailLike, + labDependentCodes: Set, +): boolean { + return ( + Boolean(detail.id) && + isDetailTypeSelected(detail) && + labDependentCodes.has(detail.treatmentType) && + detail.teeth.length === 0 && + !detail.sentAt + ); +} + +export function isLabCaseCompleted(progress: LabCaseTaskProgress | null | undefined): boolean { + if (!progress || progress.total <= 0) return false; + return progress.completed >= progress.total; +} + +/** Detail was sent to lab and the lab case still has open tasks. */ +export function canCommentOnDetailLabCase(detail: DetailLike): boolean { + if (!detail.sentAt || !detail.labCaseId) return false; + return !isLabCaseCompleted(detail.taskProgress); +} diff --git a/frontend/src/components/treatment/treatmentHistoryFilters.ts b/frontend/src/components/treatment/treatmentHistoryFilters.ts new file mode 100644 index 0000000..d4871dc --- /dev/null +++ b/frontend/src/components/treatment/treatmentHistoryFilters.ts @@ -0,0 +1,49 @@ +import { startOfLocalDay } from '@/components/appointments/appointmentTime'; +import type { PastTreatment } from '@/types/treatment'; + +export type TreatmentHistoryFilters = { + notShippedOnly: boolean; + date: string; +}; + +export function treatmentHasUnshippedProsthesis( + treatment: PastTreatment, + labDependentCodes: Set, +): boolean { + return treatment.details.some( + (detail) => labDependentCodes.has(detail.treatmentType) && !detail.sentAt, + ); +} + +export function treatmentMatchesHistoryDate(treatmentAt: string, date: string): boolean { + if (!date) return true; + + const treatmentDay = startOfLocalDay(new Date(treatmentAt)).getTime(); + const filterDay = startOfLocalDay(new Date(`${date}T00:00:00`)).getTime(); + return treatmentDay === filterDay; +} + +export function filterTreatmentHistoryItems( + history: PastTreatment[], + currentDraft: PastTreatment | null, + labDependentCodes: Set, + filters: TreatmentHistoryFilters, +): PastTreatment[] { + const source = (() => { + if (!currentDraft) return history; + const withoutCurrentAppointment = currentDraft.appointmentId + ? history.filter((item) => item.appointmentId !== currentDraft.appointmentId) + : history; + return [currentDraft, ...withoutCurrentAppointment]; + })(); + + return source.filter((treatment) => { + if (!treatmentMatchesHistoryDate(treatment.treatmentAt, filters.date)) { + return false; + } + if (filters.notShippedOnly && !treatmentHasUnshippedProsthesis(treatment, labDependentCodes)) { + return false; + } + return true; + }); +} diff --git a/frontend/src/components/treatment/treatmentStatusStyles.ts b/frontend/src/components/treatment/treatmentStatusStyles.ts index e426ef0..8e71ff0 100644 --- a/frontend/src/components/treatment/treatmentStatusStyles.ts +++ b/frontend/src/components/treatment/treatmentStatusStyles.ts @@ -10,6 +10,9 @@ export const labSentBannerClass = export const labPendingBannerClass = 'text-xs rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1.5'; +export const labBlockedBannerClass = + 'text-xs rounded-[var(--radius-sm)] border border-amber-500/35 bg-amber-500/5 text-amber-800 dark:text-amber-300 px-2 py-1.5'; + export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string { switch (status) { case 'dirty': diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx index 3ddbfb2..a4254a2 100644 --- a/frontend/src/components/ui/organizations/OrganizationsPage.tsx +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; @@ -25,6 +26,7 @@ import { Input } from '@/components/ui/shared/Input'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Table } from '@/components/ui/shared/Table'; import { getUserFacingError } from '@/components/shared/formatApiError'; +import { useRouter } from '@/i18n/navigation'; function formatOrganizationStatusLabel(status: string): string { if (!status) return status; @@ -45,6 +47,8 @@ export function OrganizationsPage() { const tErrors = useTranslations('errors'); const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const router = useRouter(); const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); const toast = useToast(); @@ -123,6 +127,14 @@ export function OrganizationsPage() { void loadList(); }, []); + useEffect(() => { + if (searchParams.get('action') !== 'invite-lab') { + return; + } + setShowInviteForm(true); + router.replace('/organizations'); + }, [searchParams, router]); + useEffect(() => { let cancelled = false; const q = query.trim(); diff --git a/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx b/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx new file mode 100644 index 0000000..ad61213 --- /dev/null +++ b/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { treatmentsApi } from '@/lib/api/treatments'; +import type { TreatmentDetailDraft } from '@/types/treatment'; + +interface DetailLabCaseCommentsSectionProps { + detail: TreatmentDetailDraft; + canPost: boolean; + onError?: (message: string) => void; +} + +export function DetailLabCaseCommentsSection({ + detail, + canPost, + onError, +}: DetailLabCaseCommentsSectionProps) { + const t = useTranslations('treatment'); + const caseId = detail.labCaseId; + + if (!caseId) return null; + + return ( +
+
+

{t('labCaseCommentsTitle')}

+

{t('labCaseCommentsHint')}

+
+ { + const response = await treatmentsApi.listLabCaseComments(caseId); + return response.data; + }} + onPost={async (body) => { + const response = await treatmentsApi.addLabCaseComment(caseId, { body }); + return response.data; + }} + onError={onError} + /> +
+ ); +} diff --git a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx index 4d4e8c2..8f067fa 100644 --- a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx +++ b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; @@ -23,7 +24,7 @@ export function DetailLabSendBadge({ }: DetailLabSendBadgeProps) { const t = useTranslations('treatment'); - if (!labDependentCodes.has(detail.treatmentType)) { + if (!isDetailTypeSelected(detail) || !labDependentCodes.has(detail.treatmentType)) { return null; } diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 2595104..959134a 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; @@ -30,6 +30,8 @@ interface LabCasesDispatchPanelProps { onOrganizationSearchChange: (value: string) => void; recentOrganizationIds: string[]; onRecentOrganizationPick: (orgId: string) => void; + canInviteLab?: boolean; + onInviteLab?: () => void; sendBusyId: string | null; onAddLabCase: () => void; onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void; @@ -89,6 +91,8 @@ export function LabCasesDispatchPanel({ onOrganizationSearchChange, recentOrganizationIds, onRecentOrganizationPick, + canInviteLab = false, + onInviteLab, sendBusyId, onAddLabCase, onSendLabCase, @@ -100,18 +104,13 @@ export function LabCasesDispatchPanel({ const [pendingComment, setPendingComment] = useState(''); const activeLinkedOrganizations = orgs.filter((o) => o.active); - const filteredOrganizations = (() => { - const q = organizationSearch.trim().toLowerCase(); - if (!q) return activeLinkedOrganizations; - return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q)); - })(); const recentOrganizations = recentOrganizationIds .map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .filter(Boolean) as LinkedOrganizationOption[]; const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null; const isLabDependentDetail = Boolean( - activeDetail && labDependentCodes.has(activeDetail.treatmentType), + activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes), ); const labCaseForActiveDetail = @@ -214,6 +213,14 @@ export function LabCasesDispatchPanel({ updateActiveLabCase({ attachmentIds: [...set] }); } + function handleSelectOrganization(org: LinkedOrganizationOption) { + updateActiveLabCase({ + destinationOrganizationId: org.id, + toothProsthesis: [], + }); + setApplyAllProsthesis(''); + } + const activeDetailAttachments = activeDetail.attachmentMetas ?? []; return ( @@ -340,11 +347,16 @@ export function LabCasesDispatchPanel({

{t('selectLab')}

- {recentOrganizations.length > 0 && (
@@ -362,28 +374,6 @@ export function LabCasesDispatchPanel({ ))}
)} - { - const nextOrgId = e.target.value || null; - updateActiveLabCase({ - destinationOrganizationId: nextOrgId, - toothProsthesis: [], - }); - setApplyAllProsthesis(''); - }} - disabled={disabled || filteredOrganizations.length === 0} - > - - {filteredOrganizations.map((o) => ( - - ))} - - {filteredOrganizations.length === 0 && ( -

{t('noOrgMatch')}

- )}
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? ( @@ -411,7 +401,7 @@ export function LabCasesDispatchPanel({ ))} -
+
{t('tableName')}{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableAccess')} + {t('tableAction')} +
{m.name}{m.email} + {m.isOwner ? ( + {t('roleOwner')} + ) : ( + {t('roleStaff')} + )} + + {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( + {t('statusActive')} + ) : m.invitationStatus === 'PENDING' ? ( + {t('statusPending')} + ) : m.invitationStatus === 'DISABLED' ? ( + {t('statusDisabled')} + ) : ( + {t('statusExpired')} + )} + + {m.isOwner ? ( + {t('allFeatures')} + ) : ( + + {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} + + )} + + {!m.isOwner && ( +
+ {canShareStaffInviteLink(m) && ( + + )} + {canEnableStaff(m) && ( + + )} + {canDisableStaff(m) && ( + + )} + + +
+ )} +
diff --git a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx new file mode 100644 index 0000000..0ed8432 --- /dev/null +++ b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; +import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; +import type { LinkedOrganizationOption } from '@/types/treatment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; + +interface LabDispatchAttentionPanelProps { + items: LabDispatchAttentionItem[]; + treatmentCatalog: TreatmentCatalogEntry[]; + labDependentCodes: Set; + orgs?: LinkedOrganizationOption[]; + onGoToDispatch: (item: LabDispatchAttentionItem) => void; +} + +export function LabDispatchAttentionPanel({ + items, + treatmentCatalog, + labDependentCodes, + orgs, + onGoToDispatch, +}: LabDispatchAttentionPanelProps) { + const t = useTranslations('treatment'); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ +
+

{t('labAttentionTitle')}

+

{t('labAttentionSubtitle')}

+
+
+ +
    + {items.map((item) => { + const teeth = item.detail.teeth.length + ? [...item.detail.teeth].sort().join(', ') + : t('teethNone'); + const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return ( +
  • +
    +
    + + {item.isCurrentDraft ? ( + + {t('labAttentionCurrentDraft')} + + ) : null} +
    +
    + + {t('detailLabel', { n: item.detailNumber })} + + + +
    +

    + {t('teethLabel')} {teeth} +

    +
    + +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx new file mode 100644 index 0000000..71eddee --- /dev/null +++ b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { AlertCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +export function LabShipmentBlockedNotice() { + const t = useTranslations('treatment'); + + return ( +
+
+ +
+

{t('labShipmentBlockedTitle')}

+

{t('labShipmentBlockedBody')}

+
+
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx b/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx new file mode 100644 index 0000000..fae2a35 --- /dev/null +++ b/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx @@ -0,0 +1,109 @@ +'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 { LinkedOrganizationOption } from '@/types/treatment'; + +interface LinkedOrganizationSearchComboboxProps { + search: string; + onSearchChange: (value: string) => void; + organizations: LinkedOrganizationOption[]; + selectedOrganizationId?: string | null; + onSelectOrganization: (org: LinkedOrganizationOption) => void; + disabled?: boolean; + canInviteLab?: boolean; + onInviteLab?: () => void; + placeholder?: string; + emptyResultsMessage?: string; + noPermissionMessage?: string; +} + +export function LinkedOrganizationSearchCombobox({ + search, + onSearchChange, + organizations, + selectedOrganizationId, + onSelectOrganization, + disabled = false, + canInviteLab = false, + onInviteLab, + placeholder, + emptyResultsMessage, + noPermissionMessage, +}: LinkedOrganizationSearchComboboxProps) { + const t = useTranslations('treatment'); + const trimmed = search.trim(); + const showResults = !disabled && trimmed.length > 0; + + const filtered = trimmed + ? organizations.filter((o) => o.name.toLowerCase().includes(trimmed.toLowerCase())) + : []; + + const selectedOrg = selectedOrganizationId + ? organizations.find((o) => o.id === selectedOrganizationId) + : null; + + function handleSelect(org: LinkedOrganizationOption) { + onSelectOrganization(org); + onSearchChange(''); + } + + return ( +
+ onSearchChange(e.target.value)} + disabled={disabled} + icon={} + /> + + {selectedOrg && !trimmed ? ( +

+ {selectedOrg.name} +

+ ) : null} + + {showResults ? ( +
+ {filtered.length === 0 ? ( +
+

+ {emptyResultsMessage ?? t('noOrgMatch')} +

+ {canInviteLab && onInviteLab ? ( + + ) : noPermissionMessage ? ( +

{noPermissionMessage}

+ ) : null} +
+ ) : ( +
+ {filtered.map((org) => { + const isSelected = selectedOrganizationId === org.id; + return ( + + ); + })} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx index a97a225..e7884a7 100644 --- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -1,43 +1,127 @@ 'use client'; +import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine'; -import type { PastTreatment } from '@/types/treatment'; +import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; +import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters'; +import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface PastTreatmentsPanelProps { items: PastTreatment[]; + currentDraft?: PastTreatment | null; + patientName?: string; + currentAppointmentId?: string | null; treatmentCatalog: TreatmentCatalogEntry[]; + labDependentCodes: Set; + orgs?: LinkedOrganizationOption[]; loading?: boolean; selectedPreviewId?: string | null; onSelectTreatment?: (treatment: PastTreatment) => void; } +function formatHistoryTimestamp(iso: string): string { + const date = new Date(iso); + return date.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + export function PastTreatmentsPanel({ items, + currentDraft = null, + patientName, + currentAppointmentId, treatmentCatalog, + labDependentCodes, + orgs, loading, selectedPreviewId, onSelectTreatment, }: PastTreatmentsPanelProps) { const t = useTranslations('treatment'); + const [notShippedOnly, setNotShippedOnly] = useState(false); + const [filterDate, setFilterDate] = useState(''); + + const hasActiveFilters = notShippedOnly || Boolean(filterDate); + + const displayedItems = useMemo( + () => + filterTreatmentHistoryItems(items, currentDraft, labDependentCodes, { + notShippedOnly, + date: filterDate, + }), + [items, currentDraft, labDependentCodes, notShippedOnly, filterDate], + ); + + function clearFilters() { + setNotShippedOnly(false); + setFilterDate(''); + } + + const filterInputClass = `${FORM_SELECT_CLASS} rounded-md px-2 py-1.5 text-xs min-w-[9.5rem]`; return (
-

{t('historyTitle')}

+

+ {patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')} +

{t('historySubtitle')}

+
+ + + +
+ {loading &&

{t('loadingHistory')}

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

{t('historyEmpty')}

+ {!loading && displayedItems.length === 0 && ( +

+ {hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')} +

)} -
- {items.map((treatment) => { +
+ {displayedItems.map((treatment) => { const isSelected = selectedPreviewId === treatment.id; + const isCurrentAppointment = + Boolean(currentAppointmentId) && treatment.appointmentId === currentAppointmentId; + const isLiveDraft = treatment.id === 'current-draft'; return (
- +
+ + {isLiveDraft ? ( + + {t('labAttentionCurrentDraft')} + + ) : isCurrentAppointment ? ( + + {t('historyCurrentAppointment')} + + ) : null} +
{treatment.details.length === 0 ? (

{t('noDetails')}

) : (
{treatment.details.map((detail, idx) => ( -
+
+
))}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx index e016801..683f757 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; @@ -39,10 +40,16 @@ export function TreatmentDetailSummaryRow({ {t('detailLabel', { n: detailNumber })} - + {isDetailTypeSelected(detail) ? ( + + ) : ( + + {t('treatmentTypeNotSelected')} + + )}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index f7a33bb..3a72f39 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -12,6 +12,14 @@ import { import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; +import { + canCommentOnDetailLabCase, + isDetailReadyForLabDispatch, + isDetailTypeSelected, + isLabDependentDetailMissingTeeth, +} from '@/components/treatment/treatmentDetailRules'; +import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; +import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; interface TreatmentDetailsEditorProps { details: TreatmentDetailDraft[]; @@ -27,6 +35,7 @@ interface TreatmentDetailsEditorProps { uploadBusy: boolean; onAddDetail: () => void; onUploadFiles: (files: FileList | null) => void; + onCommentError?: (message: string) => void; } export function TreatmentDetailsEditor({ @@ -43,6 +52,7 @@ export function TreatmentDetailsEditor({ uploadBusy, onAddDetail, onUploadFiles, + onCommentError, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const attachmentInputRef = useRef(null); @@ -52,12 +62,19 @@ export function TreatmentDetailsEditor({ const locked = isDetailLocked(activeDetail); const readOnly = disabled || locked; - const treatmentTypeTextColor = treatmentTypeColor( - activeDetail.treatmentType, - treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType), + const treatmentTypeTextColor = isDetailTypeSelected(activeDetail) + ? treatmentTypeColor( + activeDetail.treatmentType, + treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType), + ) + : undefined; + const showPendingLabHint = + isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly; + const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth( + activeDetail, + labDependentCodes, ); - const isLabDependent = labDependentCodes.has(activeDetail.treatmentType); - const showPendingLabHint = isLabDependent && !locked && !readOnly; + const showLabCaseComments = canCommentOnDetailLabCase(activeDetail); return (
@@ -107,6 +124,9 @@ export function TreatmentDetailsEditor({ {showPendingLabHint && (

{t('detailPendingLabSend')}

)} + {showMissingTeethLabBlock && ( +

{t('labShipmentBlockedBody')}

+ )}
+ {showLabCaseComments ? ( + + ) : null} + {canEdit && saveStatus !== 'idle' && (

{detailNumber}. - + {isDetailTypeSelected(detail) ? ( + + ) : ( + {t('treatmentTypeNotSelected')} + )} {teeth} {attachmentCount > 0 && ( diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 67385c0..44595dc 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -1,58 +1,56 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow'; import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface TreatmentPreviewCardProps { treatment: PastTreatment | null; + /** e.g. "Current draft" or a formatted date label while browsing. */ + heading: string; labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; - openDisabled?: boolean; - onOpen: () => void; } export function TreatmentPreviewCard({ treatment, + heading, labDependentCodes, treatmentCatalog, orgs, - openDisabled = false, - onOpen, }: TreatmentPreviewCardProps) { const t = useTranslations('treatment'); return (

-
-

{t('previewTitle')}

- -
+

{heading}

+ {!treatment ? (

{t('selectAppointment')}

) : (
-
-

{treatment.title}

+ {treatment.id !== 'current-draft' ? ( -
+ ) : null}
{treatment.details.length === 0 ? (

{t('noDetails')}

) : ( treatment.details.map((detail, idx) => ( entry.availableInTreatment); - if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) { - return purpose as TreatmentDetailDraft['treatmentType']; - } - return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType']; -} - -function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft { +function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft { return { clientId: typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - treatmentType: defaultTreatmentType ?? 'restoration', + treatmentType: defaultTreatmentType ?? '', teeth: [], comment: '', attachmentMetas: [], @@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft { comment: d.notes ?? '', attachmentMetas: d.attachmentMetas ?? [], labCaseId: d.labCaseId ?? null, + taskProgress: d.taskProgress ?? null, sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [], sends: d.sends ?? [], sentAt: d.sentAt ?? null, @@ -220,7 +224,7 @@ function isDetailsDirty( savedSnapshot: string | null, ): boolean { if (savedSnapshot === null) { - return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0; + return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1; } return serializeDetails(details) !== savedSnapshot; } @@ -242,6 +246,7 @@ function detailsToPreviewTreatment( notes: d.comment || null, attachmentMetas: d.attachmentMetas, labCaseId: d.labCaseId ?? null, + taskProgress: d.taskProgress ?? null, destinationOrganizationId: d.sendToOrganizationIds[0] ?? null, sends: d.sends ?? [], sentAt: d.sentAt ?? null, @@ -316,6 +321,8 @@ export function TreatmentWorkspace({ labCaseDraftsRef.current = labCaseDrafts; const skipNextGetDraftRef = useRef(false); const pendingAppointmentIdRef = useRef(initialAppointmentId); + const labPanelRef = useRef(null); + const historyRequestRef = useRef(0); useEffect(() => { pendingAppointmentIdRef.current = initialAppointmentId; @@ -360,18 +367,25 @@ export function TreatmentWorkspace({ !isViewingPastDay && workspaceMode === 'live'; - const historyPanelItems = useMemo(() => { - return history.filter((item) => { - if ( - workspaceMode === 'live' && - selectedAppointmentId && - item.appointmentId === selectedAppointmentId - ) { - return false; - } - return true; - }); - }, [history, selectedAppointmentId, workspaceMode]); + const historyPanelItems = history; + + const activeDetail = useMemo( + () => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null, + [details, activeDetailId], + ); + + const showLabDispatchPanel = useMemo( + () => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)), + [details, labDependentCodes], + ); + + const showLabShipmentBlocked = useMemo( + () => + Boolean( + activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes), + ), + [activeDetail, labDependentCodes], + ); const currentDraftPreview = useMemo(() => { if (!selectedAppointment) return null; @@ -388,17 +402,29 @@ export function TreatmentWorkspace({ const previewTreatment = useMemo(() => { if (!selectedPreviewId) return currentDraftPreview; - return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview; - }, [selectedPreviewId, historyPanelItems, currentDraftPreview]); + return ( + history.find((item) => item.id === selectedPreviewId) ?? + historyPanelItems.find((item) => item.id === selectedPreviewId) ?? + currentDraftPreview + ); + }, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]); - const isPreviewAlreadyOpen = useMemo(() => { - if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false; - if (selectedAppointmentId !== previewTreatment.appointmentId) return false; - if (workspaceMode === 'historical') return true; - if (workspaceMode === 'live' && selectedPreviewId === null) return true; - if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true; - return false; - }, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]); + const isBrowsing = selectedPreviewId !== null; + + const previewHeading = isBrowsing + ? t('previewBrowsingTitle') + : t('previewCurrentDraft'); + + const labAttentionItems = useMemo( + () => + collectLabDispatchAttention( + labDependentCodes, + currentDraftPreview, + history, + selectedAppointmentId, + ), + [labDependentCodes, currentDraftPreview, history, selectedAppointmentId], + ); const hydrateFromTreatment = useCallback((treatment: PastTreatment) => { const mapped = treatment.details.map(mapDetailFromApi); @@ -417,11 +443,6 @@ export function TreatmentWorkspace({ setSaveStatus('idle'); }, []); - const activeDetail = useMemo( - () => details.find((d) => d.clientId === activeDetailId) ?? details[0], - [details, activeDetailId], - ); - const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const wholePlanTeethSet = useMemo(() => { @@ -459,10 +480,6 @@ export function TreatmentWorkspace({ setActiveLabCaseId(match?.clientId ?? null); }, [activeDetailId, labCaseDrafts]); - useEffect(() => { - setSelectionLocked(false); - }, [selectedDay]); - useEffect(() => { let cancelled = false; setApptsLoading(true); @@ -550,28 +567,34 @@ export function TreatmentWorkspace({ setHistoryLoading(false); return; } - setHistoryPatientId(selectedAppointment.patientId); + const nextPatientId = selectedAppointment.patientId; + setHistoryPatientId((prev) => { + if (prev !== nextPatientId) { + setHistory([]); + setHistoryLoading(true); + } + return nextPatientId; + }); }, [selectedAppointment?.patientId]); useEffect(() => { if (!historyPatientId) return; - let cancelled = false; + const requestId = ++historyRequestRef.current; setHistoryLoading(true); void (async () => { try { - const response = await treatmentsApi.listPatientHistory(historyPatientId); - if (!cancelled) setHistory(response.data); + const response = await treatmentsApi.listPatientHistory(historyPatientId, 50); + if (requestId !== historyRequestRef.current) return; + setHistory(response.data); } catch (error: unknown) { - if (!cancelled) { - showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); - } + if (requestId !== historyRequestRef.current) return; + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); } finally { - if (!cancelled) setHistoryLoading(false); + if (requestId === historyRequestRef.current) { + setHistoryLoading(false); + } } })(); - return () => { - cancelled = true; - }; }, [historyPatientId, showError, t]); useEffect(() => { @@ -652,6 +675,16 @@ export function TreatmentWorkspace({ }); } + if (!areDetailsPersistable(currentDetails)) { + return detailsToPreviewTreatment(currentDetails, { + title: t('treatmentPlanTitle', { + patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, + }), + patientId: selectedAppointment.patientId, + treatmentAt: selectedAppointment.startAt, + }); + } + const response = await treatmentsApi.saveDraft(selectedAppointment.id, { details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ clientId, @@ -674,6 +707,18 @@ export function TreatmentWorkspace({ [selectedAppointment, t], ); + const refreshHistory = useCallback(async (patientId: string) => { + const requestId = ++historyRequestRef.current; + try { + const response = await treatmentsApi.listPatientHistory(patientId, 50); + if (requestId !== historyRequestRef.current) return; + setHistory(response.data); + } catch (error: unknown) { + if (requestId !== historyRequestRef.current) return; + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); + } + }, [showError, t]); + const runDraftSave = useCallback(async () => { if (!selectedAppointment || saveInFlightRef.current) { if (saveInFlightRef.current) saveQueuedRef.current = true; @@ -681,7 +726,8 @@ export function TreatmentWorkspace({ } if ( - !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) + !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) || + !areDetailsPersistable(detailsRef.current) ) { return; } @@ -691,6 +737,9 @@ export function TreatmentWorkspace({ try { await persistDraft(); setSaveStatus('saved'); + if (historyPatientId) { + await refreshHistory(historyPatientId); + } } catch (error: unknown) { setSaveStatus('error'); showError(getUserFacingError(error, tErrors, t('errorSaveDraft'))); @@ -704,16 +753,7 @@ export function TreatmentWorkspace({ } } } - }, [selectedAppointment, persistDraft, showError, t]); - - const refreshHistory = useCallback(async (patientId: string) => { - try { - const response = await treatmentsApi.listPatientHistory(patientId); - setHistory(response.data); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); - } - }, [showError, t]); + }, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]); const flushDraftSave = useCallback(async (): Promise => { if (autosaveTimerRef.current) { @@ -735,14 +775,11 @@ export function TreatmentWorkspace({ try { await runDraftSave(); - if (historyPatientId) { - await refreshHistory(historyPatientId); - } return true; } catch { return window.confirm(t('confirmDiscard')); } - }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]); + }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]); useEffect(() => { if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) { @@ -787,6 +824,8 @@ export function TreatmentWorkspace({ [flushDraftSave, resetToLiveContext], ); + const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations'); + const onSelectDay = useCallback( (day: Date) => { void (async () => { @@ -794,7 +833,8 @@ export function TreatmentWorkspace({ if (!ok) return; const patientIdToRefresh = historyPatientId; resetToLiveContext(); - setSelectedDay(day); + setSelectionLocked(false); + setSelectedDay(startOfLocalDay(day)); if (patientIdToRefresh) { await refreshHistory(patientIdToRefresh); } @@ -807,22 +847,23 @@ export function TreatmentWorkspace({ setSelectedPreviewId(treatment.id); }, []); - const handleOpenTreatment = useCallback(() => { - void (async () => { - const treatment = previewTreatment; - if (!treatment?.appointmentId) { + const exitBrowse = useCallback(() => { + setSelectedPreviewId(null); + }, []); + + const loadTreatmentIntoWorkspace = useCallback( + async (treatment: PastTreatment, focusDetailClientId?: string) => { + if (!treatment.appointmentId) { showError(t('errorNoAppointmentForTreatment')); - return; + return false; } - if (isPreviewAlreadyOpen) return; - const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true; - if (!ok) return; + if (!ok) return false; const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart); setWorkspaceMode(isHistorical ? 'historical' : 'live'); - setSelectedPreviewId(treatment.id); + setSelectedPreviewId(null); setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt))); setSelectionLocked(true); setSelectedAppointmentId(treatment.appointmentId); @@ -831,16 +872,58 @@ export function TreatmentWorkspace({ draftHydratingRef.current = true; hydrateFromTreatment(treatment); draftHydratingRef.current = false; - })(); - }, [ - previewTreatment, - isPreviewAlreadyOpen, - flushDraftSave, - hydrateFromTreatment, - showError, - t, - todayStart, - ]); + + if (focusDetailClientId) { + setActiveDetailId(focusDetailClientId); + const mappedLabCases = withoutEmptyLabCaseDrafts( + (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), + ); + const linked = mappedLabCases.find( + (lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId, + ); + if (linked) { + setActiveLabCaseId(linked.clientId); + } + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + } + + return true; + }, + [flushDraftSave, hydrateFromTreatment, showError, t, todayStart], + ); + + const handleLoadIntoWorkspace = useCallback(() => { + if (!previewTreatment) return; + void loadTreatmentIntoWorkspace(previewTreatment); + }, [loadTreatmentIntoWorkspace, previewTreatment]); + + const handleGoToLabDispatch = useCallback( + (item: LabDispatchAttentionItem) => { + if (item.isCurrentDraft) { + exitBrowse(); + setActiveDetailId(item.detailClientId); + const linked = labCaseDrafts.find( + (lc) => !lc.sentAt && lc.detailClientId === item.detailClientId, + ); + if (linked) { + setActiveLabCaseId(linked.clientId); + } + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + return; + } + + const treatment = + history.find((entry) => entry.id === item.treatmentId) ?? + historyPanelItems.find((entry) => entry.id === item.treatmentId); + if (!treatment) return; + void loadTreatmentIntoWorkspace(treatment, item.detailClientId); + }, + [exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], + ); const uploadForDetail = useCallback( async (detailClientId: string, files: FileList | File[]) => { @@ -984,8 +1067,17 @@ export function TreatmentWorkspace({ } const activeDetail = details.find((d) => d.clientId === activeDetailId); - const shouldIncludeActive = - Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)); + if ( + activeDetail && + isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes) + ) { + showError(t('labShipmentBlockedBody')); + return; + } + + const shouldIncludeActive = Boolean( + activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes), + ); const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId); if (orphan && shouldIncludeActive) { @@ -1068,21 +1160,28 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.sendLabCase(refreshedLabCase.id); - const sentDetailClientId = labCase.detailClientId; - setDetails((prev) => - prev.map((detail) => { - if (detail.clientId !== sentDetailClientId) return detail; - return { - ...detail, - labCaseId: response.data.id, - sentAt: response.data.sentAt, - sends: response.data.sends, - sendToOrganizationIds: response.data.destinationOrganizationId - ? [response.data.destinationOrganizationId] - : detail.sendToOrganizationIds, - }; - }), - ); + const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id); + if (draftResponse.data?.details?.length) { + const mapped = draftResponse.data.details.map(mapDetailFromApi); + setDetails(mapped); + setSavedSnapshot(serializeDetails(mapped)); + } else { + const sentDetailClientId = labCase.detailClientId; + setDetails((prev) => + prev.map((detail) => { + if (detail.clientId !== sentDetailClientId) return detail; + return { + ...detail, + labCaseId: response.data.id, + sentAt: response.data.sentAt, + sends: response.data.sends, + sendToOrganizationIds: response.data.destinationOrganizationId + ? [response.data.destinationOrganizationId] + : detail.sendToOrganizationIds, + }; + }), + ); + } setLabCaseDrafts((prev) => prev.map((lc) => @@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
)} + + + {isBrowsing && previewTreatment ? ( +
+

+ {t('browseBanner', { + date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }), + })} +

+
+ + +
+
+ ) : null} + 1 ? ( - + ) : undefined } onToggle={(fdi) => { @@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({ setActiveDetailId(next.clientId); }} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} + onCommentError={showError} /> +
+ {showLabShipmentBlocked ? : null} + {showLabDispatchPanel ? ( void handleAddLabCase()} onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)} onCommentError={showError} + canInviteLab={canAccessOrganizations} + onInviteLab={() => router.push('/organizations?action=invite-lab')} /> + ) : null} +
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index 3a5d233..9a76cc0 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -69,6 +69,11 @@ export interface LabCaseSendInfo { /** @deprecated Use LabCaseSendInfo */ export type TreatmentCaseSendInfo = LabCaseSendInfo; +export interface LabCaseTaskProgress { + completed: number; + total: number; +} + export interface PastTreatmentDetail { id: string; clientId: string; @@ -78,6 +83,7 @@ export interface PastTreatmentDetail { attachmentMetas?: TreatmentAttachmentMeta[]; labCaseId?: string | null; destinationOrganizationId?: string | null; + taskProgress?: LabCaseTaskProgress | null; sends?: LabCaseSendInfo[]; sentAt?: string | null; } @@ -131,6 +137,7 @@ export interface TreatmentDetailDraft { comment: string; attachmentMetas: TreatmentAttachmentMeta[]; labCaseId?: string | null; + taskProgress?: LabCaseTaskProgress | null; sendToOrganizationIds: string[]; sends?: LabCaseSendInfo[]; sentAt?: string | null; -- 2.53.0.windows.1 From 4e6ed7584459adef6c7bddf784cf46f29a56731c Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 02:09:49 +0330 Subject: [PATCH 11/24] improvement: tasks feature UX fully overhauled. --- .cursor/rules/lab-tasks.mdc | 14 + .cursor/skills/lab-tasks/SKILL.md | 55 +++ AGENTS.md | 3 + backend/src/modules/tasks/dto/tasks.dto.ts | 5 + backend/src/modules/tasks/tasks.controller.ts | 11 + backend/src/modules/tasks/tasks.module.ts | 2 + backend/src/modules/tasks/tasks.service.ts | 136 ++++++-- frontend/messages/en.json | 9 + frontend/messages/fa.json | 9 + frontend/messages/nl.json | 9 + .../src/components/lab/taskListGrouping.ts | 89 +++++ .../components/shared/catalog-type-colors.ts | 18 +- .../treatment/prosthesisTypeDisplay.ts | 33 ++ .../src/components/ui/lab/CaseDetailPanel.tsx | 22 +- .../components/ui/lab/CaseToothChartPanel.tsx | 11 +- .../components/ui/lab/TaskCaseGroupHeader.tsx | 54 +++ .../ui/lab/TaskProsthesisGroupHeader.tsx | 36 ++ frontend/src/components/ui/lab/TaskRow.tsx | 174 ++++++++++ frontend/src/components/ui/lab/TasksPage.tsx | 318 +++++++++--------- .../components/ui/today/TodayDashboard.tsx | 22 +- frontend/src/lib/api/tasks.ts | 6 + frontend/src/types/cases.ts | 7 + 22 files changed, 833 insertions(+), 210 deletions(-) create mode 100644 .cursor/rules/lab-tasks.mdc create mode 100644 .cursor/skills/lab-tasks/SKILL.md create mode 100644 frontend/src/components/lab/taskListGrouping.ts create mode 100644 frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx create mode 100644 frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx create mode 100644 frontend/src/components/ui/lab/TaskRow.tsx diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc new file mode 100644 index 0000000..7049047 --- /dev/null +++ b/.cursor/rules/lab-tasks.mdc @@ -0,0 +1,14 @@ +--- +description: Lab Tasks tab — sort, grouping, filters, prosthesis colors +globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/** +alwaysApply: false +--- + +# Lab Tasks + +- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`). +- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. +- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. +- **Step completed filter:** `stepCompleted` query param; groups where that step is `COMPLETED`; with `IN_PROGRESS` status shows remaining open tasks only. + +Full map: `.cursor/skills/lab-tasks/SKILL.md` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md new file mode 100644 index 0000000..92a7d4a --- /dev/null +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -0,0 +1,55 @@ +--- +name: dyolink-lab-tasks +description: Lab Tasks tab — list, sort, filters, case grouping, prosthesis colors, step-completed filter. Use when changing TasksPage, tasks API, or lab task list UX. +--- + +# Lab Tasks + +Main UI: [`frontend/src/components/ui/lab/TasksPage.tsx`](frontend/src/components/ui/lab/TasksPage.tsx) +Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/) + +## Default sort (backend) + +`sortBy=date` + `sortDir=desc`: + +1. `labCase.sentAt` desc (newest case first) +2. `labCaseId`, `treatmentDetailId`, `prosthesisTypeCode` asc (stable grouping) +3. `stepOrder` asc (steps 1→N within prosthesis group) +4. `id` asc + +Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker. + +## Case grouping (frontend) + +- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header → task rows. +- **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context. + +Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. + +## Prosthesis colors + +- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code). +- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeBadgeStyleFromCatalog(code, catalog)`, **not** list row index. +- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard. + +## Filters + +| Param | API | UI | +|-------|-----|-----| +| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | +| `stepCompleted` | `GET /tasks` | Workflow step dropdown | +| Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | + +**Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). + +## APIs + +- `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) +- `PATCH /tasks/:taskId` — update status +- `GET /tasks/filter-options` — clinics + workflow steps (localized) + +List items include `caseSentAt` for case headers. + +## Permissions + +`TAB_TASKS_READ` / `TAB_TASKS_EDIT`; `LabOrgGuard` on all task routes. diff --git a/AGENTS.md b/AGENTS.md index 62e2675..f21ba25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,8 @@ frontend/src/ - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). +**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog — see `.cursor/skills/lab-tasks/SKILL.md`. + ## Backend layout ``` @@ -67,6 +69,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | | `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | +| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index c1ffe75..f93dc12 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -65,6 +65,11 @@ export class ListLabTasksDto { @IsDateString() sentTo?: string; + /** Workflow step code (e.g. design) completed within the prosthesis group. */ + @IsOptional() + @IsString() + stepCompleted?: string; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index 279d86c..7ea7788 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -19,6 +19,17 @@ export class TasksController { return this.tasksService.list(organizationId, req.user.id, query, req.user.language); } + @Get('filter-options') + @ApiOperation({ summary: 'Filter options for lab tasks list' }) + listFilterOptions(@Req() req) { + const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); + return this.tasksService.listFilterOptions( + organizationId, + req.user.id, + req.user.language, + ); + } + @Patch(':taskId') @ApiOperation({ summary: 'Update task status' }) updateStatus( diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts index 76ba304..d65d0b7 100644 --- a/backend/src/modules/tasks/tasks.module.ts +++ b/backend/src/modules/tasks/tasks.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { CatalogModule } from '../catalog/catalog.module'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ + imports: [CatalogModule], controllers: [TasksController], providers: [TasksService], }) diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 5b76b17..7a5c8b8 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -55,7 +55,7 @@ export class TasksService { const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const skip = (page - 1) * limit; - const where = this.buildListWhere(labOrganizationId, query); + const where = await this.buildListWhere(labOrganizationId, query); const [items, total] = await Promise.all([ this.prisma.labCaseTask.findMany({ @@ -149,10 +149,60 @@ export class TasksService { return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) }; } - private buildListWhere( + async listFilterOptions( + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanReadTasks(actorUserId, labOrganizationId); + + const rows = await this.prisma.labCase.findMany({ + where: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + select: { + treatment: { + select: { + organization: { select: { id: true, name: true } }, + }, + }, + }, + }); + + const clinicsById = new Map(); + for (const row of rows) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + } + + const locale = normalizeCatalogLocale(localeInput); + const steps = await this.prisma.labWorkflowStep.findMany({ + orderBy: { sortOrder: 'asc' }, + select: { code: true }, + }); + const stepCodes = steps.map((s) => s.code); + const stepLabels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.LAB_WORKFLOW_STEP, + stepCodes, + locale, + ); + + return { + success: true, + data: { + clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)), + workflowSteps: steps.map((step) => ({ + code: step.code, + label: stepLabels.get(step.code) ?? step.code, + })), + }, + }; + } + + private async buildListWhere( labOrganizationId: string, query: ListLabTasksDto, - ): Prisma.LabCaseTaskWhereInput { + ): Promise { const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; if (query.sentFrom) { @@ -181,18 +231,47 @@ export class TasksService { status = LabTaskStatus.IN_PROGRESS; } - return { - labCase: { - sentAt: sentAtFilter, - sends: { some: { organizationId: labOrganizationId } }, - ...(query.clinicOrganizationId - ? { treatment: { organizationId: query.clinicOrganizationId } } - : {}), - ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), - ...(query.important !== undefined ? { isImportant: query.important } : {}), - }, + const labCaseScope: Prisma.LabCaseWhereInput = { + sentAt: sentAtFilter, + sends: { some: { organizationId: labOrganizationId } }, + ...(query.clinicOrganizationId + ? { treatment: { organizationId: query.clinicOrganizationId } } + : {}), + ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + ...(query.important !== undefined ? { isImportant: query.important } : {}), + }; + + const base: Prisma.LabCaseTaskWhereInput = { + labCase: labCaseScope, ...(status !== undefined ? { status } : {}), }; + + const stepCompleted = query.stepCompleted?.trim(); + if (!stepCompleted) { + return base; + } + + const completedGroups = await this.prisma.labCaseTask.groupBy({ + by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'], + where: { + workflowStepCode: stepCompleted, + status: LabTaskStatus.COMPLETED, + labCase: labCaseScope, + }, + }); + + if (completedGroups.length === 0) { + return { id: { in: [] } }; + } + + return { + ...base, + OR: completedGroups.map((group) => ({ + labCaseId: group.labCaseId, + treatmentDetailId: group.treatmentDetailId, + prosthesisTypeCode: group.prosthesisTypeCode, + })), + }; } private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { @@ -214,40 +293,50 @@ export class TasksService { private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { const dir = query.sortDir ?? 'desc'; + const stepTiebreakers: Prisma.LabCaseTaskOrderByWithRelationInput[] = [ + { stepOrder: 'asc' }, + { id: 'asc' }, + ]; + switch (query.sortBy) { case 'status': - return [{ status: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers]; case 'clinic': return [ { labCase: { treatment: { organization: { name: dir } } } }, { createdAt: 'desc' }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'patient': return [ { labCase: { treatment: { patient: { lastName: dir } } } }, { labCase: { treatment: { patient: { firstName: dir } } } }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'important': - return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, { id: 'asc' }]; + return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers]; case 'prosthesis': - return [{ prosthesisTypeCode: dir }, { createdAt: 'desc' }, { id: 'asc' }]; + return [ + { prosthesisTypeCode: dir }, + { createdAt: 'desc' }, + ...stepTiebreakers, + ]; case 'taskType': return [ { workflowStepCode: dir }, { stepOrder: 'asc' }, { createdAt: 'desc' }, - { id: 'asc' }, + ...stepTiebreakers, ]; case 'date': default: - // date / caseId / taskId / stepId — newest first by default. return [ { labCase: { sentAt: dir } }, - { labCaseId: dir }, - { id: dir }, - { stepOrder: dir }, + { labCaseId: 'asc' }, + { treatmentDetailId: 'asc' }, + { prosthesisTypeCode: 'asc' }, + { stepOrder: 'asc' }, + { id: 'asc' }, ]; } } @@ -275,6 +364,7 @@ export class TasksService { ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, createdAt: task.createdAt.toISOString(), + caseSentAt: task.labCase.sentAt?.toISOString() ?? null, clinic: task.labCase.treatment.organization, patient: { id: task.labCase.treatment.patient.id, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index fc13a73..7b9abb9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -479,6 +479,8 @@ "filterClinicAll": "All clinics", "filterStatus": "Status", "filterStatusAll": "All statuses", + "filterStepCompleted": "Step completed", + "filterStepCompletedAll": "Any step", "showCompleted": "Show completed", "importantOnly": "Important only", "filterSentFrom": "From", @@ -492,6 +494,13 @@ "sortProsthesis": "Prosthesis type", "sortTaskType": "Task type", "sortDirection": "Sort direction", + "groupingOffClinic": "Sorted by clinic — case grouping is off.", + "groupingOffPatient": "Sorted by patient — case grouping is off.", + "groupingOffProsthesis": "Sorted by prosthesis type — case grouping is off.", + "groupingOffTaskType": "Sorted by task type — case grouping is off.", + "groupingOffStatus": "Sorted by status — case grouping is off.", + "caseReceivedAt": "Received {date}", + "caseTaskProgress": "{completed}/{total} tasks on this page", "clearFilters": "Clear filters", "commentsButton": "Comments", "errorLoadList": "Failed to load tasks.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c9ed74f..4710503 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -480,6 +480,8 @@ "filterClinicAll": "همه کلینیک‌ها", "filterStatus": "وضعیت", "filterStatusAll": "همه وضعیت‌ها", + "filterStepCompleted": "مرحله تکمیل‌شده", + "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", "importantOnly": "فقط مهم‌ها", "filterSentFrom": "از", @@ -493,6 +495,13 @@ "sortProsthesis": "نوع پروتز", "sortTaskType": "نوع کار", "sortDirection": "جهت مرتب‌سازی", + "groupingOffClinic": "مرتب‌سازی بر اساس کلینیک — گروه‌بندی پرونده غیرفعال است.", + "groupingOffPatient": "مرتب‌سازی بر اساس بیمار — گروه‌بندی پرونده غیرفعال است.", + "groupingOffProsthesis": "مرتب‌سازی بر اساس نوع پروتز — گروه‌بندی پرونده غیرفعال است.", + "groupingOffTaskType": "مرتب‌سازی بر اساس نوع کار — گروه‌بندی پرونده غیرفعال است.", + "groupingOffStatus": "مرتب‌سازی بر اساس وضعیت — گروه‌بندی پرونده غیرفعال است.", + "caseReceivedAt": "دریافت {date}", + "caseTaskProgress": "{completed}/{total} کار در این صفحه", "clearFilters": "پاک کردن فیلترها", "commentsButton": "نظرات", "errorLoadList": "بارگذاری وظایف ناموفق بود.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index f9bf5fd..4cb1339 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -480,6 +480,8 @@ "filterClinicAll": "Alle klinieken", "filterStatus": "Status", "filterStatusAll": "Alle statussen", + "filterStepCompleted": "Stap voltooid", + "filterStepCompletedAll": "Elke stap", "showCompleted": "Voltooide tonen", "importantOnly": "Alleen belangrijk", "filterSentFrom": "Vanaf", @@ -493,6 +495,13 @@ "sortProsthesis": "Prothesetype", "sortTaskType": "Taaktype", "sortDirection": "Sorteerrichting", + "groupingOffClinic": "Gesorteerd op kliniek — casagroepering is uit.", + "groupingOffPatient": "Gesorteerd op patiënt — casagroepering is uit.", + "groupingOffProsthesis": "Gesorteerd op prothesetype — casagroepering is uit.", + "groupingOffTaskType": "Gesorteerd op taaktype — casagroepering is uit.", + "groupingOffStatus": "Gesorteerd op status — casagroepering is uit.", + "caseReceivedAt": "Ontvangen {date}", + "caseTaskProgress": "{completed}/{total} taken op deze pagina", "clearFilters": "Filters wissen", "commentsButton": "Opmerkingen", "errorLoadList": "Taken laden mislukt.", diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts new file mode 100644 index 0000000..14d52f0 --- /dev/null +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -0,0 +1,89 @@ +import type { LabTaskListItem, TaskSortField } from '@/types/cases'; + +export type ProsthesisTaskGroup = { + key: string; + treatmentDetailId: string; + prosthesisTypeCode: string; + prosthesisTypeLabel: string; + teeth: string[]; + tasks: LabTaskListItem[]; +}; + +export type CaseTaskGroup = { + labCaseId: string; + clinic: LabTaskListItem['clinic']; + patient: LabTaskListItem['patient']; + caseSentAt: string | null; + isImportant: boolean; + prosthesisGroups: ProsthesisTaskGroup[]; +}; + +export type TaskDisplayModel = + | { mode: 'grouped'; cases: CaseTaskGroup[] } + | { mode: 'flat'; tasks: LabTaskListItem[] }; + +function prosthesisGroupKey(task: LabTaskListItem): string { + return `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; +} + +export function groupTasksForDisplay( + tasks: LabTaskListItem[], + sortBy: TaskSortField, +): TaskDisplayModel { + if (sortBy !== 'date') { + return { mode: 'flat', tasks }; + } + + const cases: CaseTaskGroup[] = []; + const caseIndex = new Map(); + + for (const task of tasks) { + let caseIdx = caseIndex.get(task.labCaseId); + if (caseIdx === undefined) { + caseIdx = cases.length; + caseIndex.set(task.labCaseId, caseIdx); + cases.push({ + labCaseId: task.labCaseId, + clinic: task.clinic, + patient: task.patient, + caseSentAt: task.caseSentAt ?? null, + isImportant: task.isImportant, + prosthesisGroups: [], + }); + } + + const caseGroup = cases[caseIdx]; + const pgKey = prosthesisGroupKey(task); + let prosthesisGroup = caseGroup.prosthesisGroups.find((g) => g.key === pgKey); + if (!prosthesisGroup) { + prosthesisGroup = { + key: pgKey, + treatmentDetailId: task.treatmentDetailId, + prosthesisTypeCode: task.prosthesisTypeCode, + prosthesisTypeLabel: task.prosthesisTypeLabel, + teeth: task.teeth, + tasks: [], + }; + caseGroup.prosthesisGroups.push(prosthesisGroup); + } + + prosthesisGroup.tasks.push(task); + } + + return { mode: 'grouped', cases }; +} + +export function countCaseTaskProgress(caseGroup: CaseTaskGroup): { + completed: number; + total: number; +} { + let completed = 0; + let total = 0; + for (const group of caseGroup.prosthesisGroups) { + for (const task of group.tasks) { + total += 1; + if (task.status === 'COMPLETED') completed += 1; + } + } + return { completed, total }; +} diff --git a/frontend/src/components/shared/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts index 5ae0452..9f8be02 100644 --- a/frontend/src/components/shared/catalog-type-colors.ts +++ b/frontend/src/components/shared/catalog-type-colors.ts @@ -24,22 +24,22 @@ export const TREATMENT_TYPE_COLORS: Record = { export const PROSTHESIS_TYPE_COLORS: Record = { pfm_crown: '#e2e8f0', pfz_crown: '#bbf7d0', - monolithic_zirconia: '#e0f2fe', + monolithic_zirconia: '#bae6fd', glass_ceramic_crown: '#fef08a', full_metal_crown: '#d4d4d8', - temporary_resin_crown: '#bae6fd', - pmma: '#7dd3fc', - peek_crown: '#5eead4', - veneer_zirconia: '#6ee7b7', + temporary_resin_crown: '#ddd6fe', + pmma: '#fcd34d', + peek_crown: '#fda4af', + veneer_zirconia: '#86efac', veneer_ips_press: '#fed7aa', veneer_ips_cad: '#fdba74', - soft_structure: '#ddd6fe', + soft_structure: '#e9d5ff', customized_abutment: '#a5b4fc', prefabricated_abutment: '#c7d2fe', - ti_base_abutment: '#bfdbfe', + ti_base_abutment: '#93c5fd', multi_unit_abutment: '#818cf8', - zirconia_abutment: '#34d399', - screw_retained: '#e9d5ff', + zirconia_abutment: '#4ade80', + screw_retained: '#f0abfc', zirconia_overlay: '#2dd4bf', ips_overlay: '#fef3c7', smile_design: '#f9a8d4', diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts index 4f46f07..0592030 100644 --- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -1,4 +1,5 @@ import type { CSSProperties } from 'react'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import { PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, @@ -15,15 +16,40 @@ import { /** Dark ink that stays readable on every pastel in the palette. */ const BADGE_INK = '#14253d'; +/** Stable catalog index for fallback colors — use sortOrder, not list row position. */ +export function prosthesisCatalogColorIndex( + code: string, + catalog: readonly Pick[], +): number { + const entry = catalog.find((e) => e.code === code); + if (entry) return Math.max(0, entry.sortOrder - 1); + const idx = catalog.findIndex((e) => e.code === code); + return idx >= 0 ? idx : 0; +} + export function prosthesisTypeColor(code: string, index = 0): string { return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS); } +export function prosthesisTypeColorFromCatalog( + code: string, + catalog: readonly Pick[], +): string { + return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog)); +} + /** Filled swatch (small indicator dots). */ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties { return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' }; } +export function prosthesisTypeSwatchStyleFromCatalog( + code: string, + catalog: readonly Pick[], +): CSSProperties { + return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog)); +} + /** Pastel pill / banner fill with readable dark text (group headers, badges). */ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties { return { @@ -33,6 +59,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties }; } +export function prosthesisTypeBadgeStyleFromCatalog( + code: string, + catalog: readonly Pick[], +): CSSProperties { + return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog)); +} + export function formatToothList(teeth: string[]): string { return teeth.join(', '); } diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 99d0ab5..5229a8d 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { MessageSquare } from 'lucide-react'; import { Badge } from '@/components/ui/shared/Badge'; @@ -12,8 +12,9 @@ import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachments import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { formatToothList, - prosthesisTypeBadgeStyle, + prosthesisTypeBadgeStyleFromCatalog, } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { buildCaseProsthesisRows, formatCaseDateTime, @@ -21,6 +22,7 @@ import { latestCaseAttachment, } from '@/components/lab/caseDetailUtils'; import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { const pct = total > 0 ? Math.round((completed / total) * 100) : 0; @@ -77,6 +79,14 @@ export function CaseDetailPanel({ }: CaseDetailPanelProps) { const t = useTranslations('cases'); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + + useEffect(() => { + void prosthesisCatalogApi + .list() + .then((response) => setProsthesisCatalog(response.data)) + .catch(() => {}); + }, []); const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]); const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]); @@ -154,6 +164,7 @@ export function CaseDetailPanel({ @@ -177,7 +188,7 @@ export function CaseDetailPanel({ {labCase.tasksByTooth.length === 0 ? (

{t('noTasks')}

) : ( - labCase.tasksByTooth.map((group, groupIndex) => ( + labCase.tasksByTooth.map((group) => (
{group.prosthesisTypeLabel} diff --git a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx index ea51b15..aabbd3a 100644 --- a/frontend/src/components/ui/lab/CaseToothChartPanel.tsx +++ b/frontend/src/components/ui/lab/CaseToothChartPanel.tsx @@ -2,7 +2,8 @@ import { useMemo } from 'react'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; -import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColor, prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId } from '@/types/treatment'; export interface CaseToothChartDetail { @@ -18,6 +19,7 @@ interface CaseToothChartPanelProps { details: CaseToothChartDetail[]; /** Prosthesis mapping from case tasks or toothProsthesis rows. */ prosthesisRows: CaseToothChartProsthesisRow[]; + prosthesisCatalog?: readonly ProsthesisCatalogEntry[]; scale?: number; compact?: boolean; className?: string; @@ -27,6 +29,7 @@ interface CaseToothChartPanelProps { export function CaseToothChartPanel({ details, prosthesisRows, + prosthesisCatalog, scale = 1, compact = true, className = '', @@ -42,13 +45,15 @@ export function CaseToothChartPanel({ const toothColors = useMemo(() => { const colors: Partial> = {}; prosthesisRows.forEach((row, index) => { - const color = prosthesisTypeColor(row.prosthesisTypeCode, index); + const color = prosthesisCatalog?.length + ? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog) + : prosthesisTypeColor(row.prosthesisTypeCode, index); for (const tooth of row.teeth) { colors[tooth as FdiToothId] = color; } }); return colors; - }, [prosthesisRows]); + }, [prosthesisRows, prosthesisCatalog]); if (selected.size === 0) return null; diff --git a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx new file mode 100644 index 0000000..a235d5d --- /dev/null +++ b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Badge } from '@/components/ui/shared/Badge'; +import type { CaseTaskGroup } from '@/components/lab/taskListGrouping'; +import { countCaseTaskProgress } from '@/components/lab/taskListGrouping'; + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +interface TaskCaseGroupHeaderProps { + caseGroup: CaseTaskGroup; + locale: string; +} + +export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) { + const t = useTranslations('tasks'); + const progress = countCaseTaskProgress(caseGroup); + + const sentLabel = caseGroup.caseSentAt + ? new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(caseGroup.caseSentAt)) + : null; + + return ( +
+
+
+

+ {t('fromClinic', { name: caseGroup.clinic.name })} ·{' '} + {formatPatientName(caseGroup.patient)} +

+ {caseGroup.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
+ {sentLabel ? ( +

+ {t('caseReceivedAt', { date: sentLabel })} +

+ ) : null} +
+

+ {t('caseTaskProgress', { completed: progress.completed, total: progress.total })} +

+
+ ); +} diff --git a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx new file mode 100644 index 0000000..6198839 --- /dev/null +++ b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Badge } from '@/components/ui/shared/Badge'; +import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +interface TaskProsthesisGroupHeaderProps { + group: ProsthesisTaskGroup; + prosthesisCatalog: readonly ProsthesisCatalogEntry[]; +} + +export function TaskProsthesisGroupHeader({ + group, + prosthesisCatalog, +}: TaskProsthesisGroupHeaderProps) { + const t = useTranslations('tasks'); + + return ( +
+ + {group.prosthesisTypeLabel} + + + {t('teethLabel', { teeth: formatToothList(group.teeth) })} + +
+ ); +} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx new file mode 100644 index 0000000..8fc63e5 --- /dev/null +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { MessageSquare } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Badge } from '@/components/ui/shared/Badge'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { + labTaskStatusSelectStyle, + labTaskStatusVariant, +} from '@/components/lab/labTaskStatusDisplay'; +import { + formatToothList, + prosthesisTypeBadgeStyleFromCatalog, +} from '@/components/treatment/prosthesisTypeDisplay'; +import { tasksApi } from '@/lib/api/tasks'; +import type { LabTaskListItem, LabTaskStatus } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +interface TaskRowProps { + task: LabTaskListItem; + locale: string; + flatMode: boolean; + canEdit: boolean; + statusOptions: { value: LabTaskStatus; label: string }[]; + updatingTaskId: string | null; + commentsOpen: boolean; + prosthesisCatalog: readonly ProsthesisCatalogEntry[]; + onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; + onToggleComments: (taskId: string) => void; + onCommentError: (message: string) => void; +} + +function formatPatientName(patient: { firstName: string; lastName: string }) { + return `${patient.firstName} ${patient.lastName}`.trim(); +} + +export function TaskRow({ + task, + locale, + flatMode, + canEdit, + statusOptions, + updatingTaskId, + commentsOpen, + prosthesisCatalog, + onStatusUpdate, + onToggleComments, + onCommentError, +}: TaskRowProps) { + const t = useTranslations('tasks'); + const taskDate = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(task.createdAt)); + + return ( +
  • +
    +
    +
    +

    + {task.stepOrder}. {task.stepLabel} +

    + {flatMode && task.isImportant ? ( + + {t('importantBadge')} + + ) : null} +
    + {flatMode ? ( +

    + {t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '} + · {t('teethLabel', { teeth: formatToothList(task.teeth) })} +

    + ) : null} +

    + {t('taskDate', { date: taskDate })} + {task.lastStatusChangedBy ? ( + <> + · + {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} + + ) : null} +

    +
    + +
    + {canEdit ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} + + )} +
    + +
    + {canEdit ? ( + + ) : null} + {flatMode ? ( + + {task.prosthesisTypeLabel} + + ) : null} +
    +
    + + {commentsOpen && canEdit ? ( +
    + { + const r = await tasksApi.listComments(task.labCaseId); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await tasksApi.addComment(task.labCaseId, { + body, + visibleToClinic, + }); + return r.data; + }} + onToggleVisibility={async (commentId, visible) => { + const r = await tasksApi.setCommentVisibility(commentId, visible); + return r.data; + }} + onError={onCommentError} + /> +
    + ) : null} +
  • + ); +} diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index 005f8f3..8aead1e 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -2,39 +2,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { MessageSquare } from 'lucide-react'; -import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; -import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; -import { - labTaskStatusSelectStyle, - labTaskStatusVariant, -} from '@/components/lab/labTaskStatusDisplay'; -import { - formatToothList, - prosthesisTypeBadgeStyle, -} from '@/components/treatment/prosthesisTypeDisplay'; +import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader'; +import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader'; +import { TaskRow } from '@/components/ui/lab/TaskRow'; +import { groupTasksForDisplay } from '@/components/lab/taskListGrouping'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { canEditTasks, canViewTasks } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { tasksApi } from '@/lib/api/tasks'; import type { LabTaskListItem, LabTaskStatus, ListLabTasksParams, PaginatedLabTasks, + TaskFilterOptions, TaskSortField, } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; const PAGE_SIZE = 50; -function formatPatientName(patient: { firstName: string; lastName: string }) { - return `${patient.firstName} ${patient.lastName}`.trim(); -} - export function TasksPage() { const t = useTranslations('tasks'); const tErrors = useTranslations('errors'); @@ -48,6 +40,11 @@ export function TasksPage() { total: 0, totalPages: 1, }); + const [filterOptions, setFilterOptions] = useState({ + clinics: [], + workflowSteps: [], + }); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); @@ -56,8 +53,7 @@ export function TasksPage() { const [search, setSearch] = useState(''); const [clinicId, setClinicId] = useState(''); const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS'); - const [sentFrom, setSentFrom] = useState(''); - const [sentTo, setSentTo] = useState(''); + const [stepCompleted, setStepCompleted] = useState(''); const [sortBy, setSortBy] = useState('date'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); @@ -86,18 +82,16 @@ export function TasksPage() { if (search.trim()) params.q = search.trim(); if (clinicId) params.clinicOrganizationId = clinicId; if (statusFilter) params.status = statusFilter; - if (sentFrom) params.sentFrom = sentFrom; - if (sentTo) params.sentTo = sentTo; + if (stepCompleted) params.stepCompleted = stepCompleted; return params; - }, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]); + }, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]); - const clinicOptions = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - map.set(task.clinic.id, task.clinic.name); - } - return [...map.entries()].map(([id, name]) => ({ id, name })); - }, [tasks]); + const displayModel = useMemo( + () => groupTasksForDisplay(tasks, sortBy), + [tasks, sortBy], + ); + + const groupingDisabled = sortBy !== 'date'; const loadTasks = useCallback(async () => { setLoading(true); @@ -111,7 +105,7 @@ export function TasksPage() { } finally { setLoading(false); } - }, [listParams, showError, setError]); + }, [listParams, showError, setError, tErrors]); useEffect(() => { if (!canView) return; @@ -119,30 +113,58 @@ export function TasksPage() { return () => clearTimeout(timeout); }, [canView, loadTasks, search]); - async function handleStatusUpdate(taskId: string, status: LabTaskStatus) { - if (!canEdit) return; - setUpdatingTaskId(taskId); - setError(''); - try { - await tasksApi.updateStatus(taskId, status); - await loadTasks(); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); - } finally { - setUpdatingTaskId(null); - } - } + useEffect(() => { + if (!canView) return; + void (async () => { + try { + const [optionsRes, catalogRes] = await Promise.all([ + tasksApi.filterOptions(), + prosthesisCatalogApi.list(), + ]); + setFilterOptions(optionsRes.data); + setProsthesisCatalog(catalogRes.data); + } catch { + // Non-blocking — filters fall back to empty options. + } + })(); + }, [canView]); - function formatTaskDate(value: string) { - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }).format(new Date(value)); - } + const handleStatusUpdate = useCallback( + async (taskId: string, status: LabTaskStatus) => { + if (!canEdit) return; + setUpdatingTaskId(taskId); + setError(''); + try { + await tasksApi.updateStatus(taskId, status); + await loadTasks(); + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + }, + [canEdit, loadTasks, setError, showError, t, tErrors], + ); const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; + const sortHintKey = useMemo(() => { + switch (sortBy) { + case 'clinic': + return 'groupingOffClinic'; + case 'patient': + return 'groupingOffPatient'; + case 'prosthesis': + return 'groupingOffProsthesis'; + case 'taskType': + return 'groupingOffTaskType'; + case 'status': + return 'groupingOffStatus'; + default: + return null; + } + }, [sortBy]); + if (!isAuthReady) { return
    {t('loading')}
    ; } @@ -173,7 +195,7 @@ export function TasksPage() { }} placeholder={t('searchPlaceholder')} /> -
    +
    +
    + {groupingDisabled && sortHintKey ? ( +

    {t(sortHintKey)}

    + ) : null}
    @@ -243,125 +286,65 @@ export function TasksPage() {

    {t('loading')}

    ) : tasks.length === 0 ? (

    {t('emptyList')}

    + ) : displayModel.mode === 'grouped' ? ( +
    + {displayModel.cases.map((caseGroup) => ( +
    + + {caseGroup.prosthesisGroups.map((prosthesisGroup) => ( +
    + +
      + {prosthesisGroup.tasks.map((task) => ( + void handleStatusUpdate(id, status)} + onToggleComments={(id) => + setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) + } + onCommentError={showError} + /> + ))} +
    +
    + ))} +
    + ))} +
    ) : (
      - {tasks.map((task, index) => { - const commentsOpen = expandedCommentsTaskId === task.id; - - return ( -
    • -
      -
      -
      -

      - {task.stepOrder}. {task.stepLabel} -

      - {task.isImportant ? ( - - {t('importantBadge')} - - ) : null} -
      -

      - {t('fromClinic', { name: task.clinic.name })} ·{' '} - {formatPatientName(task.patient)} ·{' '} - {t('teethLabel', { teeth: formatToothList(task.teeth) })} -

      -

      - {t('taskDate', { date: formatTaskDate(task.createdAt) })} - {task.lastStatusChangedBy ? ( - <> - · - - {t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })} - - - ) : null} -

      -
      - -
      - {canEdit ? ( - - ) : ( - - {statusOptions.find((opt) => opt.value === task.status)?.label ?? - task.status} - - )} -
      - -
      - {canEdit ? ( - - ) : null} - - {task.prosthesisTypeLabel} - -
      -
      - - {commentsOpen && canEdit ? ( -
      - { - const r = await tasksApi.listComments(task.labCaseId); - return r.data; - }} - onPost={async (body, visibleToClinic) => { - const r = await tasksApi.addComment(task.labCaseId, { - body, - visibleToClinic, - }); - return r.data; - }} - onToggleVisibility={async (commentId, visible) => { - const r = await tasksApi.setCommentVisibility(commentId, visible); - return r.data; - }} - onError={showError} - /> -
      - ) : null} -
    • - ); - })} + {displayModel.tasks.map((task) => ( + void handleStatusUpdate(id, status)} + onToggleComments={(id) => + setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) + } + onCommentError={showError} + /> + ))}
    )}
    @@ -395,7 +378,6 @@ export function TasksPage() {
    )} -
    ); } diff --git a/frontend/src/components/ui/today/TodayDashboard.tsx b/frontend/src/components/ui/today/TodayDashboard.tsx index c953b34..f0ee49b 100644 --- a/frontend/src/components/ui/today/TodayDashboard.tsx +++ b/frontend/src/components/ui/today/TodayDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; import { @@ -43,8 +43,10 @@ import { type TodayDashboardCell, } from '@/components/today/today-dashboard-layout'; import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry'; -import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { TodayCompletionGauge, TodaySubscriptionSnapshot, @@ -77,6 +79,15 @@ export function TodayDashboard({ const { currentOrganization } = useAuth(); const orgType = currentOrganization?.type; const isOwner = Boolean(currentOrganization?.isOwner); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + + useEffect(() => { + if (orgType !== 'LAB') return; + void prosthesisCatalogApi + .list() + .then((response) => setProsthesisCatalog(response.data)) + .catch(() => {}); + }, [orgType]); const showUpcoming = orgType === 'CLINIC' && @@ -159,6 +170,7 @@ export function TodayDashboard({ orgType, isOwner, currentOrganization, + prosthesisCatalog, }); }, [ isInitialLoad, @@ -177,6 +189,7 @@ export function TodayDashboard({ actions, subscription, currentOrganization, + prosthesisCatalog, ]); if (hasError && !loading && cells.length === 0) { @@ -297,6 +310,7 @@ function buildDashboardCells(options: { orgType?: 'CLINIC' | 'LAB'; isOwner: boolean; currentOrganization: ReturnType['currentOrganization']; + prosthesisCatalog: ProsthesisCatalogEntry[]; }): TodayDashboardCell[] { const cells: TodayDashboardCell[] = []; @@ -318,6 +332,7 @@ function buildDashboardCells(options: { (options.orgType === 'LAB' && canEditCases(options.currentOrganization))), dayLabelFormatter: options.dayLabelFormatter, + prosthesisCatalog: options.prosthesisCatalog, }), ); } @@ -405,6 +420,7 @@ function buildChartCells(options: { showMyAppointmentsWeekChart: boolean; showCasePartnersChart: boolean; dayLabelFormatter: ReturnType; + prosthesisCatalog: ProsthesisCatalogEntry[]; }): TodayDashboardCell[] { const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options; const cells: TodayDashboardCell[] = []; @@ -580,7 +596,7 @@ function buildChartCells(options: { > prosthesisTypeColor(code, index)} + colorForCode={(code) => prosthesisTypeColorFromCatalog(code, options.prosthesisCatalog)} /> ), diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index fa3ecc2..de12817 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -5,6 +5,7 @@ import type { LabTaskStatus, ListLabTasksParams, PaginatedLabTasks, + TaskFilterOptions, } from '@/types/cases'; export const tasksApi = { @@ -15,6 +16,11 @@ export const tasksApi = { return response.data; }, + filterOptions: async (): Promise<{ success: boolean; data: TaskFilterOptions }> => { + const response = await apiClient.get('/tasks/filter-options'); + return response.data; + }, + updateStatus: async ( taskId: string, status: LabTaskStatus, diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index 9a4fe86..0672e46 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -150,6 +150,7 @@ export interface ListLabTasksParams { important?: boolean; sentFrom?: string; sentTo?: string; + stepCompleted?: string; sortBy?: TaskSortField; sortDir?: 'asc' | 'desc'; page?: number; @@ -172,10 +173,16 @@ export interface LabTaskListItem { lastStatusChangedAt: string | null; lastStatusChangedBy: LabTaskUser | null; createdAt: string; + caseSentAt: string | null; clinic: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string }; } +export interface TaskFilterOptions { + clinics: { id: string; name: string }[]; + workflowSteps: { code: string; label: string }[]; +} + export interface PaginatedLabTasks { items: LabTaskListItem[]; pagination: { -- 2.53.0.windows.1 From 0d073f1ec0c868b3fcabc3994fb930c9a9ee1737 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 13:22:38 +0330 Subject: [PATCH 12/24] improvement: sorts and filters updated for tasks feature. --- .cursor/rules/lab-tasks.mdc | 3 +- .cursor/skills/lab-tasks/SKILL.md | 7 + backend/src/modules/cases/cases.service.ts | 2 + backend/src/modules/tasks/dto/tasks.dto.ts | 71 +++++ backend/src/modules/tasks/tasks.controller.ts | 9 +- backend/src/modules/tasks/tasks.service.ts | 186 ++++++++++- frontend/messages/en.json | 9 +- frontend/messages/fa.json | 9 +- frontend/messages/nl.json | 9 +- .../src/components/lab/tasksViewDefaults.ts | 51 +++ frontend/src/components/ui/lab/CasesPage.tsx | 17 +- frontend/src/components/ui/lab/TaskRow.tsx | 49 ++- frontend/src/components/ui/lab/TasksPage.tsx | 290 +++++++++++++----- frontend/src/lib/api/tasks.ts | 9 + frontend/src/styles/globals.css | 25 ++ frontend/src/types/cases.ts | 22 ++ 16 files changed, 666 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/lab/tasksViewDefaults.ts diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index 7049047..bb1e150 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -9,6 +9,7 @@ alwaysApply: false - **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`). - **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. - **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. -- **Step completed filter:** `stepCompleted` query param; groups where that step is `COMPLETED`; with `IN_PROGRESS` status shows remaining open tasks only. +- **Important only:** server-side `important=true` (not client per-page). +- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. Full map: `.cursor/skills/lab-tasks/SKILL.md` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 92a7d4a..faff3ae 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -38,15 +38,22 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | | `stepCompleted` | `GET /tasks` | Workflow step dropdown | +| `important` | `GET /tasks` | Important cases only | | Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | **Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). +- **Important only:** server-side `important=true` on `GET /tasks` (full list pagination, not per-page client filter). +- **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`. +- **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task. +- **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch. + ## APIs - `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) - `PATCH /tasks/:taskId` — update status - `GET /tasks/filter-options` — clinics + workflow steps (localized) +- `GET /tasks/locate-page` — page number for a task in the sorted filtered list List items include `caseSentAt` for case headers. diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index ea5fe64..62c97b6 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -442,6 +442,7 @@ export class CasesService { private mapLabCaseListItem(lc: { id: string; sentAt: Date | null; + isImportant: boolean; treatment: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; @@ -455,6 +456,7 @@ export class CasesService { return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, + isImportant: lc.isImportant, clinic: lc.treatment.organization, patient: { id: lc.treatment.patient.id, diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index f93dc12..86956db 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -57,6 +57,12 @@ export class ListLabTasksDto { @IsBoolean() important?: boolean; + /** When true, important lab cases are listed before others (does not hide non-important). */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + pinImportant?: boolean; + @IsOptional() @IsDateString() sentFrom?: string; @@ -70,6 +76,11 @@ export class ListLabTasksDto { @IsString() stepCompleted?: string; + /** Narrow list to a single lab case (e.g. show-in-case navigation). */ + @IsOptional() + @IsUUID() + labCaseId?: string; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; @@ -91,3 +102,63 @@ export class ListLabTasksDto { @Max(100) limit = 50; } + +/** Same filters as list (no page) — used to find which page contains a task. */ +export class LocateTaskPageDto { + @IsUUID() + taskId: string; + + @IsOptional() + @IsString() + q?: string; + + @IsOptional() + @IsUUID() + clinicOrganizationId?: string; + + @IsOptional() + @IsEnum(LabTaskStatus) + status?: LabTaskStatus; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + completed?: boolean; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + important?: boolean; + + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + pinImportant?: boolean; + + @IsOptional() + @IsDateString() + sentFrom?: string; + + @IsOptional() + @IsDateString() + sentTo?: string; + + @IsOptional() + @IsString() + stepCompleted?: string; + + @IsOptional() + @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) + sortBy?: TaskSortField; + + @IsOptional() + @IsIn(['asc', 'desc']) + sortDir?: 'asc' | 'desc'; + + @IsOptional() + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(100) + limit = 50; +} diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts index 7ea7788..a5bd091 100644 --- a/backend/src/modules/tasks/tasks.controller.ts +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nes import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; +import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { TasksService } from './tasks.service'; @ApiTags('tasks') @@ -19,6 +19,13 @@ export class TasksController { return this.tasksService.list(organizationId, req.user.id, query, req.user.language); } + @Get('locate-page') + @ApiOperation({ summary: 'Find pagination page for a task in the sorted list' }) + locatePage(@Query() query: LocateTaskPageDto, @Req() req) { + const organizationId = this.tasksService.getOrganizationIdFromUser(req.user); + return this.tasksService.locateTaskPage(organizationId, req.user.id, query); + } + @Get('filter-options') @ApiOperation({ summary: 'Filter options for lab tasks list' }) listFilterOptions(@Req() req) { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 7a5c8b8..46619eb 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -12,7 +12,7 @@ import { normalizeCatalogLocale, } from '../catalog/catalog-label.service'; import { normalizeTaskTeeth } from '../cases/lab-case-task.util'; -import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto'; +import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; const taskListInclude = { @@ -90,6 +90,68 @@ export class TasksService { }; } + async locateTaskPage( + labOrganizationId: string, + actorUserId: string, + query: LocateTaskPageDto, + ) { + await this.assertCanReadTasks(actorUserId, labOrganizationId); + + const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); + const listQuery = this.toListQueryFromLocate(query); + + const target = await this.prisma.labCaseTask.findFirst({ + where: { + id: query.taskId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + include: { + labCase: { select: { sentAt: true } }, + }, + }); + + if (!target?.labCase.sentAt) { + throw new NotFoundException('Task not found'); + } + + const where = await this.buildListWhere(labOrganizationId, listQuery); + const inFilteredSet = await this.prisma.labCaseTask.count({ + where: { AND: [where, { id: query.taskId }] }, + }); + + if (inFilteredSet === 0) { + return { + success: true, + data: { page: 1, found: false, labCaseId: target.labCaseId }, + }; + } + + const position = await this.countTasksBeforeSortedPosition( + where, + listQuery, + { + sentAt: target.labCase.sentAt, + labCaseId: target.labCaseId, + treatmentDetailId: target.treatmentDetailId, + prosthesisTypeCode: target.prosthesisTypeCode, + stepOrder: target.stepOrder, + id: target.id, + }, + ); + + return { + success: true, + data: { + page: Math.floor(position / limit) + 1, + found: true, + labCaseId: target.labCaseId, + }, + }; + } + async updateStatus( taskId: string, dto: UpdateLabTaskDto, @@ -238,10 +300,10 @@ export class TasksService { ? { treatment: { organizationId: query.clinicOrganizationId } } : {}), ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), - ...(query.important !== undefined ? { isImportant: query.important } : {}), }; const base: Prisma.LabCaseTaskWhereInput = { + ...(query.labCaseId ? { labCaseId: query.labCaseId } : {}), labCase: labCaseScope, ...(status !== undefined ? { status } : {}), }; @@ -274,6 +336,97 @@ export class TasksService { }; } + private toListQueryFromLocate(query: LocateTaskPageDto): ListLabTasksDto { + return { + page: 1, + q: query.q, + clinicOrganizationId: query.clinicOrganizationId, + status: query.status, + completed: query.completed, + important: query.important, + pinImportant: query.pinImportant, + sentFrom: query.sentFrom, + sentTo: query.sentTo, + stepCompleted: query.stepCompleted, + sortBy: query.sortBy, + sortDir: query.sortDir, + limit: query.limit, + }; + } + + private async countTasksBeforeSortedPosition( + where: Prisma.LabCaseTaskWhereInput, + query: ListLabTasksDto, + target: { + sentAt: Date; + labCaseId: string; + treatmentDetailId: string; + prosthesisTypeCode: string; + stepOrder: number; + id: string; + }, + ): Promise { + const sortBy = query.sortBy ?? 'date'; + const dir = query.sortDir ?? 'desc'; + + if (sortBy !== 'date') { + throw new BadRequestException('Task page location is only supported for date sort'); + } + + const sentAt = target.sentAt; + const sameSentAt = { labCase: { sentAt } }; + const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [ + { + AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }], + }, + { + AND: [ + sameSentAt, + { labCaseId: target.labCaseId }, + { treatmentDetailId: { lt: target.treatmentDetailId } }, + ], + }, + { + AND: [ + sameSentAt, + { labCaseId: target.labCaseId }, + { treatmentDetailId: target.treatmentDetailId }, + { prosthesisTypeCode: { lt: target.prosthesisTypeCode } }, + ], + }, + { + AND: [ + sameSentAt, + { labCaseId: target.labCaseId }, + { treatmentDetailId: target.treatmentDetailId }, + { prosthesisTypeCode: target.prosthesisTypeCode }, + { stepOrder: { lt: target.stepOrder } }, + ], + }, + { + AND: [ + sameSentAt, + { labCaseId: target.labCaseId }, + { treatmentDetailId: target.treatmentDetailId }, + { prosthesisTypeCode: target.prosthesisTypeCode }, + { stepOrder: target.stepOrder }, + { id: { lt: target.id } }, + ], + }, + ]; + + const sentAtBefore: Prisma.LabCaseTaskWhereInput = + dir === 'desc' + ? { labCase: { sentAt: { gt: sentAt } } } + : { labCase: { sentAt: { lt: sentAt } } }; + + return this.prisma.labCaseTask.count({ + where: { + AND: [where, { OR: [sentAtBefore, ...tupleBefore] }], + }, + }); + } + private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { const orConditions: Prisma.PatientWhereInput[] = [ { firstName: { contains: q, mode: 'insensitive' } }, @@ -298,39 +451,47 @@ export class TasksService { { id: 'asc' }, ]; + let orderBy: Prisma.LabCaseTaskOrderByWithRelationInput[]; + switch (query.sortBy) { case 'status': - return [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers]; + orderBy = [{ status: dir }, { createdAt: 'desc' }, ...stepTiebreakers]; + break; case 'clinic': - return [ + orderBy = [ { labCase: { treatment: { organization: { name: dir } } } }, { createdAt: 'desc' }, ...stepTiebreakers, ]; + break; case 'patient': - return [ + orderBy = [ { labCase: { treatment: { patient: { lastName: dir } } } }, { labCase: { treatment: { patient: { firstName: dir } } } }, ...stepTiebreakers, ]; + break; case 'important': - return [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers]; + orderBy = [{ labCase: { isImportant: dir } }, { createdAt: 'desc' }, ...stepTiebreakers]; + break; case 'prosthesis': - return [ + orderBy = [ { prosthesisTypeCode: dir }, { createdAt: 'desc' }, ...stepTiebreakers, ]; + break; case 'taskType': - return [ + orderBy = [ { workflowStepCode: dir }, { stepOrder: 'asc' }, { createdAt: 'desc' }, ...stepTiebreakers, ]; + break; case 'date': default: - return [ + orderBy = [ { labCase: { sentAt: dir } }, { labCaseId: 'asc' }, { treatmentDetailId: 'asc' }, @@ -338,7 +499,14 @@ export class TasksService { { stepOrder: 'asc' }, { id: 'asc' }, ]; + break; } + + if (query.pinImportant) { + return [{ labCase: { isImportant: 'desc' } }, ...orderBy]; + } + + return orderBy; } private mapTaskListItem( diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7b9abb9..2a4c382 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -482,7 +482,14 @@ "filterStepCompleted": "Step completed", "filterStepCompletedAll": "Any step", "showCompleted": "Show completed", - "importantOnly": "Important only", + "importantOnly": "Important first", + "resetView": "Reset filters & sort", + "showInCase": "Show in case", + "showInCaseNotFound": "This task is not in the default in-progress list.", + "showInCaseError": "Could not locate this task in the list.", + "locatingCase": "Finding case in list…", + "taskCompletedToast": "Step completed — nice work!", + "taskCompletedFlash": "Done", "filterSentFrom": "From", "filterSentTo": "To", "sortBy": "Sort by", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 4710503..1b61932 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -483,7 +483,14 @@ "filterStepCompleted": "مرحله تکمیل‌شده", "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", - "importantOnly": "فقط مهم‌ها", + "importantOnly": "مهم‌ها در ابتدا", + "resetView": "بازنشانی فیلترها و مرتب‌سازی", + "showInCase": "نمایش در پرونده", + "showInCaseNotFound": "این کار در فهرست پیش‌فرض در حال انجام نیست.", + "showInCaseError": "یافتن این کار در فهرست ممکن نشد.", + "locatingCase": "در حال یافتن پرونده در فهرست…", + "taskCompletedToast": "مرحله تکمیل شد — آفرین!", + "taskCompletedFlash": "انجام شد", "filterSentFrom": "از", "filterSentTo": "تا", "sortBy": "مرتب‌سازی بر اساس", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 4cb1339..797d35c 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -483,7 +483,14 @@ "filterStepCompleted": "Stap voltooid", "filterStepCompletedAll": "Elke stap", "showCompleted": "Voltooide tonen", - "importantOnly": "Alleen belangrijk", + "importantOnly": "Belangrijke cases eerst", + "resetView": "Filters en sortering resetten", + "showInCase": "In case tonen", + "showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.", + "showInCaseError": "Kon deze taak niet in de lijst vinden.", + "locatingCase": "Case in lijst zoeken…", + "taskCompletedToast": "Stap voltooid — goed gedaan!", + "taskCompletedFlash": "Klaar", "filterSentFrom": "Vanaf", "filterSentTo": "Tot", "sortBy": "Sorteren op", diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts new file mode 100644 index 0000000..f3da4b0 --- /dev/null +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -0,0 +1,51 @@ +import type { LabTaskStatus, TaskSortField } from '@/types/cases'; + +export type TasksViewState = { + search: string; + clinicId: string; + statusFilter: LabTaskStatus | ''; + stepCompleted: string; + sortBy: TaskSortField; + sortDir: 'asc' | 'desc'; + importantOnly: boolean; + page: number; + highlightTaskId: string | null; +}; + +export const DEFAULT_TASKS_VIEW: TasksViewState = { + search: '', + clinicId: '', + statusFilter: 'IN_PROGRESS', + stepCompleted: '', + sortBy: 'date', + sortDir: 'desc', + importantOnly: false, + page: 1, + highlightTaskId: null, +}; + +export const TASK_COMPLETE_EXIT_MS = 550; + +export function isDefaultTasksView(state: TasksViewState): boolean { + return ( + state.search === DEFAULT_TASKS_VIEW.search && + state.clinicId === DEFAULT_TASKS_VIEW.clinicId && + state.statusFilter === DEFAULT_TASKS_VIEW.statusFilter && + state.stepCompleted === DEFAULT_TASKS_VIEW.stepCompleted && + state.sortBy === DEFAULT_TASKS_VIEW.sortBy && + state.sortDir === DEFAULT_TASKS_VIEW.sortDir && + state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly && + state.page === DEFAULT_TASKS_VIEW.page && + state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId + ); +} + +export function buildDefaultLocateParams(taskId: string, limit: number) { + return { + taskId, + limit, + sortBy: 'date' as const, + sortDir: 'desc' as const, + status: 'IN_PROGRESS' as const, + }; +} diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index d0d9688..a4ec79f 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -17,6 +17,7 @@ import { casesApi } from '@/lib/api/cases'; import { tasksApi } from '@/lib/api/tasks'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { MobileDetailBackButton } from '@/components/ui/shared/MobileDetailBackButton'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; @@ -216,6 +217,11 @@ export function CasesPage() { try { const response = await casesApi.setCaseImportant(selectedCaseId, isImportant); setSelectedCase(response.data); + setCases((prev) => + prev.map((item) => + item.id === selectedCaseId ? { ...item, isImportant: response.data.isImportant } : item, + ), + ); } catch (error: unknown) { setSelectedCase(previousCase); toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); @@ -345,8 +351,15 @@ export function CasesPage() { : 'border-border hover:border-primary/40' }`} > -
    - {formatPatientName(item.patient)} +
    +
    + {formatPatientName(item.patient)} +
    + {item.isImportant ? ( + + {t('importantLabel')} + + ) : null}
    {item.patient.mobile} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 8fc63e5..a4a3233 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -1,8 +1,9 @@ 'use client'; -import { MessageSquare } from 'lucide-react'; +import { Check, FolderOpen, MessageSquare } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/shared/Badge'; +import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { @@ -21,6 +22,8 @@ interface TaskRowProps { task: LabTaskListItem; locale: string; flatMode: boolean; + highlighted?: boolean; + exiting?: boolean; canEdit: boolean; statusOptions: { value: LabTaskStatus; label: string }[]; updatingTaskId: string | null; @@ -29,6 +32,7 @@ interface TaskRowProps { onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; onToggleComments: (taskId: string) => void; onCommentError: (message: string) => void; + onShowInCase?: (task: LabTaskListItem) => void; } function formatPatientName(patient: { firstName: string; lastName: string }) { @@ -39,6 +43,8 @@ export function TaskRow({ task, locale, flatMode, + highlighted = false, + exiting = false, canEdit, statusOptions, updatingTaskId, @@ -47,6 +53,7 @@ export function TaskRow({ onStatusUpdate, onToggleComments, onCommentError, + onShowInCase, }: TaskRowProps) { const t = useTranslations('tasks'); const taskDate = new Intl.DateTimeFormat(locale, { @@ -55,8 +62,18 @@ export function TaskRow({ day: 'numeric', }).format(new Date(task.createdAt)); + const rowClassName = [ + flatMode ? undefined : 'border-b border-border/40 last:border-b-0', + exiting ? 'task-row-complete-exit' : undefined, + !exiting && highlighted + ? 'relative bg-primary/10 ring-2 ring-inset ring-primary/50 shadow-[0_0_16px_rgba(99,102,241,0.2)]' + : undefined, + ] + .filter(Boolean) + .join(' '); + return ( -
  • +
  • {task.stepOrder}. {task.stepLabel}

    - {flatMode && task.isImportant ? ( + {exiting ? ( + + + {t('taskCompletedFlash')} + + ) : null} + {flatMode && task.isImportant && !exiting ? ( {t('importantBadge')} @@ -94,7 +117,7 @@ export function TaskRow({ {canEdit ? ( { - setClinicId(e.target.value); - setPage(1); - }} + onChange={(e) => applyFilterChange(() => setClinicId(e.target.value))} className={filterSelectClass} > @@ -218,28 +367,21 @@ export function TasksPage() { {t('filterStatus')}
  • +
    + applyFilterChange(() => setImportantOnly(checked))} + label={t('importantOnly')} + className="text-xs [&_span:last-child]:text-xs" + /> + {showReset ? ( + + ) : null} +
    {groupingDisabled && sortHintKey ? (

    {t(sortHintKey)}

    ) : null}
    - {loading && tasks.length === 0 ? ( -

    {t('loading')}

    + {(loading || locatingCase) && tasks.length === 0 ? ( +

    + {locatingCase ? t('locatingCase') : t('loading')} +

    ) : tasks.length === 0 ? (

    {t('emptyList')}

    ) : displayModel.mode === 'grouped' ? ( @@ -301,24 +461,7 @@ export function TasksPage() { prosthesisCatalog={prosthesisCatalog} />
      - {prosthesisGroup.tasks.map((task) => ( - void handleStatusUpdate(id, status)} - onToggleComments={(id) => - setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) - } - onCommentError={showError} - /> - ))} + {prosthesisGroup.tasks.map((task) => renderTaskRow(task, false))}
    ))} @@ -327,29 +470,12 @@ export function TasksPage() { ) : (
      - {displayModel.tasks.map((task) => ( - void handleStatusUpdate(id, status)} - onToggleComments={(id) => - setExpandedCommentsTaskId((prev) => (prev === id ? null : id)) - } - onCommentError={showError} - /> - ))} + {displayModel.tasks.map((task) => renderTaskRow(task, true))}
    )} - {pagination.totalPages > 1 && ( + {pagination.totalPages > 1 ? (

    {t('pageSummary', { @@ -363,7 +489,10 @@ export function TasksPage() { type="button" variant="secondary" disabled={page <= 1 || loading} - onClick={() => setPage((p) => Math.max(1, p - 1))} + onClick={() => { + setPage((p) => Math.max(1, p - 1)); + clearFocus(); + }} > ← @@ -371,13 +500,16 @@ export function TasksPage() { type="button" variant="secondary" disabled={page >= pagination.totalPages || loading} - onClick={() => setPage((p) => p + 1)} + onClick={() => { + setPage((p) => p + 1); + clearFocus(); + }} > →

    - )} + ) : null} ); } diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index de12817..02647cc 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -4,6 +4,8 @@ import type { LabTaskListItem, LabTaskStatus, ListLabTasksParams, + LocateTaskPageParams, + LocateTaskPageResult, PaginatedLabTasks, TaskFilterOptions, } from '@/types/cases'; @@ -21,6 +23,13 @@ export const tasksApi = { return response.data; }, + locatePage: async ( + params: LocateTaskPageParams, + ): Promise<{ success: boolean; data: LocateTaskPageResult }> => { + const response = await apiClient.get('/tasks/locate-page', { params }); + return response.data; + }, + updateStatus: async ( taskId: string, status: LabTaskStatus, diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 20e9786..048a7db 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -309,4 +309,29 @@ select option { .lucide { color: var(--color-icon); stroke: currentColor; +} + +@keyframes task-row-complete-exit { + 0% { + opacity: 1; + transform: translateX(0); + max-height: 9rem; + } + 40% { + opacity: 1; + transform: translateX(0); + background-color: color-mix(in srgb, var(--color-badge-success-bg) 75%, transparent); + } + 100% { + opacity: 0; + transform: translateX(0.75rem); + max-height: 0; + overflow: hidden; + } +} + +.task-row-complete-exit { + animation: task-row-complete-exit 0.55s ease-in forwards; + pointer-events: none; + overflow: hidden; } \ No newline at end of file diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index 0672e46..6a520a5 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -3,6 +3,7 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED'; export interface LabCaseListItem { id: string; sentAt: string | null; + isImportant: boolean; clinic: { id: string; name: string }; patient: { id: string; @@ -148,15 +149,36 @@ export interface ListLabTasksParams { status?: LabTaskStatus; completed?: boolean; important?: boolean; + pinImportant?: boolean; sentFrom?: string; sentTo?: string; stepCompleted?: string; + labCaseId?: string; sortBy?: TaskSortField; sortDir?: 'asc' | 'desc'; page?: number; limit?: number; } +export interface LocateTaskPageParams { + taskId: string; + q?: string; + clinicOrganizationId?: string; + status?: LabTaskStatus; + important?: boolean; + pinImportant?: boolean; + stepCompleted?: string; + sortBy?: TaskSortField; + sortDir?: 'asc' | 'desc'; + limit?: number; +} + +export interface LocateTaskPageResult { + page: number; + found: boolean; + labCaseId: string; +} + export interface LabTaskListItem { id: string; labCaseId: string; -- 2.53.0.windows.1 From a391eee15f29490e10624a01e868a053ac301f29 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 15:47:32 +0330 Subject: [PATCH 13/24] improvement: task assignment flow added. cases and tasks feature updated accordingly. --- .cursor/rules/lab-tasks.mdc | 3 +- .cursor/skills/lab-tasks/SKILL.md | 16 ++- AGENTS.md | 2 +- .../migration.sql | 9 ++ backend/prisma/schema.prisma | 5 + backend/src/modules/cases/cases.controller.ts | 28 +++++- backend/src/modules/cases/cases.service.ts | 98 ++++++++++++++++++- backend/src/modules/cases/dto/cases.dto.ts | 6 ++ backend/src/modules/tasks/dto/tasks.dto.ts | 12 +++ backend/src/modules/tasks/tasks.service.ts | 32 ++++-- frontend/messages/en.json | 6 ++ frontend/messages/fa.json | 6 ++ frontend/messages/nl.json | 6 ++ .../components/lab/labTaskStatusDisplay.ts | 12 ++- .../src/components/lab/tasksViewDefaults.ts | 3 + .../src/components/ui/lab/CaseDetailPanel.tsx | 51 +++++++--- frontend/src/components/ui/lab/CasesPage.tsx | 29 +++++- frontend/src/components/ui/lab/TaskRow.tsx | 26 ++++- frontend/src/components/ui/lab/TasksPage.tsx | 14 ++- frontend/src/lib/api/cases.ts | 17 ++++ frontend/src/types/cases.ts | 11 +++ 21 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index bb1e150..feb420b 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -9,7 +9,8 @@ alwaysApply: false - **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`). - **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. - **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. -- **Important only:** server-side `important=true` (not client per-page). +- **Important first:** `pinImportant=true` (sort pin, not filter). +- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”. - **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. Full map: `.cursor/skills/lab-tasks/SKILL.md` diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index faff3ae..9cbd83f 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -38,12 +38,18 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | | `stepCompleted` | `GET /tasks` | Workflow step dropdown | -| `important` | `GET /tasks` | Important cases only | +| `pinImportant` | `GET /tasks` | Important first (sort pin) | +| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | | Clinics + steps options | `GET /tasks/filter-options` | Populates dropdowns (not from current page) | +**Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row. + +**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Managers assign/monitor in Cases. + **Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). -- **Important only:** server-side `important=true` on `GET /tasks` (full list pagination, not per-page client filter). +- **Important first:** `pinImportant=true` prepends important cases in sort order. +- **Assigned to me:** `assignedToMe=true` filters to current user's assigned tasks only. - **Reset view:** `resetView` restores `DEFAULT_TASKS_VIEW` from `tasksViewDefaults.ts`. - **Show in case:** flat-sort rows only; resets filters/sort, calls `GET /tasks/locate-page` to find the correct page in the full default-sorted list, then highlights + scrolls to the task. - **Complete animation:** when marking done under in-progress filter, row plays exit animation + success toast before refetch. @@ -51,11 +57,13 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. ## APIs - `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) -- `PATCH /tasks/:taskId` — update status +- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task) - `GET /tasks/filter-options` — clinics + workflow steps (localized) - `GET /tasks/locate-page` — page number for a task in the sorted filtered list +- `GET /cases/assignable-staff` — staff eligible for task assignment +- `PATCH /cases/:caseId/tasks/:taskId/assign` — assign or unassign (`assigneeUserId` nullable) -List items include `caseSentAt` for case headers. +List items include `caseSentAt`, `assignee`, `assignedAt` for case headers / flat rows. ## Permissions diff --git a/AGENTS.md b/AGENTS.md index f21ba25..b800cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ frontend/src/ - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. - **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). -**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog — see `.cursor/skills/lab-tasks/SKILL.md`. +**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown — see `.cursor/skills/lab-tasks/SKILL.md`. ## Backend layout diff --git a/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql b/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql new file mode 100644 index 0000000..95fd0cf --- /dev/null +++ b/backend/prisma/migrations/20260713120000_lab_task_assignment/migration.sql @@ -0,0 +1,9 @@ +-- Reintroduce per-task assignment for lab workflow + +ALTER TABLE "lab_case_tasks" ADD COLUMN "assigneeUserId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_assigneeUserId_fkey" + FOREIGN KEY ("assigneeUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "lab_case_tasks_assigneeUserId_idx" ON "lab_case_tasks"("assigneeUserId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 06bf90a..e503da2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] statusChangedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskLastStatusChangedBy") + assignedLabCaseTasks LabCaseTask[] @relation("LabCaseTaskAssignee") labCaseTaskStatusEvents LabCaseTaskStatusEvent[] labCaseComments LabCaseComment[] phoneVerificationCodes PhoneVerificationCode[] @@ -353,11 +354,14 @@ model LabCaseTask { stepOrder Int stepLabel String status LabTaskStatus @default(IN_PROGRESS) + assigneeUserId String? + assignedAt DateTime? lastStatusChangedByUserId String? lastStatusChangedAt DateTime? labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) statusEvents LabCaseTaskStatusEvent[] @@ -366,6 +370,7 @@ model LabCaseTask { @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder]) @@index([labCaseId, status]) + @@index([assigneeUserId]) @@map("lab_case_tasks") } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index d54a7b1..451dbf5 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -14,7 +14,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -30,6 +30,13 @@ export class CasesController { return this.casesService.list(organizationId, req.user.id, query); } + @Get('assignable-staff') + @ApiOperation({ summary: 'Staff who can be assigned lab tasks' }) + listAssignableStaff(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listAssignableStaff(organizationId, req.user.id); + } + @Get('filter-options') @ApiOperation({ summary: 'Clinics and treatment types for inbox filters' }) listFilterOptions(@Req() req) { @@ -74,4 +81,23 @@ export class CasesController { const organizationId = this.casesService.getOrganizationIdFromUser(req.user); return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language); } + + @Patch(':caseId/tasks/:taskId/assign') + @ApiOperation({ summary: 'Assign or unassign a task to lab staff' }) + assignTask( + @Param('caseId') caseId: string, + @Param('taskId') taskId: string, + @Body() dto: AssignLabCaseTaskDto, + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.assignTask( + caseId, + taskId, + dto, + organizationId, + req.user.id, + req.user.language, + ); + } } diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 62c97b6..072c97a 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -14,8 +14,9 @@ import { } from '../catalog/catalog-label.service'; import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto'; import { normalizeTaskTeeth } from './lab-case-task.util'; +import { hasEffectivePermission } from '../../common/membership-permissions'; const labCaseListInclude = { treatment: { @@ -49,6 +50,7 @@ const labCaseListInclude = { ], include: { lastStatusChangedBy: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true } }, statusEvents: { orderBy: { changedAt: 'asc' as const }, include: { changedBy: { select: { id: true, name: true } } }, @@ -74,6 +76,7 @@ const labCaseListInclude = { type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ include: { lastStatusChangedBy: { select: { id: true; name: true } }; + assignee: { select: { id: true; name: true } }; statusEvents: { include: { changedBy: { select: { id: true; name: true } } }; }; @@ -370,6 +373,95 @@ export class CasesService { return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; } + async listAssignableStaff(labOrganizationId: string, actorUserId: string) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId: labOrganizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + user: { select: { id: true, name: true } }, + }, + }); + + const staff = memberships + .filter((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')) + .map((m) => ({ + id: m.user.id, + name: m.user.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { success: true, data: staff }; + } + + async assignTask( + labCaseId: string, + taskId: string, + dto: AssignLabCaseTaskDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + + const task = await this.prisma.labCaseTask.findFirst({ + where: { + id: taskId, + labCaseId, + labCase: { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + }, + select: { id: true }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const assigneeUserId = dto.assigneeUserId ?? null; + + if (assigneeUserId) { + const memberships = await this.prisma.membership.findMany({ + where: { + organizationId: labOrganizationId, + userId: assigneeUserId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, + }); + + const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT')); + if (!canReceive) { + throw new BadRequestException('Selected user cannot be assigned tasks'); + } + } + + await this.prisma.labCaseTask.update({ + where: { id: taskId }, + data: { + assigneeUserId, + assignedAt: assigneeUserId ? new Date() : null, + }, + }); + + const labCase = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) }; + } + private buildListWhere( labOrganizationId: string, query: ListLabCasesDto, @@ -579,6 +671,10 @@ export class CasesService { stepOrder: task.stepOrder, stepLabel: task.stepLabel, status: task.status, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name } + : null, + assignedAt: task.assignedAt?.toISOString() ?? null, createdAt: task.createdAt.toISOString(), lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, lastStatusChangedBy: task.lastStatusChangedBy diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index afa969b..be290bb 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -6,6 +6,12 @@ export class UpdateLabCaseImportantDto { isImportant: boolean; } +export class AssignLabCaseTaskDto { + @IsOptional() + @IsUUID() + assigneeUserId?: string | null; +} + export class ListLabCasesDto { @IsOptional() @IsString() diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 86956db..dcffd93 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -81,6 +81,12 @@ export class ListLabTasksDto { @IsUUID() labCaseId?: string; + /** When true, only tasks assigned to the current user. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignedToMe?: boolean; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; @@ -147,6 +153,12 @@ export class LocateTaskPageDto { @IsString() stepCompleted?: string; + /** When true, only tasks assigned to the current user. */ + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + assignedToMe?: boolean; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 46619eb..0460720 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -17,6 +17,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions'; const taskListInclude = { lastStatusChangedBy: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true } }, labCase: { include: { treatment: { @@ -55,7 +56,7 @@ export class TasksService { const limit = Math.min(Math.max(query.limit ?? 50, 1), 100); const skip = (page - 1) * limit; - const where = await this.buildListWhere(labOrganizationId, query); + const where = await this.buildListWhere(labOrganizationId, actorUserId, query); const [items, total] = await Promise.all([ this.prisma.labCaseTask.findMany({ @@ -117,7 +118,7 @@ export class TasksService { throw new NotFoundException('Task not found'); } - const where = await this.buildListWhere(labOrganizationId, listQuery); + const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery); const inFilteredSet = await this.prisma.labCaseTask.count({ where: { AND: [where, { id: query.taskId }] }, }); @@ -176,6 +177,10 @@ export class TasksService { throw new NotFoundException('Task not found'); } + if (task.assigneeUserId && task.assigneeUserId !== actorUserId) { + throw new ForbiddenException('This task is assigned to another staff member'); + } + const updated = await this.prisma.$transaction(async (tx) => { const result = await tx.labCaseTask.update({ where: { id: taskId }, @@ -263,6 +268,7 @@ export class TasksService { private async buildListWhere( labOrganizationId: string, + actorUserId: string, query: ListLabTasksDto, ): Promise { const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null }; @@ -306,6 +312,7 @@ export class TasksService { ...(query.labCaseId ? { labCaseId: query.labCaseId } : {}), labCase: labCaseScope, ...(status !== undefined ? { status } : {}), + ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}), }; const stepCompleted = query.stepCompleted?.trim(); @@ -327,12 +334,16 @@ export class TasksService { } return { - ...base, - OR: completedGroups.map((group) => ({ - labCaseId: group.labCaseId, - treatmentDetailId: group.treatmentDetailId, - prosthesisTypeCode: group.prosthesisTypeCode, - })), + AND: [ + base, + { + OR: completedGroups.map((group) => ({ + labCaseId: group.labCaseId, + treatmentDetailId: group.treatmentDetailId, + prosthesisTypeCode: group.prosthesisTypeCode, + })), + }, + ], }; } @@ -348,6 +359,7 @@ export class TasksService { sentFrom: query.sentFrom, sentTo: query.sentTo, stepCompleted: query.stepCompleted, + assignedToMe: query.assignedToMe, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, @@ -527,6 +539,10 @@ export class TasksService { stepLabel: task.stepLabel, status: task.status, isImportant: task.labCase.isImportant, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name } + : null, + assignedAt: task.assignedAt?.toISOString() ?? null, lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null, lastStatusChangedBy: task.lastStatusChangedBy ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2a4c382..2012dd1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -435,6 +435,10 @@ "errorLoadList": "Failed to load cases.", "errorLoadDetail": "Failed to load case details.", "errorUpdateTask": "Failed to update task.", + "errorAssignTask": "Failed to assign task.", + "assigneeLabel": "Assigned to", + "assigneeUnassigned": "Unassigned", + "assignedTo": "Assigned to {name}", "filterClinic": "Clinic", "filterClinicAll": "All clinics", "filterTreatmentType": "Treatment type", @@ -483,6 +487,8 @@ "filterStepCompletedAll": "Any step", "showCompleted": "Show completed", "importantOnly": "Important first", + "assignedToMe": "Assigned to me", + "assignedToStaff": "Assigned to {name}", "resetView": "Reset filters & sort", "showInCase": "Show in case", "showInCaseNotFound": "This task is not in the default in-progress list.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 1b61932..d41236e 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -435,6 +435,10 @@ "errorLoadList": "بارگذاری پرونده‌ها ناموفق بود.", "errorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود.", "errorUpdateTask": "به‌روزرسانی وظیفه ناموفق بود.", + "errorAssignTask": "واگذاری وظیفه ناموفق بود.", + "assigneeLabel": "واگذار شده به", + "assigneeUnassigned": "واگذار نشده", + "assignedTo": "واگذار شده به {name}", "filterClinic": "کلینیک", "filterClinicAll": "همه کلینیک‌ها", "filterTreatmentType": "نوع درمان", @@ -484,6 +488,8 @@ "filterStepCompletedAll": "هر مرحله‌ای", "showCompleted": "نمایش تکمیل‌شده‌ها", "importantOnly": "مهم‌ها در ابتدا", + "assignedToMe": "واگذار شده به من", + "assignedToStaff": "واگذار شده به {name}", "resetView": "بازنشانی فیلترها و مرتب‌سازی", "showInCase": "نمایش در پرونده", "showInCaseNotFound": "این کار در فهرست پیش‌فرض در حال انجام نیست.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 797d35c..7b26ce4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -435,6 +435,10 @@ "errorLoadList": "Dossiers laden mislukt.", "errorLoadDetail": "Dossierdetails laden mislukt.", "errorUpdateTask": "Taak bijwerken mislukt.", + "errorAssignTask": "Taak toewijzen mislukt.", + "assigneeLabel": "Toegewezen aan", + "assigneeUnassigned": "Niet toegewezen", + "assignedTo": "Toegewezen aan {name}", "filterClinic": "Kliniek", "filterClinicAll": "Alle klinieken", "filterTreatmentType": "Behandeltype", @@ -484,6 +488,8 @@ "filterStepCompletedAll": "Elke stap", "showCompleted": "Voltooide tonen", "importantOnly": "Belangrijke cases eerst", + "assignedToMe": "Toegewezen aan mij", + "assignedToStaff": "Toegewezen aan {name}", "resetView": "Filters en sortering resetten", "showInCase": "In case tonen", "showInCaseNotFound": "Deze taak staat niet in de standaardlijst met taken in uitvoering.", diff --git a/frontend/src/components/lab/labTaskStatusDisplay.ts b/frontend/src/components/lab/labTaskStatusDisplay.ts index f96f8b9..b358606 100644 --- a/frontend/src/components/lab/labTaskStatusDisplay.ts +++ b/frontend/src/components/lab/labTaskStatusDisplay.ts @@ -1,6 +1,16 @@ import type { CSSProperties } from 'react'; import type { BadgeVariant } from '@/components/ui/shared/Badge'; -import type { LabTaskStatus } from '@/types/cases'; +import type { LabTaskListItem, LabTaskStatus } from '@/types/cases'; + +export function canEditLabTaskStatus( + task: Pick, + currentUserId: string | undefined, + canEditTasks: boolean, +): boolean { + if (!canEditTasks || !currentUserId) return false; + if (!task.assignee) return true; + return task.assignee.id === currentUserId; +} export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant { return status === 'COMPLETED' ? 'success' : 'warning'; diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index f3da4b0..4d2d285 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -8,6 +8,7 @@ export type TasksViewState = { sortBy: TaskSortField; sortDir: 'asc' | 'desc'; importantOnly: boolean; + assignedToMe: boolean; page: number; highlightTaskId: string | null; }; @@ -20,6 +21,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { sortBy: 'date', sortDir: 'desc', importantOnly: false, + assignedToMe: false, page: 1, highlightTaskId: null, }; @@ -35,6 +37,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.sortBy === DEFAULT_TASKS_VIEW.sortBy && state.sortDir === DEFAULT_TASKS_VIEW.sortDir && state.importantOnly === DEFAULT_TASKS_VIEW.importantOnly && + state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId ); diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 5229a8d..1159de4 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/shared/Badge'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; @@ -21,7 +22,7 @@ import { formatPatientName, latestCaseAttachment, } from '@/components/lab/caseDetailUtils'; -import type { LabCaseDetail, LabTaskStatus } from '@/types/cases'; +import type { AssignableTaskStaff, LabCaseDetail, LabTaskStatus } from '@/types/cases'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) { @@ -60,6 +61,10 @@ export interface CaseDetailPanelProps { updatingImportant?: boolean; onImportantChange?: (checked: boolean) => void; commentsSection?: ReactNode; + assignableStaff?: AssignableTaskStaff[]; + canAssignTasks?: boolean; + assigningTaskId?: string | null; + onAssignTask?: (taskId: string, assigneeUserId: string | null) => void; } export function CaseDetailPanel({ @@ -76,6 +81,10 @@ export function CaseDetailPanel({ updatingImportant = false, onImportantChange, commentsSection, + assignableStaff = [], + canAssignTasks = false, + assigningTaskId = null, + onAssignTask, }: CaseDetailPanelProps) { const t = useTranslations('cases'); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); @@ -213,24 +222,42 @@ export function CaseDetailPanel({
      {group.tasks.map((task) => ( -
    • -
      - +
    • +
      + {task.stepOrder}. {task.stepLabel} + + {task.lastStatusChangedBy + ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) + : t('lastUpdatedUnknown')} + {task.lastStatusChangedAt + ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` + : ''} + + {canAssignTasks && onAssignTask ? ( + + ) : null} {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
      -

      - {task.lastStatusChangedBy - ? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name }) - : t('lastUpdatedUnknown')} - {task.lastStatusChangedAt - ? ` · ${formatCaseDateTime(task.lastStatusChangedAt, locale)}` - : ''} -

    • ))}
    diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index a4ec79f..32ef830 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -24,6 +24,7 @@ import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { + AssignableTaskStaff, CasesFilterOptions, LabCaseDetail, LabCaseListItem, @@ -67,6 +68,8 @@ export function CasesPage() { const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingImportant, setUpdatingImportant] = useState(false); + const [assignableStaff, setAssignableStaff] = useState([]); + const [assigningTaskId, setAssigningTaskId] = useState(null); const [commentCount, setCommentCount] = useState(0); const canEdit = canEditCases(currentOrganization); @@ -142,8 +145,11 @@ export function CasesPage() { useEffect(() => { void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {}); void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {}); + if (canEdit) { + void casesApi.listAssignableStaff().then((r) => setAssignableStaff(r.data)).catch(() => {}); + } // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch - }, []); + }, [canEdit]); useEffect(() => { const caseIdFromUrl = searchParams.get('caseId'); @@ -206,6 +212,21 @@ export function CasesPage() { setPage(1); } + async function handleAssignTask(taskId: string, assigneeUserId: string | null) { + if (!selectedCaseId || !canEdit) return; + + setAssigningTaskId(taskId); + toast.setError(''); + try { + const response = await casesApi.assignTask(selectedCaseId, taskId, assigneeUserId); + setSelectedCase(response.data); + } catch (error: unknown) { + toast.showError(getUserFacingError(error, tErrors, t('errorAssignTask'))); + } finally { + setAssigningTaskId(null); + } + } + async function handleCaseImportantToggle(isImportant: boolean) { if (!selectedCaseId || !canEdit || !selectedCase) return; @@ -439,6 +460,12 @@ export function CasesPage() { canEditImportant={canEdit} updatingImportant={updatingImportant} onImportantChange={(checked) => void handleCaseImportantToggle(checked)} + assignableStaff={assignableStaff} + canAssignTasks={canEdit} + assigningTaskId={assigningTaskId} + onAssignTask={(taskId, assigneeUserId) => + void handleAssignTask(taskId, assigneeUserId) + } headerMetaLines={

    {t('fromClinic', { name: selectedCase.clinic.name })} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index a4a3233..7c1137b 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/shared/Button'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { + canEditLabTaskStatus, labTaskStatusSelectStyle, labTaskStatusVariant, } from '@/components/lab/labTaskStatusDisplay'; @@ -25,6 +26,7 @@ interface TaskRowProps { highlighted?: boolean; exiting?: boolean; canEdit: boolean; + currentUserId?: string; statusOptions: { value: LabTaskStatus; label: string }[]; updatingTaskId: string | null; commentsOpen: boolean; @@ -46,6 +48,7 @@ export function TaskRow({ highlighted = false, exiting = false, canEdit, + currentUserId, statusOptions, updatingTaskId, commentsOpen, @@ -56,6 +59,9 @@ export function TaskRow({ onShowInCase, }: TaskRowProps) { const t = useTranslations('tasks'); + const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit); + const assignedToOther = + Boolean(task.assignee) && task.assignee!.id !== currentUserId; const taskDate = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', @@ -75,7 +81,7 @@ export function TaskRow({ return (

  • @@ -113,8 +119,12 @@ export function TaskRow({

    -
    - {canEdit ? ( +
    + {canEditStatus && !exiting ? ( + ) : assignedToOther && !exiting ? ( + + {t('assignedToStaff', { name: task.assignee!.name })} + ) : ( {statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status} @@ -135,7 +153,7 @@ export function TaskRow({ )}
    -
    +
    {flatMode && onShowInCase && !exiting ? ( - ) : null} - {canEdit && !exiting ? ( - - ) : null} - {flatMode ? ( + {flatMode ? ( +
    + {onShowInCase && !exiting ? ( + + ) : null} {task.prosthesisTypeLabel} - ) : null} -
    +
    + ) : null}
    {commentsOpen && canEdit && !exiting ? ( diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index eba00f4..d7a2044 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -282,7 +282,7 @@ export function TasksPage() { [canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors], ); - const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`; + const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-h-[44px] rounded-md px-2 py-2 text-base sm:min-h-0 sm:py-1.5 sm:text-sm`; const sortHintKey = useMemo(() => { switch (sortBy) { @@ -467,7 +467,7 @@ export function TasksPage() { ) : null} -
    +
    {(loading || locatingCase) && tasks.length === 0 ? (

    {locatingCase ? t('locatingCase') : t('loading')} diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index b3f9e72..df42ac2 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -315,7 +315,7 @@ export function LabCasesDispatchPanel({ {!detailAlreadyInShipment ? (

    {t('labDispatchEmpty')}

    ) : activeLabCase ? ( -
    +
    {renderShipmentCardHeader()} {sent ? ( <> @@ -513,6 +513,7 @@ export function LabCasesDispatchPanel({
    ) : null} - {activeLabCase.id ? ( - + ) : null} + + {canShowComments && activeLabCase?.id ? ( + { - const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!); - return r.data; - }} - onPost={async (body) => { - const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body }); - return r.data; - }} onError={onCommentError} + onMarkRead={onLabCaseMarkedRead} + onActivityChange={onLabCaseActivityChange} /> ) : null} diff --git a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx index 0ed8432..1cd3731 100644 --- a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx +++ b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx @@ -16,6 +16,7 @@ interface LabDispatchAttentionPanelProps { labDependentCodes: Set; orgs?: LinkedOrganizationOption[]; onGoToDispatch: (item: LabDispatchAttentionItem) => void; + compact?: boolean; } export function LabDispatchAttentionPanel({ @@ -24,6 +25,7 @@ export function LabDispatchAttentionPanel({ labDependentCodes, orgs, onGoToDispatch, + compact = false, }: LabDispatchAttentionPanelProps) { const t = useTranslations('treatment'); @@ -32,16 +34,22 @@ export function LabDispatchAttentionPanel({ } return ( -
    -
    - -
    -

    {t('labAttentionTitle')}

    -

    {t('labAttentionSubtitle')}

    +
    + {!compact ? ( +
    + +
    +

    {t('labAttentionTitle')}

    +

    {t('labAttentionSubtitle')}

    +
    -
    + ) : null} -
      +
        {items.map((item) => { const teeth = item.detail.teeth.length ? [...item.detail.teeth].sort().join(', ') @@ -55,7 +63,9 @@ export function LabDispatchAttentionPanel({ return (
      • @@ -92,7 +102,7 @@ export function LabDispatchAttentionPanel({ -
        + + +
        + ); - {loading &&

        {t('loadingHistory')}

        } + const listBlock = ( + <> + {loading &&

        {t('loadingHistory')}

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

        +

        {hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')}

        )} -
        +
        {displayedItems.map((treatment) => { const isSelected = selectedPreviewId === treatment.id; const isCurrentAppointment = @@ -188,6 +194,35 @@ export function PastTreatmentsPanel({ ); })}
        + + ); + + if (compact) { + return ( +
        + + {filtersOpen ? filtersBlock : null} + {listBlock} +
        + ); + } + + return ( +
        +
        +

        + {patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')} +

        +

        {t('historySubtitle')}

        +
        + {filtersBlock} + {listBlock}
        ); } diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 3a72f39..f3206f9 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -13,12 +13,10 @@ import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; import { - canCommentOnDetailLabCase, isDetailReadyForLabDispatch, isDetailTypeSelected, isLabDependentDetailMissingTeeth, } from '@/components/treatment/treatmentDetailRules'; -import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; interface TreatmentDetailsEditorProps { @@ -35,7 +33,6 @@ interface TreatmentDetailsEditorProps { uploadBusy: boolean; onAddDetail: () => void; onUploadFiles: (files: FileList | null) => void; - onCommentError?: (message: string) => void; } export function TreatmentDetailsEditor({ @@ -52,7 +49,6 @@ export function TreatmentDetailsEditor({ uploadBusy, onAddDetail, onUploadFiles, - onCommentError, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const attachmentInputRef = useRef(null); @@ -74,7 +70,6 @@ export function TreatmentDetailsEditor({ activeDetail, labDependentCodes, ); - const showLabCaseComments = canCommentOnDetailLabCase(activeDetail); return (
        @@ -213,14 +208,6 @@ export function TreatmentDetailsEditor({
        - {showLabCaseComments ? ( - - ) : null} - {canEdit && saveStatus !== 'idle' && (

        void; + items: PatientLabCaseSummary[]; + loading: boolean; + locale: string; + prosthesisCatalog: ProsthesisCatalogEntry[]; + unreadUpdatesCount: number; + otherPatientsUnreadCount: number; + canShowPatientScope: boolean; + selectedLabCaseId?: string | null; + onSelect: (item: PatientLabCaseSummary) => void; + compact?: boolean; +} + +function prosthesisLabel(code: string, catalog: ProsthesisCatalogEntry[]): string { + return catalog.find((entry) => entry.code === code)?.label ?? code; +} + +export function TreatmentLabCasesPanel({ + scope, + onScopeChange, + items, + loading, + locale, + prosthesisCatalog, + unreadUpdatesCount, + otherPatientsUnreadCount, + canShowPatientScope, + selectedLabCaseId, + onSelect, + compact = false, +}: TreatmentLabCasesPanelProps) { + const t = useTranslations('treatment'); + const tCommon = useTranslations('common'); + const showPatientName = scope === 'updates'; + + return ( +

        + {canShowPatientScope || unreadUpdatesCount > 0 ? ( +
        + {canShowPatientScope ? ( + + ) : null} + {unreadUpdatesCount > 0 ? ( + + ) : null} +
        + ) : null} + + {scope === 'patient' && otherPatientsUnreadCount > 0 ? ( + + ) : null} + + {loading ? ( +

        {tCommon('loading')}

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

        + {scope === 'updates' ? t('labShipmentsUpdatesEmpty') : t('labShipmentsEmpty')} +

        + ) : ( +
          + {items.map((item) => { + const isActive = item.labCaseId === selectedLabCaseId; + + return ( +
        • + +
        • + ); + })} +
        + )} +
        + ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 44595dc..79da58f 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -12,6 +12,7 @@ interface TreatmentPreviewCardProps { labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; + embedded?: boolean; } export function TreatmentPreviewCard({ @@ -20,12 +21,13 @@ export function TreatmentPreviewCard({ labDependentCodes, treatmentCatalog, orgs, + embedded = false, }: TreatmentPreviewCardProps) { const t = useTranslations('treatment'); return ( -
        -

        {heading}

        +
        + {heading ?

        {heading}

        : null} {!treatment ? (

        {t('selectAppointment')}

        diff --git a/frontend/src/components/ui/treatment/TreatmentRailSection.tsx b/frontend/src/components/ui/treatment/TreatmentRailSection.tsx new file mode 100644 index 0000000..19e893e --- /dev/null +++ b/frontend/src/components/ui/treatment/TreatmentRailSection.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface TreatmentRailSectionProps { + title: string; + subtitle?: string; + count?: number; + defaultExpanded?: boolean; + variant?: 'default' | 'attention'; + children: React.ReactNode; +} + +export function TreatmentRailSection({ + title, + subtitle, + count, + defaultExpanded = false, + variant = 'default', + children, +}: TreatmentRailSectionProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + const shellClass = + variant === 'attention' + ? 'surface-card border border-amber-500/35 bg-amber-500/5' + : 'surface-card'; + + return ( +
        + + {expanded ?
        {children}
        : null} +
        + ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index f83c454..e4e7e0e 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -5,6 +5,12 @@ import { useTranslations } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox'; +import { + TreatmentLabCasesPanel, + type TreatmentLabCasesScope, +} from '@/components/ui/treatment/TreatmentLabCasesPanel'; +import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; @@ -22,7 +28,9 @@ import { } from '@/components/appointments/appointmentTime'; import { appointmentsApi } from '@/lib/api/appointments'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { treatmentsApi } from '@/lib/api/treatments'; +import { notificationsApi } from '@/lib/api/notifications'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { areDetailsPersistable, @@ -35,14 +43,17 @@ import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatc import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions'; import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain'; -import { useMarkTabReadOnVisit } from '@/lib/hooks/useTabBadgeCounts'; -import { notificationsApi } from '@/lib/api/notifications'; +import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts'; +import { tabBadgesChangedEventName } from '@/lib/tabBadgeUtils'; import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; +import { useAuth } from '@/lib/hooks/useAuth'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useToast } from '@/lib/hooks/useToast'; +import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery'; import type { Organization } from '@/types/organization'; +import type { Patient } from '@/types/patient'; import type { AppointmentRecord } from '@/types/appointment'; -import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId, LabCaseDraft, @@ -53,6 +64,7 @@ import type { TreatmentAppointment, TreatmentDetailDraft, } from '@/types/treatment'; +import type { PatientLabCaseSummary } from '@/types/lab-case-activity'; type WorkspaceMode = 'live' | 'historical'; @@ -274,11 +286,16 @@ export function TreatmentWorkspace({ }: TreatmentWorkspaceProps) { const t = useTranslations('treatment'); const tErrors = useTranslations('errors'); + const tPatients = useTranslations('patients'); const router = useRouter(); + const { user } = useAuth(); const { showError, showSuccess, messages: toastMessages } = useToast(); + const locale = user?.language ?? 'en'; const canView = canViewTreatment(currentOrganization); const canEdit = canEditTreatment(currentOrganization); useMarkTabReadOnVisit(); + const tabBadgeCounts = useTabBadgeCounts(); + const initialLabCasesScopeSetRef = useRef(false); const [stripHidden, setStripHidden] = useState(false); const todayStart = useMemo(() => startOfLocalDay(new Date()), []); @@ -292,10 +309,29 @@ export function TreatmentWorkspace({ const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [historyPatientId, setHistoryPatientId] = useState(null); + const [patientLabCases, setPatientLabCases] = useState([]); + const [patientLabCasesLoading, setPatientLabCasesLoading] = useState(false); + const [unreadLabCases, setUnreadLabCases] = useState([]); + const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false); + const [labCasesScope, setLabCasesScope] = useState('patient'); + const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState(null); + const [searchedPatient, setSearchedPatient] = useState | null>(null); + const [patientSearchBusy, setPatientSearchBusy] = useState(false); + + const { + search: patientSearch, + setSearch: setPatientSearch, + patients: patientSearchResults, + loading: patientSearchLoading, + } = usePatientSearchQuery(canView); const [orgs, setOrgs] = useState([]); const [labDependentCodes, setLabDependentCodes] = useState>(new Set()); const [treatmentCatalog, setTreatmentCatalog] = useState([]); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); const treatmentDropdownCatalog = useMemo( () => treatmentCatalog.filter((entry) => entry.availableInTreatment), [treatmentCatalog], @@ -357,11 +393,6 @@ export function TreatmentWorkspace({ return match?.id ?? null; }, [labCaseDrafts, activeDetailId]); - useEffect(() => { - if (!activeSentLabCaseId) return; - void notificationsApi.markCaseRead(activeSentLabCaseId).then(() => notifyTabBadgesChanged()); - }, [activeSentLabCaseId]); - const isDirty = useMemo( () => isDetailsDirty(details, savedSnapshot), [details, savedSnapshot], @@ -374,6 +405,55 @@ export function TreatmentWorkspace({ [appointments, selectedAppointmentId], ); + const activePatient = useMemo(() => { + if (selectedAppointment) { + return { + id: selectedAppointment.patientId, + firstName: selectedAppointment.patientFirstName, + lastName: selectedAppointment.patientLastName, + purpose: selectedAppointment.purpose, + }; + } + if (searchedPatient) { + return { + id: searchedPatient.id, + firstName: searchedPatient.firstName, + lastName: searchedPatient.lastName, + purpose: undefined as string | undefined, + }; + } + return null; + }, [selectedAppointment, searchedPatient]); + + const activePatientId = activePatient?.id ?? null; + const activePatientName = activePatient + ? `${activePatient.firstName} ${activePatient.lastName}` + : null; + + const unreadUpdatesCount = unreadLabCases.length; + const otherPatientsUnreadCount = useMemo( + () => unreadLabCases.filter((item) => item.patientId !== activePatientId).length, + [unreadLabCases, activePatientId], + ); + const displayedLabCases = labCasesScope === 'updates' ? unreadLabCases : patientLabCases; + const labCasesListLoading = + labCasesScope === 'updates' + ? unreadLabCasesLoading && unreadLabCases.length === 0 + : patientLabCasesLoading && patientLabCases.length === 0; + const showLabShipmentsSection = Boolean(activePatient) || unreadUpdatesCount > 0; + const labShipmentsSubtitle = + labCasesScope === 'updates' + ? t('labShipmentsUpdatesScope') + : activePatientName + ? t('labShipmentsPatientScope', { patientName: activePatientName }) + : t('labShipmentsSubtitle'); + + useEffect(() => { + if (selectedAppointment && searchedPatient?.id === selectedAppointment.patientId) { + setSearchedPatient(null); + } + }, [selectedAppointment, searchedPatient?.id]); + const isViewingPastDay = useMemo( () => compareLocalDayStart(selectedDay, todayStart) < 0, [selectedDay, todayStart], @@ -429,10 +509,6 @@ export function TreatmentWorkspace({ const isBrowsing = selectedPreviewId !== null; - const previewHeading = isBrowsing - ? t('previewBrowsingTitle') - : t('previewCurrentDraft'); - const labAttentionItems = useMemo( () => collectLabDispatchAttention( @@ -557,13 +633,15 @@ export function TreatmentWorkspace({ let cancelled = false; void (async () => { try { - const [orgsResponse, catalogResponse] = await Promise.all([ + const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([ treatmentsApi.listLinkedOrganizations(), treatmentCatalogApi.list(), + prosthesisCatalogApi.list(), ]); if (cancelled) return; setOrgs(orgsResponse.data); setTreatmentCatalog(catalogResponse.data); + setProsthesisCatalog(prosthesisResponse.data); setLabDependentCodes( new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), ); @@ -579,21 +657,85 @@ export function TreatmentWorkspace({ }, [showError, t]); useEffect(() => { - if (!selectedAppointment?.patientId) { + if (!activePatientId) { setHistoryPatientId(null); setHistory([]); setHistoryLoading(false); + setPatientLabCases([]); + setPatientLabCasesLoading(false); + setSelectedRailLabCaseId(null); return; } - const nextPatientId = selectedAppointment.patientId; setHistoryPatientId((prev) => { - if (prev !== nextPatientId) { + if (prev !== activePatientId) { setHistory([]); setHistoryLoading(true); } - return nextPatientId; + return activePatientId; }); - }, [selectedAppointment?.patientId]); + }, [activePatientId]); + + const refreshPatientLabCases = useCallback( + async (patientId: string, options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setPatientLabCasesLoading(true); + try { + const response = await treatmentsApi.listPatientLabCases(patientId); + setPatientLabCases(response.data ?? []); + } catch { + if (!silent) setPatientLabCases([]); + } finally { + if (!silent) setPatientLabCasesLoading(false); + } + }, + [], + ); + + const refreshUnreadLabCases = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setUnreadLabCasesLoading(true); + try { + const response = await treatmentsApi.listUnreadLabCases(); + setUnreadLabCases(response.data ?? []); + } catch { + if (!silent) setUnreadLabCases([]); + } finally { + if (!silent) setUnreadLabCasesLoading(false); + } + }, []); + + const handleLabCaseMarkedRead = useCallback((labCaseId: string) => { + setPatientLabCases((prev) => + prev.map((item) => (item.labCaseId === labCaseId ? { ...item, hasUnread: false } : item)), + ); + setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId)); + }, []); + + useEffect(() => { + void refreshUnreadLabCases(); + }, [refreshUnreadLabCases]); + + useEffect(() => { + if (initialLabCasesScopeSetRef.current) return; + if ((tabBadgeCounts.treatment ?? 0) > 0) { + setLabCasesScope('updates'); + initialLabCasesScopeSetRef.current = true; + return; + } + if (activePatientId) { + setLabCasesScope('patient'); + initialLabCasesScopeSetRef.current = true; + } + }, [tabBadgeCounts.treatment, activePatientId]); + + useEffect(() => { + if (unreadLabCases.length === 0 && labCasesScope === 'updates' && activePatientId) { + setLabCasesScope('patient'); + } + if (unreadLabCases.length > 0 && !activePatientId && labCasesScope === 'patient') { + setLabCasesScope('updates'); + } + }, [unreadLabCases.length, labCasesScope, activePatientId]); useEffect(() => { if (!historyPatientId) return; @@ -604,6 +746,7 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.listPatientHistory(historyPatientId, 50); if (requestId !== historyRequestRef.current) return; setHistory(response.data); + void refreshPatientLabCases(historyPatientId); } catch (error: unknown) { if (requestId !== historyRequestRef.current) return; showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); @@ -613,7 +756,16 @@ export function TreatmentWorkspace({ } } })(); - }, [historyPatientId, showError, t]); + }, [historyPatientId, refreshPatientLabCases, showError, t, tErrors]); + + useEffect(() => { + const onBadgesChanged = () => { + if (historyPatientId) void refreshPatientLabCases(historyPatientId, { silent: true }); + void refreshUnreadLabCases({ silent: true }); + }; + window.addEventListener(tabBadgesChangedEventName(), onBadgesChanged); + return () => window.removeEventListener(tabBadgesChangedEventName(), onBadgesChanged); + }, [historyPatientId, refreshPatientLabCases, refreshUnreadLabCases]); useEffect(() => { const appointmentId = selectedAppointment?.id; @@ -731,11 +883,12 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.listPatientHistory(patientId, 50); if (requestId !== historyRequestRef.current) return; setHistory(response.data); + void refreshPatientLabCases(patientId); } catch (error: unknown) { if (requestId !== historyRequestRef.current) return; showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); } - }, [showError, t]); + }, [refreshPatientLabCases, showError, t, tErrors]); const runDraftSave = useCallback(async () => { if (!selectedAppointment || saveInFlightRef.current) { @@ -835,6 +988,7 @@ export function TreatmentWorkspace({ const ok = await flushDraftSave(); if (!ok) return; resetToLiveContext(); + setSearchedPatient(null); setSelectionLocked(true); setSelectedAppointmentId(id); })(); @@ -851,6 +1005,7 @@ export function TreatmentWorkspace({ if (!ok) return; const patientIdToRefresh = historyPatientId; resetToLiveContext(); + setSearchedPatient(null); setSelectionLocked(false); setSelectedDay(startOfLocalDay(day)); if (patientIdToRefresh) { @@ -870,7 +1025,11 @@ export function TreatmentWorkspace({ }, []); const loadTreatmentIntoWorkspace = useCallback( - async (treatment: PastTreatment, focusDetailClientId?: string) => { + async ( + treatment: PastTreatment, + focusDetailClientId?: string, + options?: { scrollToLabPanel?: boolean }, + ) => { if (!treatment.appointmentId) { showError(t('errorNoAppointmentForTreatment')); return false; @@ -882,7 +1041,8 @@ export function TreatmentWorkspace({ const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart); setWorkspaceMode(isHistorical ? 'historical' : 'live'); setSelectedPreviewId(null); - setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt))); + const nextDay = startOfLocalDay(new Date(treatment.treatmentAt)); + setSelectedDay((prev) => (compareLocalDayStart(prev, nextDay) === 0 ? prev : nextDay)); setSelectionLocked(true); setSelectedAppointmentId(treatment.appointmentId); @@ -902,9 +1062,11 @@ export function TreatmentWorkspace({ if (linked) { setActiveLabCaseId(linked.clientId); } - requestAnimationFrame(() => { - scrollWithinMainScrollContainer(labPanelRef.current); - }); + if (options?.scrollToLabPanel !== false) { + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + } } return true; @@ -912,6 +1074,38 @@ export function TreatmentWorkspace({ [flushDraftSave, hydrateFromTreatment, showError, t, todayStart], ); + const handleSelectSearchedPatient = useCallback( + (patient: Patient) => { + void (async () => { + setPatientSearchBusy(true); + setSearchedPatient({ + id: patient.id, + firstName: patient.firstName, + lastName: patient.lastName, + }); + try { + const response = await treatmentsApi.listPatientHistory(patient.id, 1); + const latest = response.data[0]; + if (!latest) { + showError(t('errorNoTreatmentForPatient')); + setSearchedPatient(null); + return; + } + const ok = await loadTreatmentIntoWorkspace(latest); + if (!ok) { + setSearchedPatient(null); + } + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorNoTreatmentForPatient'))); + setSearchedPatient(null); + } finally { + setPatientSearchBusy(false); + } + })(); + }, + [loadTreatmentIntoWorkspace, showError, t, tErrors], + ); + const handleLoadIntoWorkspace = useCallback(() => { if (!previewTreatment) return; void loadTreatmentIntoWorkspace(previewTreatment); @@ -943,6 +1137,97 @@ export function TreatmentWorkspace({ [exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], ); + const activeLabCaseSummary = useMemo(() => { + if (activeSentLabCaseId) { + return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null; + } + return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null; + }, [patientLabCases, activeSentLabCaseId, activeDetailId]); + + const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => { + setPatientLabCases((prev) => + prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)), + ); + }, []); + + const handleSelectPatientLabCase = useCallback( + (item: PatientLabCaseSummary) => { + void (async () => { + setSelectedRailLabCaseId(item.labCaseId); + + // Ensure a patient context is established before we potentially clear the last unread update, + // so the rail section doesn't briefly unmount/collapse. + if (item.patientId && item.patientId !== activePatientId) { + setSearchedPatient({ + id: item.patientId, + firstName: item.patientFirstName, + lastName: item.patientLastName, + }); + } + + try { + await notificationsApi.markCaseRead(item.labCaseId); + notifyTabBadgesChanged(); + handleLabCaseMarkedRead(item.labCaseId); + } catch { + // Non-blocking — workspace navigation still proceeds. + } + + let treatment = + history.find((entry) => entry.id === item.treatmentId) ?? + historyPanelItems.find((entry) => entry.id === item.treatmentId) ?? + (selectedAppointment?.id === item.appointmentId ? currentDraftPreview : null); + + if (!treatment && item.patientId) { + try { + const response = await treatmentsApi.listPatientHistory(item.patientId, 50); + treatment = response.data.find((entry) => entry.id === item.treatmentId) ?? null; + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); + return; + } + } + + if (!treatment?.appointmentId) return; + + if ( + selectedAppointmentId === treatment.appointmentId && + workspaceMode === 'live' && + !isBrowsing + ) { + setActiveDetailId(item.detailClientId); + return; + } + + await loadTreatmentIntoWorkspace(treatment, item.detailClientId, { + scrollToLabPanel: false, + }); + })(); + }, + [ + activePatientId, + currentDraftPreview, + handleLabCaseMarkedRead, + history, + historyPanelItems, + isBrowsing, + loadTreatmentIntoWorkspace, + selectedAppointment?.id, + selectedAppointmentId, + showError, + t, + tErrors, + workspaceMode, + ], + ); + + useEffect(() => { + const match = patientLabCases.find((item) => item.detailClientId === activeDetailId); + if (match) { + setSelectedRailLabCaseId(match.labCaseId); + } + }, [activeDetailId, patientLabCases]); + const uploadForDetail = useCallback( async (detailClientId: string, files: FileList | File[]) => { if (!canEditTreatmentForDay || !selectedAppointment) return; @@ -1222,6 +1507,9 @@ export function TreatmentWorkspace({ }); showSuccess(t('successCaseSent')); notifyTabBadgesChanged(); + if (selectedAppointment.patientId) { + void refreshPatientLabCases(selectedAppointment.patientId); + } } catch (error: unknown) { showError(getUserFacingError(error, tErrors, t('errorSendCase'))); } finally { @@ -1233,9 +1521,11 @@ export function TreatmentWorkspace({ selectedAppointment, persistDraft, persistLabCases, + refreshPatientLabCases, showSuccess, showError, t, + tErrors, ], ); @@ -1283,82 +1573,147 @@ export function TreatmentWorkspace({
        - {selectedAppointment ? ( -
        -

        {t('selectedPatient')}

        -

        - {selectedAppointment.patientFirstName} {selectedAppointment.patientLastName} -

        -

        - {t('purposeLabel')}{' '} - - {treatmentTypeLabelFromCatalog(selectedAppointment.purpose, treatmentCatalog)} - -

        -
        - ) : ( -
        - {apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')} -
        - )} +
        + - - - {isBrowsing && previewTreatment ? ( -
        -

        - {t('browseBanner', { - date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - }), - })} -

        -
        - - + {activePatient ? ( +
        +

        {t('selectedPatient')}

        +

        {activePatientName}

        + {activePatient.purpose ? ( +

        + {t('purposeLabel')}{' '} + + {treatmentTypeLabelFromCatalog(activePatient.purpose, treatmentCatalog)} + +

        + ) : null}
        -
        + ) : ( +

        + {apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')} +

        + )} +
        + + {workspaceMode === 'live' && !isBrowsing && selectedAppointment ? ( + ) : null} - + {isBrowsing && previewTreatment ? ( + <> +
        +

        + {t('browseBanner', { + date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }), + })} +

        +
        + + +
        +
        + +
        + +
        +
        + + ) : null} - + {labAttentionItems.length > 0 ? ( + + + + ) : null} + + {activePatient ? ( + + + + ) : null} + + {showLabShipmentsSection ? ( + + + + ) : null}
        @@ -1411,7 +1766,6 @@ export function TreatmentWorkspace({ setActiveDetailId(next.clientId); }} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} - onCommentError={showError} />
        @@ -1423,6 +1777,15 @@ export function TreatmentWorkspace({ labCases={labCaseDrafts} labDependentCodes={labDependentCodes} treatmentCatalog={treatmentCatalog} + labCaseSummary={activeLabCaseSummary} + locale={locale} + onLabCaseSummaryChange={handleLabCaseSummaryChange} + onLabCaseMarkedRead={handleLabCaseMarkedRead} + onLabCaseActivityChange={() => { + if (historyPatientId) { + void refreshPatientLabCases(historyPatientId, { silent: true }); + } + }} activeLabCaseId={activeLabCaseId} onLabCasesChange={handleLabCasesChange} disabled={!canEditTreatmentForDay} diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 37e43cf..4f4ed3c 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/lib/api/client'; +import type { LabCaseActivityItem } from '@/types/lab-case-activity'; import type { LabCaseTabReadTarget, TabBadgeCounts } from '@/lib/tabBadgeUtils'; export const notificationsApi = { @@ -7,6 +8,16 @@ export const notificationsApi = { return response.data; }, + listLabCaseActivities: async ( + labCaseId: string, + limit = 50, + ): Promise<{ success: boolean; data: LabCaseActivityItem[] }> => { + const response = await apiClient.get(`/notifications/lab-cases/${labCaseId}/activities`, { + params: { limit }, + }); + return response.data; + }, + markTabRead: async (tab: LabCaseTabReadTarget): Promise<{ success: boolean }> => { const response = await apiClient.post('/notifications/mark-tab-read', { tab }); return response.data; diff --git a/frontend/src/lib/api/treatments.ts b/frontend/src/lib/api/treatments.ts index ff027fe..e7bc414 100644 --- a/frontend/src/lib/api/treatments.ts +++ b/frontend/src/lib/api/treatments.ts @@ -24,6 +24,21 @@ export const treatmentsApi = { return response.data; }, + listPatientLabCases: async ( + patientId: string, + ): Promise<{ success: boolean; data: import('@/types/lab-case-activity').PatientLabCaseSummary[] }> => { + const response = await apiClient.get(`/treatments/patients/${patientId}/lab-cases`); + return response.data; + }, + + listUnreadLabCases: async (): Promise<{ + success: boolean; + data: import('@/types/lab-case-activity').PatientLabCaseSummary[]; + }> => { + const response = await apiClient.get('/treatments/lab-cases/unread'); + return response.data; + }, + getDraft: async ( appointmentId: string, ): Promise<{ success: boolean; data: PastTreatment | null }> => { diff --git a/frontend/src/lib/hooks/useTabBadgeCounts.ts b/frontend/src/lib/hooks/useTabBadgeCounts.ts index 03da398..2ad3af7 100644 --- a/frontend/src/lib/hooks/useTabBadgeCounts.ts +++ b/frontend/src/lib/hooks/useTabBadgeCounts.ts @@ -50,8 +50,8 @@ export function useMarkTabReadOnVisit() { useEffect(() => { const tab = tabFromPathname(pathname); - // Cases tab badge clears per opened case (mark-case-read), not on tab visit. - if (!tab || tab === 'CASES' || !currentOrganization?.id) return; + // Cases and Treatment tab badges clear per opened case (mark-case-read), not on tab visit. + if (!tab || tab === 'CASES' || tab === 'TREATMENT' || !currentOrganization?.id) return; void notificationsApi.markTabRead(tab).then(() => { window.dispatchEvent(new Event(tabBadgesChangedEventName())); diff --git a/frontend/src/lib/labCaseActivityLabels.ts b/frontend/src/lib/labCaseActivityLabels.ts new file mode 100644 index 0000000..772c505 --- /dev/null +++ b/frontend/src/lib/labCaseActivityLabels.ts @@ -0,0 +1,56 @@ +import type { LabCaseActivityItem } from '@/types/lab-case-activity'; + +type ActivityLabelTranslator = ( + key: string, + values?: Record, +) => string; + +export function formatLabCaseActivityLine( + activity: LabCaseActivityItem, + t: ActivityLabelTranslator, + locale: string, +): string { + const actor = activity.actorName ?? t('activityUnknownActor'); + const date = new Date(activity.createdAt).toLocaleString(locale, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + switch (activity.type) { + case 'CASE_SENT': + return t('activityCaseSent', { date }); + case 'CLINIC_COMMENT': + return t('activityClinicComment', { + actor, + preview: truncatePreview(activity.commentBody), + date, + }); + case 'LAB_COMMENT': + return t('activityLabComment', { + actor, + preview: truncatePreview(activity.commentBody), + date, + }); + case 'TASK_COMPLETED': + return t('activityTaskCompleted', { + step: activity.stepLabel ?? t('activityUnknownStep'), + actor, + date, + }); + case 'CASE_IMPORTANT': + return t('activityCaseImportant', { actor, date }); + case 'CASE_AMENDED': + return t('activityCaseAmended', { actor, date }); + default: + return t('activityGeneric', { date }); + } +} + +function truncatePreview(text: string | null | undefined, max = 60): string { + const trimmed = text?.trim() ?? ''; + if (!trimmed) return '…'; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max - 1)}…`; +} diff --git a/frontend/src/types/lab-case-activity.ts b/frontend/src/types/lab-case-activity.ts new file mode 100644 index 0000000..6ca2d07 --- /dev/null +++ b/frontend/src/types/lab-case-activity.ts @@ -0,0 +1,45 @@ +export type LabCaseActivityType = + | 'CASE_SENT' + | 'CLINIC_COMMENT' + | 'LAB_COMMENT' + | 'CASE_IMPORTANT' + | 'CASE_AMENDED' + | 'TASK_COMPLETED'; + +export interface LabCaseActivityItem { + id: string; + labCaseId: string; + type: LabCaseActivityType; + createdAt: string; + actorName: string | null; + commentBody?: string | null; + stepLabel?: string | null; + visibleToClinic?: boolean; +} + +export interface PatientLabCaseProsthesisGroup { + prosthesisTypeCode: string; + teeth: string[]; +} + +export interface PatientLabCaseSummary { + labCaseId: string; + patientId: string; + patientFirstName: string; + patientLastName: string; + treatmentId: string; + appointmentId: string | null; + treatmentAt: string; + detailClientId: string; + teeth: string[]; + prosthesisGroups: PatientLabCaseProsthesisGroup[]; + toothCount: number; + labOrganizationId: string | null; + labName: string; + sentAt: string | null; + dueDate: string | null; + isOverdue: boolean; + taskProgress: { completed: number; total: number }; + hasUnread: boolean; + lastActivity: LabCaseActivityItem | null; +} -- 2.53.0.windows.1 From ec2b23f4b1d00694f8ede55f61b6788552b8764a Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 14 Jul 2026 01:27:11 +0330 Subject: [PATCH 18/24] improvement: rtl direction, solar calendar and persian formatting added for persian users. --- .cursor/rules/dyolink-overview.mdc | 2 + .cursor/rules/frontend-components.mdc | 11 + .cursor/skills/i18n-formatting/SKILL.md | 80 ++++++ AGENTS.md | 3 + .../src/app/[locale]/(dashboard)/layout.tsx | 2 +- frontend/src/app/[locale]/layout.tsx | 28 +- .../appointments/appointmentTime.ts | 7 +- .../src/components/lab/caseDetailUtils.ts | 6 +- .../components/lab/labCaseDueDateDisplay.ts | 9 +- .../src/components/shared/formSelectStyles.ts | 9 +- frontend/src/components/staff/workingHours.ts | 7 +- .../src/components/today/chart-day-labels.ts | 12 +- .../src/components/treatment/caseSendLabel.ts | 9 +- .../AppointmentOverlapPopover.tsx | 13 +- .../appointments/AppointmentScheduleGrid.tsx | 7 +- .../src/components/ui/billing/BillingPage.tsx | 50 ++-- .../src/components/ui/lab/CaseDetailPanel.tsx | 2 +- frontend/src/components/ui/lab/CasesPage.tsx | 17 +- .../components/ui/lab/TaskCaseGroupHeader.tsx | 7 +- frontend/src/components/ui/lab/TaskRow.tsx | 7 +- frontend/src/components/ui/lab/TasksPage.tsx | 16 +- .../organizations/InvitationHistoryDialog.tsx | 34 +-- .../ui/organizations/OrganizationsPage.tsx | 51 ++-- .../ui/patient/PatientAppointmentHistory.tsx | 24 +- .../src/components/ui/shared/AppDateInput.tsx | 175 ++++++++++++ .../ui/shared/CalendarDayPartsPanel.tsx | 166 +++++++++++ .../ui/shared/CalendarDaySelect.tsx | 188 +++++++++++++ .../components/ui/shared/CompactSelect.tsx | 15 + .../src/components/ui/shared/Dropdown.tsx | 44 ++- .../ui/shared/ScheduleDayPicker.tsx | 264 +----------------- frontend/src/components/ui/shared/Sidebar.tsx | 9 +- frontend/src/components/ui/shared/Table.tsx | 3 +- .../src/components/ui/staff/StaffPage.tsx | 24 +- .../src/components/ui/today/TodayPage.tsx | 9 +- .../ui/today/TodayUpcomingAppointments.tsx | 9 +- .../ui/treatment/AppointmentsStrip.tsx | 16 +- .../components/ui/treatment/CaseSentLabel.tsx | 5 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 16 +- .../treatment/LabDispatchAttentionPanel.tsx | 10 +- .../ui/treatment/PastTreatmentsPanel.tsx | 26 +- .../ui/treatment/TreatmentPreviewCard.tsx | 11 +- .../ui/treatment/TreatmentRailSection.tsx | 2 +- .../ui/treatment/TreatmentWorkspace.tsx | 14 +- frontend/src/i18n/routing.ts | 7 + frontend/src/lib/hooks/useAppFormatters.ts | 45 +++ frontend/src/lib/i18n/dateInputFormat.ts | 43 +++ frontend/src/lib/i18n/format.ts | 157 +++++++++++ frontend/src/lib/i18n/persianCalendar.ts | 214 ++++++++++++++ frontend/src/lib/labCaseActivityLabels.ts | 8 +- frontend/src/styles/globals.css | 45 ++- 50 files changed, 1410 insertions(+), 528 deletions(-) create mode 100644 .cursor/skills/i18n-formatting/SKILL.md create mode 100644 frontend/src/components/ui/shared/AppDateInput.tsx create mode 100644 frontend/src/components/ui/shared/CalendarDayPartsPanel.tsx create mode 100644 frontend/src/components/ui/shared/CalendarDaySelect.tsx create mode 100644 frontend/src/components/ui/shared/CompactSelect.tsx create mode 100644 frontend/src/lib/hooks/useAppFormatters.ts create mode 100644 frontend/src/lib/i18n/dateInputFormat.ts create mode 100644 frontend/src/lib/i18n/format.ts create mode 100644 frontend/src/lib/i18n/persianCalendar.ts diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index 581523d..08edc17 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -24,6 +24,8 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**. +Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native ``); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Appointments: `ScheduleDayPicker`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`. + ## Treatment / appointment colors Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + `catalog-type-colors.ts`. UI badges: `components/ui/treatment/TreatmentTypeBadge.tsx`. diff --git a/.cursor/rules/frontend-components.mdc b/.cursor/rules/frontend-components.mdc index 2714891..f338867 100644 --- a/.cursor/rules/frontend-components.mdc +++ b/.cursor/rules/frontend-components.mdc @@ -51,3 +51,14 @@ Reference: `app/.../treatment/page.tsx` + `components/ui/treatment/TreatmentWork 1. Check `components/ui/shared/` for an existing primitive. 2. Check the feature's `ui/{feature}/` folder for an existing pattern. 3. Add i18n keys to en, fa, and nl. + +## Shared form & table primitives + +| Need | Use | +|------|-----| +| Date filter / due date field | `AppDateInput` (`ui/shared/`) — not `` | +| Filter or inline `` filters/fields — `ps-3 pe-10`, chevron from `globals.css` | +| **`FORM_SELECT_COMPACT_CLASS`** | Tiny selects (e.g. sort direction `↓`/`↑`) — symmetric `px-2`, no chevron gutter | +| **`Dropdown`** | Labeled form select — only where already used; prefer `FORM_SELECT_CLASS` for new filters | +| **`CompactSelect`** | Year/month/day sub-selects inside calendar panels | + +**`AppDateInput` behavior** + +- `fa`: Jalali display `YYYY/MM/DD` (Persian digits), parse/mask in `persianCalendar.ts` +- `en` / `nl`: Gregorian display `YYYY-MM-DD`, parse/mask in `dateInputFormat.ts` +- Calendar icon at **`end-3`** (matches select chevron inset); text uses **`text-start`** (logical — right in RTL, left in LTR) +- Popup: **`CalendarDayPartsPanel`** (shared with schedule picker) +- **Do not** add native `` — one component for all locales + +**Select chevron** + +- Defined once in `frontend/src/styles/globals.css` on `.form-select:not(.form-select-no-chevron)` +- RTL: `background-position: left 0.75rem center`; LTR: `right 0.75rem center` +- `text-align: start` on selects + +## Calendar / appointment pickers + +| Component | Use for | +|-----------|---------| +| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel | +| `CalendarDaySelect` | Navigator wrapper (arrows + panel) | +| `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` | + +Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged. + +- Conversion: `frontend/src/lib/i18n/persianCalendar.ts` +- Gregorian typing: `frontend/src/lib/i18n/dateInputFormat.ts` +- Gregorian month labels: `schedule.monthJanuary` … message keys + +## RTL + +- `dir` / `lang` on `` from `app/[locale]/layout.tsx` +- `isRtlLocale` in `frontend/src/i18n/routing.ts` +- Use **logical** CSS: `text-start`, `text-end`, `ps-*`, `pe-*`, `ms-*`, `me-*` +- **`Table`**: default `[&_th]:text-start [&_td]:text-start`; override with `text-center` or `text-end` on cells — **never** `text-left` / `text-right` on headers (causes header/body column drift in RTL) +- Minimal overrides in `globals.css` — avoid double-mirroring (no extra `row-reverse` on shells that already inherit `direction: rtl`) + +## i18n strings + +- User-facing copy: `frontend/messages/{en,fa,nl}.json` — all three locales. + +## Verify + +```bash +cd frontend && npx tsc --noEmit +``` + +Manual: switch to Persian — Cases date filters, Tasks filter row (single line + sort visible), Staff/Orgs table columns aligned; switch to English — date fields match adjacent dropdown alignment. diff --git a/AGENTS.md b/AGENTS.md index 7154132..dfe31c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,8 @@ frontend/src/ **Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. +**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter `