From 39935688ad125192ffe74690c4ca27c7e88681e2 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 12 Jul 2026 15:11:50 +0330 Subject: [PATCH] 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; + }, +};