improvement: a flow added for owner users to make it possible for them to participate in treatments or tasks.
This commit is contained in:
96
backend/src/common/membership-permissions.ts
Normal file
96
backend/src/common/membership-permissions.ts
Normal file
@@ -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<string>(CLINIC_PARTICIPATION_PERMISSIONS);
|
||||
const LAB_PARTICIPATION_SET = new Set<string>(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);
|
||||
}
|
||||
@@ -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<string>(['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT']);
|
||||
const OWNER_OPT_IN_LAB = new Set<string>(['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(
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
6
backend/src/modules/auth/dto/update-participation.dto.ts
Normal file
6
backend/src/modules/auth/dto/update-participation.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateParticipationDto {
|
||||
@IsBoolean()
|
||||
participate: boolean;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +564,11 @@ export class TodayService {
|
||||
const members = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ isOwner: true },
|
||||
{
|
||||
isOwner: false,
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "موبایل شما تأیید شد. رمز عبور جدید برای حساب خود انتخاب کنید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(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,19 +274,68 @@ export default function AccountSettingsPage() {
|
||||
{tCommon('backToApp')}
|
||||
</Link>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary mt-4">{t('accountTitle')}</h1>
|
||||
<p className="text-text-secondary text-sm mt-2">
|
||||
{isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')}
|
||||
</p>
|
||||
<p className="text-text-secondary text-sm mt-2">{t('accountSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="surface-card p-4 sm:p-6 max-w-lg">
|
||||
<h2 className="text-lg font-medium text-text-primary mb-1">
|
||||
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{(showClinicParticipation || showLabParticipation) && (
|
||||
<div className="surface-card p-4 sm:p-6 max-w-lg space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-primary">{t('participationSectionTitle')}</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('participationSectionSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{showClinicParticipation && (
|
||||
<Checkbox
|
||||
checked={participatesInTreatments}
|
||||
onChange={handleClinicParticipationChange}
|
||||
disabled={participationLoading}
|
||||
label={t('participateInTreatments')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLabParticipation && (
|
||||
<Checkbox
|
||||
checked={participatesInTasks}
|
||||
onChange={handleLabParticipationChange}
|
||||
disabled={participationLoading}
|
||||
label={t('participateInTasks')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="surface-card max-w-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between gap-3 p-4 sm:p-6 text-left hover:bg-background-card/40 transition-colors"
|
||||
onClick={() => setPasswordExpanded((open) => !open)}
|
||||
aria-expanded={passwordExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Lock className="h-5 w-5 icon-flat shrink-0" />
|
||||
<div>
|
||||
<p className="text-base font-medium text-text-primary">{t('changePasswordOption')}</p>
|
||||
<p className="text-sm text-text-secondary truncate">
|
||||
{user.email}
|
||||
{user.mobile ? ` · ${user.mobile}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-text-muted shrink-0 transition-transform ${
|
||||
passwordExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{passwordExpanded && (
|
||||
<div className="border-t border-border px-4 sm:px-6 pb-4 sm:pb-6 pt-4">
|
||||
<h2 className="text-lg font-medium text-text-primary mb-1">
|
||||
{isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')}
|
||||
</h2>
|
||||
{isResetFlow && (
|
||||
<p className="text-sm text-text-secondary mb-4">{t('resetPasswordSubtitle')}</p>
|
||||
)}
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isResetFlow && (
|
||||
@@ -179,6 +381,70 @@ export default function AccountSettingsPage() {
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && !passwordExpanded && (
|
||||
<div className="max-w-lg p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OwnerWorkingHoursDialog
|
||||
open={workingHoursOpen}
|
||||
onClose={handleWorkingHoursClose}
|
||||
onComplete={handleWorkingHoursComplete}
|
||||
/>
|
||||
|
||||
{revokeConfirmOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
|
||||
<div
|
||||
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="revoke-participation-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 id="revoke-participation-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
{t('participateConfirmRevokeTitle')}
|
||||
</h2>
|
||||
<DialogCloseButton
|
||||
onClick={() => {
|
||||
if (participationLoading) return;
|
||||
setRevokeConfirmOpen(false);
|
||||
setPendingRevokeType(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{pendingRevokeType === 'CLINIC'
|
||||
? t('participateConfirmRevokeBodyTreatments')
|
||||
: t('participateConfirmRevokeBodyTasks')}
|
||||
</p>
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={participationLoading}
|
||||
onClick={() => {
|
||||
setRevokeConfirmOpen(false);
|
||||
setPendingRevokeType(null);
|
||||
}}
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
isLoading={participationLoading}
|
||||
onClick={() => void confirmRevokeParticipation()}
|
||||
>
|
||||
{t('participateConfirmRevokeConfirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Toast variant="success">{successMessage}</Toast>
|
||||
|
||||
161
frontend/src/components/settings/OwnerWorkingHoursDialog.tsx
Normal file
161
frontend/src/components/settings/OwnerWorkingHoursDialog.tsx
Normal file
@@ -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<typeof workingHoursPayloadFromState>;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
|
||||
<div
|
||||
className="w-full sm:max-w-lg max-h-[90dvh] overflow-y-auto rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-md)] border border-border bg-background-secondary p-4 sm:p-6 shadow-xl space-y-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="owner-working-hours-title"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 id="owner-working-hours-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
{t('participateWorkingHoursTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mt-1">{t('participateWorkingHoursSubtitle')}</p>
|
||||
</div>
|
||||
<DialogCloseButton onClick={handleClose} />
|
||||
</div>
|
||||
|
||||
<StaffWorkingHoursStep
|
||||
days={days}
|
||||
autoRepeatWeekly={autoRepeatWeekly}
|
||||
onDaysChange={setDays}
|
||||
onAutoRepeatWeeklyChange={setAutoRepeatWeekly}
|
||||
onValidationChange={handleValidationChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-2">
|
||||
<Button variant="outline" type="button" disabled={loading} onClick={handleClose}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
isLoading={loading}
|
||||
onClick={() => void handleSkip()}
|
||||
>
|
||||
{tStaff('skipForNow')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
isLoading={loading}
|
||||
disabled={Boolean(validationError)}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('participateSaveHours')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { workingHoursPayloadFromState };
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
48
frontend/src/lib/api/account.ts
Normal file
48
frontend/src/lib/api/account.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user