feature: phase1 - org-type navigation, Cases permissions, staff filtering, and route guards.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- Add Cases tab permissions for lab organizations
|
||||||
|
|
||||||
|
INSERT INTO "features" ("id", "name", "description", "organizationTypeId")
|
||||||
|
VALUES (gen_random_uuid(), 'Cases', 'Lab cases inbox', NULL)
|
||||||
|
ON CONFLICT ("name") DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "permissions" ("id", "name", "description", "featureId")
|
||||||
|
SELECT gen_random_uuid(), v.name, NULL, f.id
|
||||||
|
FROM (VALUES
|
||||||
|
('TAB_CASES_READ'),
|
||||||
|
('TAB_CASES_EDIT')
|
||||||
|
) AS v(name)
|
||||||
|
CROSS JOIN "features" f
|
||||||
|
WHERE f.name = 'Cases'
|
||||||
|
ON CONFLICT ("name") DO NOTHING;
|
||||||
@@ -89,6 +89,10 @@ async function main() {
|
|||||||
name: 'Treatment',
|
name: 'Treatment',
|
||||||
permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
|
permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Cases',
|
||||||
|
permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Billing',
|
name: 'Billing',
|
||||||
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
|
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
|
||||||
|
|||||||
25
backend/src/common/guards/clinic-org.guard.ts
Normal file
25
backend/src/common/guards/clinic-org.guard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { assertClinicOrganization } from '../../common/organization-type';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClinicOrgGuard implements CanActivate {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<{ user?: { organizationId?: string } }>();
|
||||||
|
const organizationId = request.user?.organizationId;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
throw new UnauthorizedException('Organization is not selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertClinicOrganization(this.prisma, organizationId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/src/common/organization-type.ts
Normal file
100
backend/src/common/organization-type.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions';
|
||||||
|
|
||||||
|
export type OrganizationTypeName = 'CLINIC' | 'LAB';
|
||||||
|
|
||||||
|
const CLINIC_ONLY_PERMISSIONS = new Set<string>([
|
||||||
|
'TAB_PATIENTS_READ',
|
||||||
|
'TAB_PATIENTS_EDIT',
|
||||||
|
'TAB_APPOINTMENTS_READ',
|
||||||
|
'TAB_APPOINTMENTS_EDIT',
|
||||||
|
'TAB_TREATMENT_READ',
|
||||||
|
'TAB_TREATMENT_EDIT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LAB_ONLY_PERMISSIONS = new Set<string>(['TAB_CASES_READ', 'TAB_CASES_EDIT']);
|
||||||
|
|
||||||
|
const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter(
|
||||||
|
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CLINIC_TAB_PERMISSIONS = [
|
||||||
|
...SHARED_PERMISSIONS,
|
||||||
|
...CLINIC_ONLY_PERMISSIONS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const LAB_TAB_PERMISSIONS = [
|
||||||
|
...SHARED_PERMISSIONS,
|
||||||
|
...LAB_ONLY_PERMISSIONS,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CLINIC_TAB_SET = new Set<string>(CLINIC_TAB_PERMISSIONS);
|
||||||
|
const LAB_TAB_SET = new Set<string>(LAB_TAB_PERMISSIONS);
|
||||||
|
|
||||||
|
export function permissionsAllowedForOrgType(orgType: OrganizationTypeName): Set<string> {
|
||||||
|
return orgType === 'LAB' ? LAB_TAB_SET : CLINIC_TAB_SET;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPermissionsForOrgType(
|
||||||
|
names: string[],
|
||||||
|
orgType: OrganizationTypeName,
|
||||||
|
): string[] {
|
||||||
|
const allowed = permissionsAllowedForOrgType(orgType);
|
||||||
|
return normalizeTabPermissions(names.filter((n) => allowed.has(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ownerPermissionsForOrgType(
|
||||||
|
orgType: OrganizationTypeName,
|
||||||
|
hasActivePlan: boolean,
|
||||||
|
): string[] {
|
||||||
|
if (hasActivePlan) {
|
||||||
|
return orgType === 'LAB' ? [...LAB_TAB_PERMISSIONS] : [...CLINIC_TAB_PERMISSIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOnly = (perms: readonly string[]) =>
|
||||||
|
normalizeTabPermissions(perms.filter((p) => p.endsWith('_READ')));
|
||||||
|
|
||||||
|
return orgType === 'LAB' ? readOnly(LAB_TAB_PERMISSIONS) : readOnly(CLINIC_TAB_PERMISSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationTypeName(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<OrganizationTypeName> {
|
||||||
|
const org = await prisma.organization.findUnique({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { type: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = org.type.name;
|
||||||
|
if (name !== 'CLINIC' && name !== 'LAB') {
|
||||||
|
throw new ForbiddenException('Unknown organization type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertClinicOrganization(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||||
|
if (type !== 'CLINIC') {
|
||||||
|
throw new ForbiddenException('This action is only available for clinic organizations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertLabOrganization(
|
||||||
|
prisma: PrismaService,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||||
|
if (type !== 'LAB') {
|
||||||
|
throw new ForbiddenException('This action is only available for lab organizations');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export const ALL_TAB_PERMISSIONS = [
|
|||||||
'TAB_APPOINTMENTS_EDIT',
|
'TAB_APPOINTMENTS_EDIT',
|
||||||
'TAB_TREATMENT_READ',
|
'TAB_TREATMENT_READ',
|
||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
|
'TAB_CASES_READ',
|
||||||
|
'TAB_CASES_EDIT',
|
||||||
'TAB_BILLING_READ',
|
'TAB_BILLING_READ',
|
||||||
'TAB_BILLING_EDIT',
|
'TAB_BILLING_EDIT',
|
||||||
'TAB_REPORTS_READ',
|
'TAB_REPORTS_READ',
|
||||||
@@ -40,6 +42,7 @@ const EDIT_TO_READ: Record<string, string> = {
|
|||||||
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
|
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
|
||||||
TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
|
TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ',
|
||||||
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
|
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
|
||||||
|
TAB_CASES_EDIT: 'TAB_CASES_READ',
|
||||||
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
|
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
|
||||||
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
|
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AppointmentsService } from './appointments.service';
|
import { AppointmentsService } from './appointments.service';
|
||||||
import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto';
|
import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto';
|
||||||
@@ -20,7 +21,7 @@ import { UpdateAppointmentDto } from './dto/update-appointment.dto';
|
|||||||
|
|
||||||
@ApiTags('appointments')
|
@ApiTags('appointments')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||||
@Controller('appointments')
|
@Controller('appointments')
|
||||||
export class AppointmentsController {
|
export class AppointmentsController {
|
||||||
constructor(private readonly appointmentsService: AppointmentsService) {}
|
constructor(private readonly appointmentsService: AppointmentsService) {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { StaffModule } from '../staff/staff.module';
|
import { StaffModule } from '../staff/staff.module';
|
||||||
import { AppointmentsController } from './appointments.controller';
|
import { AppointmentsController } from './appointments.controller';
|
||||||
import { AppointmentsService } from './appointments.service';
|
import { AppointmentsService } from './appointments.service';
|
||||||
@@ -7,6 +8,6 @@ import { AppointmentsService } from './appointments.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [StaffModule],
|
imports: [StaffModule],
|
||||||
controllers: [AppointmentsController],
|
controllers: [AppointmentsController],
|
||||||
providers: [AppointmentsService, PrismaService],
|
providers: [AppointmentsService, PrismaService, ClinicOrgGuard],
|
||||||
})
|
})
|
||||||
export class AppointmentsModule {}
|
export class AppointmentsModule {}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
UpdateLanguageDto,
|
UpdateLanguageDto,
|
||||||
} from './dto/update-language.dto';
|
} from './dto/update-language.dto';
|
||||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||||
|
import { ownerPermissionsForOrgType, type OrganizationTypeName } from '../../common/organization-type';
|
||||||
|
|
||||||
const ALL_PERMISSIONS = [
|
const ALL_PERMISSIONS = [
|
||||||
'TAB_TODAY_READ',
|
'TAB_TODAY_READ',
|
||||||
@@ -34,6 +35,8 @@ const ALL_PERMISSIONS = [
|
|||||||
'TAB_APPOINTMENTS_EDIT',
|
'TAB_APPOINTMENTS_EDIT',
|
||||||
'TAB_TREATMENT_READ',
|
'TAB_TREATMENT_READ',
|
||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
|
'TAB_CASES_READ',
|
||||||
|
'TAB_CASES_EDIT',
|
||||||
'TAB_BILLING_READ',
|
'TAB_BILLING_READ',
|
||||||
'TAB_BILLING_EDIT',
|
'TAB_BILLING_EDIT',
|
||||||
'TAB_REPORTS_READ',
|
'TAB_REPORTS_READ',
|
||||||
@@ -806,11 +809,15 @@ export class AuthService {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
organization: {
|
organization: {
|
||||||
plan?: { name: string; maxUsers: number; price: number } | null;
|
plan?: { name: string; maxUsers: number; price: number } | null;
|
||||||
|
type?: { name: string };
|
||||||
};
|
};
|
||||||
permissions?: Array<{ permission: { name: string } }>;
|
permissions?: Array<{ permission: { name: string } }>;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
if (membership.isOwner) {
|
if (membership.isOwner) {
|
||||||
return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS;
|
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 membership.permissions?.map((p) => p.permission.name) || [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CreatePatientDto } from './dto/create-patient.dto';
|
import { CreatePatientDto } from './dto/create-patient.dto';
|
||||||
import { ListPatientsDto } from './dto/list-patients.dto';
|
import { ListPatientsDto } from './dto/list-patients.dto';
|
||||||
@@ -18,7 +19,7 @@ import { PatientsService } from './patients.service';
|
|||||||
|
|
||||||
@ApiTags('patients')
|
@ApiTags('patients')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||||
@Controller('patients')
|
@Controller('patients')
|
||||||
export class PatientsController {
|
export class PatientsController {
|
||||||
constructor(private readonly patientsService: PatientsService) {}
|
constructor(private readonly patientsService: PatientsService) {}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { PatientsController } from './patients.controller';
|
import { PatientsController } from './patients.controller';
|
||||||
import { PatientsService } from './patients.service';
|
import { PatientsService } from './patients.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PatientsController],
|
controllers: [PatientsController],
|
||||||
providers: [PatientsService, PrismaService],
|
providers: [PatientsService, PrismaService, ClinicOrgGuard],
|
||||||
})
|
})
|
||||||
export class PatientsModule {}
|
export class PatientsModule {}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||||
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
||||||
|
import {
|
||||||
|
filterPermissionsForOrgType,
|
||||||
|
getOrganizationTypeName,
|
||||||
|
} from '../../common/organization-type';
|
||||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||||
|
|
||||||
@@ -96,7 +100,8 @@ export class StaffService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const email = dto.email.trim().toLowerCase();
|
const email = dto.email.trim().toLowerCase();
|
||||||
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
|
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
||||||
|
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
||||||
|
|
||||||
const permissionRows = await this.prisma.permission.findMany({
|
const permissionRows = await this.prisma.permission.findMany({
|
||||||
where: { name: { in: normalizedPerms } },
|
where: { name: { in: normalizedPerms } },
|
||||||
@@ -371,7 +376,8 @@ export class StaffService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.permissionNames !== undefined) {
|
if (dto.permissionNames !== undefined) {
|
||||||
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
|
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
||||||
|
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
||||||
const permissionRows = await this.prisma.permission.findMany({
|
const permissionRows = await this.prisma.permission.findMany({
|
||||||
where: { name: { in: normalizedPerms } },
|
where: { name: { in: normalizedPerms } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import { FilesInterceptor } from '@nestjs/platform-express';
|
|||||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
|
import { SaveTreatmentDraftDto, SendTreatmentCaseDto } from './dto/treatment.dto';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
|
|
||||||
@ApiTags('treatments')
|
@ApiTags('treatments')
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||||
@Controller('treatments')
|
@Controller('treatments')
|
||||||
export class TreatmentsController {
|
export class TreatmentsController {
|
||||||
constructor(private readonly treatmentsService: TreatmentsService) {}
|
constructor(private readonly treatmentsService: TreatmentsService) {}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { TreatmentsController } from './treatments.controller';
|
import { TreatmentsController } from './treatments.controller';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TreatmentsController],
|
controllers: [TreatmentsController],
|
||||||
providers: [TreatmentsService, PrismaService],
|
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||||
})
|
})
|
||||||
export class TreatmentsModule {}
|
export class TreatmentsModule {}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"patients": "Patients",
|
"patients": "Patients",
|
||||||
"appointment": "Appointment",
|
"appointment": "Appointment",
|
||||||
"treatment": "Treatment",
|
"treatment": "Treatment",
|
||||||
|
"cases": "Cases",
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"clinics": "Clinics",
|
"clinics": "Clinics",
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
"featurePatients": "Patients",
|
"featurePatients": "Patients",
|
||||||
"featureAppointment": "Appointment",
|
"featureAppointment": "Appointment",
|
||||||
"featureTreatment": "Treatment",
|
"featureTreatment": "Treatment",
|
||||||
|
"featureCases": "Cases",
|
||||||
"featureBilling": "Billing",
|
"featureBilling": "Billing",
|
||||||
"featureReports": "Reports",
|
"featureReports": "Reports",
|
||||||
"noTabAccess": "No tab access",
|
"noTabAccess": "No tab access",
|
||||||
@@ -313,6 +315,10 @@
|
|||||||
"statusInactive": "Inactive",
|
"statusInactive": "Inactive",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-"
|
||||||
},
|
},
|
||||||
|
"cases": {
|
||||||
|
"title": "Cases",
|
||||||
|
"stubDescription": "Received lab cases from linked clinics will appear here. Full inbox and task workflow coming in a later phase."
|
||||||
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "Appointments",
|
"title": "Appointments",
|
||||||
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
"subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"patients": "بیماران",
|
"patients": "بیماران",
|
||||||
"appointment": "نوبتها",
|
"appointment": "نوبتها",
|
||||||
"treatment": "درمان",
|
"treatment": "درمان",
|
||||||
|
"cases": "پروندهها",
|
||||||
"billing": "صورتحساب",
|
"billing": "صورتحساب",
|
||||||
"reports": "گزارشها",
|
"reports": "گزارشها",
|
||||||
"clinics": "کلینیکها",
|
"clinics": "کلینیکها",
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
"featurePatients": "بیماران",
|
"featurePatients": "بیماران",
|
||||||
"featureAppointment": "نوبتها",
|
"featureAppointment": "نوبتها",
|
||||||
"featureTreatment": "درمان",
|
"featureTreatment": "درمان",
|
||||||
|
"featureCases": "پروندهها",
|
||||||
"featureBilling": "صورتحساب",
|
"featureBilling": "صورتحساب",
|
||||||
"featureReports": "گزارشها",
|
"featureReports": "گزارشها",
|
||||||
"noTabAccess": "دسترسی به برگهها وجود ندارد",
|
"noTabAccess": "دسترسی به برگهها وجود ندارد",
|
||||||
@@ -313,6 +315,10 @@
|
|||||||
"statusInactive": "غیرفعال",
|
"statusInactive": "غیرفعال",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-"
|
||||||
},
|
},
|
||||||
|
"cases": {
|
||||||
|
"title": "پروندهها",
|
||||||
|
"stubDescription": "پروندههای دریافتی از کلینیکهای متصل به زودی اینجا نمایش داده میشوند. صندوق ورودی کامل و گردش کار وظایف در فاز بعدی اضافه میشود."
|
||||||
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "نوبتها",
|
"title": "نوبتها",
|
||||||
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
"subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائهدهنده کلیک کنید تا رزرو کنید.",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"patients": "Patiënten",
|
"patients": "Patiënten",
|
||||||
"appointment": "Afspraak",
|
"appointment": "Afspraak",
|
||||||
"treatment": "Behandeling",
|
"treatment": "Behandeling",
|
||||||
|
"cases": "Dossiers",
|
||||||
"billing": "Facturatie",
|
"billing": "Facturatie",
|
||||||
"reports": "Rapporten",
|
"reports": "Rapporten",
|
||||||
"clinics": "Klinieken",
|
"clinics": "Klinieken",
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
"featurePatients": "Patiënten",
|
"featurePatients": "Patiënten",
|
||||||
"featureAppointment": "Afspraak",
|
"featureAppointment": "Afspraak",
|
||||||
"featureTreatment": "Behandeling",
|
"featureTreatment": "Behandeling",
|
||||||
|
"featureCases": "Dossiers",
|
||||||
"featureBilling": "Facturatie",
|
"featureBilling": "Facturatie",
|
||||||
"featureReports": "Rapporten",
|
"featureReports": "Rapporten",
|
||||||
"noTabAccess": "Geen tabbladtoegang",
|
"noTabAccess": "Geen tabbladtoegang",
|
||||||
@@ -313,6 +315,10 @@
|
|||||||
"statusInactive": "Inactief",
|
"statusInactive": "Inactief",
|
||||||
"emptyValue": "-"
|
"emptyValue": "-"
|
||||||
},
|
},
|
||||||
|
"cases": {
|
||||||
|
"title": "Dossiers",
|
||||||
|
"stubDescription": "Ontvangen labdossiers van gekoppelde klinieken verschijnen hier. Volledige inbox en takenworkflow volgen in een latere fase."
|
||||||
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "Afspraken",
|
"title": "Afspraken",
|
||||||
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
"subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.",
|
||||||
|
|||||||
14
frontend/src/app/[locale]/(dashboard)/cases/page.tsx
Normal file
14
frontend/src/app/[locale]/(dashboard)/cases/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export default function CasesPage() {
|
||||||
|
const t = useTranslations('cases');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||||
|
<p className="text-sm text-text-muted max-w-xl">{t('stubDescription')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,10 +8,8 @@ import Sidebar from '@/components/ui/shared/Sidebar';
|
|||||||
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||||||
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
|
||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
canAccessDashboardRoute,
|
||||||
firstAccessibleDashboardPath,
|
firstAccessibleDashboardPath,
|
||||||
getRequiredReadPermissionForPath,
|
|
||||||
hasPermission,
|
|
||||||
} from '@/components/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -33,16 +31,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = getRequiredReadPermissionForPath(pathname);
|
if (!canAccessDashboardRoute(currentOrganization, pathname)) {
|
||||||
if (required) {
|
|
||||||
const allowed =
|
|
||||||
hasPermission(currentOrganization, required) ||
|
|
||||||
(required === 'TAB_APPOINTMENTS_READ' &&
|
|
||||||
canAccessAppointmentsSection(currentOrganization));
|
|
||||||
if (!allowed) {
|
|
||||||
router.replace(firstAccessibleDashboardPath(currentOrganization));
|
router.replace(firstAccessibleDashboardPath(currentOrganization));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [isAuthReady, user, currentOrganization, router, pathname]);
|
}, [isAuthReady, user, currentOrganization, router, pathname]);
|
||||||
|
|
||||||
if (!isAuthReady) {
|
if (!isAuthReady) {
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import {
|
|||||||
canViewStaff,
|
canViewStaff,
|
||||||
} from '@/components/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
import {
|
import {
|
||||||
STAFF_FEATURE_GROUPS,
|
|
||||||
permissionNamesFromFeatureState,
|
permissionNamesFromFeatureState,
|
||||||
emptyFeaturePermissionState,
|
emptyFeaturePermissionState,
|
||||||
featureStateFromPermissionNames,
|
featureStateFromPermissionNames,
|
||||||
featureStateHasTreatmentEdit,
|
featureStateHasTreatmentEdit,
|
||||||
resolveStaffFeatureLabel,
|
resolveStaffFeatureLabel,
|
||||||
formatAccessSummary,
|
formatAccessSummary,
|
||||||
|
staffFeatureGroupsForOrgType,
|
||||||
type FeaturePermState,
|
type FeaturePermState,
|
||||||
} from '@/components/staff/staff-permission-form';
|
} from '@/components/staff/staff-permission-form';
|
||||||
import {
|
import {
|
||||||
@@ -112,7 +112,7 @@ function PermissionGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{STAFF_FEATURE_GROUPS.map((g) => {
|
{staffFeatureGroupsForOrgType(organizationType).map((g) => {
|
||||||
const cell = state[g.edit] ?? { read: false, edit: false };
|
const cell = state[g.edit] ?? { read: false, edit: false };
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -199,10 +199,14 @@ export default function StaffPage() {
|
|||||||
|
|
||||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||||
const inviteHasTreatmentEdit = useMemo(
|
const inviteHasTreatmentEdit = useMemo(
|
||||||
() => featureStateHasTreatmentEdit(invitePerms),
|
() =>
|
||||||
[invitePerms],
|
currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(invitePerms),
|
||||||
|
[currentOrganization?.type, invitePerms],
|
||||||
|
);
|
||||||
|
const editHasTreatmentEdit = useMemo(
|
||||||
|
() => currentOrganization?.type === 'CLINIC' && featureStateHasTreatmentEdit(editPerms),
|
||||||
|
[currentOrganization?.type, editPerms],
|
||||||
);
|
);
|
||||||
const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]);
|
|
||||||
const hasActivePlan = Boolean(currentOrganization?.plan);
|
const hasActivePlan = Boolean(currentOrganization?.plan);
|
||||||
const atSeatLimit = useMemo(() => {
|
const atSeatLimit = useMemo(() => {
|
||||||
if (!seats || seats.unlimited) return false;
|
if (!seats || seats.unlimited) return false;
|
||||||
@@ -309,7 +313,7 @@ export default function StaffPage() {
|
|||||||
setInviteStep(1);
|
setInviteStep(1);
|
||||||
setInviteEmail('');
|
setInviteEmail('');
|
||||||
setInviteName('');
|
setInviteName('');
|
||||||
setInvitePerms(emptyFeaturePermissionState());
|
setInvitePerms(emptyFeaturePermissionState(currentOrganization?.type));
|
||||||
const defaults = createDefaultWorkingHoursState();
|
const defaults = createDefaultWorkingHoursState();
|
||||||
setInviteWorkingHoursDays(defaults.days);
|
setInviteWorkingHoursDays(defaults.days);
|
||||||
setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly);
|
setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly);
|
||||||
@@ -393,7 +397,7 @@ export default function StaffPage() {
|
|||||||
setEditing(m);
|
setEditing(m);
|
||||||
setEditStep(1);
|
setEditStep(1);
|
||||||
setEditName(m.name);
|
setEditName(m.name);
|
||||||
setEditPerms(featureStateFromPermissionNames(m.permissions ?? []));
|
setEditPerms(featureStateFromPermissionNames(m.permissions ?? [], currentOrganization?.type));
|
||||||
setEditHoursValidationError(null);
|
setEditHoursValidationError(null);
|
||||||
const defaults = createDefaultWorkingHoursState();
|
const defaults = createDefaultWorkingHoursState();
|
||||||
setEditWorkingHoursDays(defaults.days);
|
setEditWorkingHoursDays(defaults.days);
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
|
|
||||||
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
|
export type OrgTypeName = 'CLINIC' | 'LAB';
|
||||||
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
|
|
||||||
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
|
export type DashboardRouteConfig = {
|
||||||
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
|
prefix: string;
|
||||||
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
|
permission: string;
|
||||||
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
|
orgTypes: OrgTypeName[];
|
||||||
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
|
};
|
||||||
{ prefix: '/billing', permission: 'TAB_BILLING_READ' },
|
|
||||||
{ prefix: '/reports', permission: 'TAB_REPORTS_READ' },
|
export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
|
||||||
|
{ prefix: '/today', permission: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
|
{ prefix: '/staff', permission: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
|
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
|
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
|
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
|
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
||||||
|
{ prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
||||||
|
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
|
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function isRouteAllowedForOrgType(pathname: string, orgType: OrgTypeName | undefined): boolean {
|
||||||
|
if (!orgType) return false;
|
||||||
|
const route = DASHBOARD_ROUTES.find(
|
||||||
|
(r) => pathname === r.prefix || pathname.startsWith(`${r.prefix}/`),
|
||||||
|
);
|
||||||
|
if (!route) return true;
|
||||||
|
return route.orgTypes.includes(orgType);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasPermission(org: Organization | null, permission: string): boolean {
|
export function hasPermission(org: Organization | null, permission: string): boolean {
|
||||||
if (!org) return false;
|
if (!org) return false;
|
||||||
return Boolean(org.permissions?.includes(permission));
|
return Boolean(org.permissions?.includes(permission));
|
||||||
@@ -26,21 +44,49 @@ export function canViewTab(org: Organization | null, readPermission: string): bo
|
|||||||
return hasPermission(org, readPermission);
|
return hasPermission(org, readPermission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequiredReadPermissionForPath(pathname: string): string | null {
|
export function getRouteConfigForPath(pathname: string): DashboardRouteConfig | null {
|
||||||
for (const { prefix, permission } of ROUTE_TAB_READ) {
|
for (const route of DASHBOARD_ROUTES) {
|
||||||
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
if (pathname === route.prefix || pathname.startsWith(`${route.prefix}/`)) {
|
||||||
return permission;
|
return route;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRequiredReadPermissionForPath(pathname: string): string | null {
|
||||||
|
return getRouteConfigForPath(pathname)?.permission ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessDashboardRoute(org: Organization | null, pathname: string): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
|
||||||
|
const route = getRouteConfigForPath(pathname);
|
||||||
|
if (!route) return true;
|
||||||
|
|
||||||
|
if (!isRouteAllowedForOrgType(pathname, org.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.prefix === '/appointments') {
|
||||||
|
return canAccessAppointmentsSection(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPermission(org, route.permission);
|
||||||
|
}
|
||||||
|
|
||||||
/** First dashboard route the user may open (ordered). Fallback: account settings. */
|
/** First dashboard route the user may open (ordered). Fallback: account settings. */
|
||||||
export function firstAccessibleDashboardPath(org: Organization | null): string {
|
export function firstAccessibleDashboardPath(org: Organization | null): string {
|
||||||
if (!org) return '/today';
|
if (!org) return '/today';
|
||||||
for (const { prefix, permission } of ROUTE_TAB_READ) {
|
|
||||||
if (hasPermission(org, permission)) return prefix;
|
for (const route of DASHBOARD_ROUTES) {
|
||||||
|
if (!route.orgTypes.includes(org.type)) continue;
|
||||||
|
if (route.prefix === '/appointments') {
|
||||||
|
if (canAccessAppointmentsSection(org)) return route.prefix;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (hasPermission(org, route.permission)) return route.prefix;
|
||||||
|
}
|
||||||
|
|
||||||
return '/settings/account';
|
return '/settings/account';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +108,9 @@ export function canEditAppointments(org: Organization | null): boolean {
|
|||||||
if (!org) {
|
if (!org) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (org.type !== 'CLINIC') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (org.isOwner) {
|
if (org.isOwner) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -76,6 +125,9 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
|
|||||||
if (!org) {
|
if (!org) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (org.type !== 'CLINIC') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (org.isOwner) {
|
if (org.isOwner) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -90,6 +142,7 @@ export function canAccessAppointmentsSection(org: Organization | null): boolean
|
|||||||
/** Treatment composer, scheduling columns, and saving clinical workflows */
|
/** Treatment composer, scheduling columns, and saving clinical workflows */
|
||||||
export function canEditTreatment(org: Organization | null): boolean {
|
export function canEditTreatment(org: Organization | null): boolean {
|
||||||
if (!org) return false;
|
if (!org) return false;
|
||||||
|
if (org.type !== 'CLINIC') return false;
|
||||||
if (org.isOwner) return true;
|
if (org.isOwner) return true;
|
||||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||||
}
|
}
|
||||||
@@ -97,9 +150,28 @@ export function canEditTreatment(org: Organization | null): boolean {
|
|||||||
/** View treatment workspace (read-only or edit) */
|
/** View treatment workspace (read-only or edit) */
|
||||||
export function canViewTreatment(org: Organization | null): boolean {
|
export function canViewTreatment(org: Organization | null): boolean {
|
||||||
if (!org) return false;
|
if (!org) return false;
|
||||||
|
if (org.type !== 'CLINIC') return false;
|
||||||
if (org.isOwner) return true;
|
if (org.isOwner) return true;
|
||||||
return (
|
return (
|
||||||
hasPermission(org, 'TAB_TREATMENT_READ') ||
|
hasPermission(org, 'TAB_TREATMENT_READ') ||
|
||||||
hasPermission(org, 'TAB_TREATMENT_EDIT')
|
hasPermission(org, 'TAB_TREATMENT_EDIT')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lab cases inbox */
|
||||||
|
export function canViewCases(org: Organization | null): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
if (org.type !== 'LAB') return false;
|
||||||
|
if (org.isOwner) return true;
|
||||||
|
return (
|
||||||
|
hasPermission(org, 'TAB_CASES_READ') ||
|
||||||
|
hasPermission(org, 'TAB_CASES_EDIT')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEditCases(org: Organization | null): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
if (org.type !== 'LAB') return false;
|
||||||
|
if (org.isOwner) return true;
|
||||||
|
return hasPermission(org, 'TAB_CASES_EDIT');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,22 +3,32 @@
|
|||||||
* Add presentational pieces under ./components/ as the UI grows.
|
* Add presentational pieces under ./components/ as the UI grows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { OrgTypeName } from '@/components/shared/permissions';
|
||||||
|
|
||||||
export const STAFF_FEATURE_GROUPS = [
|
export const STAFF_FEATURE_GROUPS = [
|
||||||
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
|
{ labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
|
{ labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
|
{ labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
|
{ labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT', orgTypes: ['CLINIC'] as const },
|
||||||
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
|
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const },
|
||||||
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
|
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const },
|
||||||
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
|
{ labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const },
|
||||||
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
|
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
|
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
|
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
|
||||||
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
|
export type OrgType = OrgTypeName | null | undefined;
|
||||||
|
|
||||||
type StaffFeaturesTranslate = (key: string) => string;
|
type StaffFeaturesTranslate = (key: string) => string;
|
||||||
|
|
||||||
|
export function staffFeatureGroupsForOrgType(organizationType: OrgType) {
|
||||||
|
if (!organizationType) return [...STAFF_FEATURE_GROUPS];
|
||||||
|
return STAFF_FEATURE_GROUPS.filter((g) =>
|
||||||
|
(g.orgTypes as readonly OrgTypeName[]).includes(organizationType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStaffFeatureLabel(
|
export function resolveStaffFeatureLabel(
|
||||||
group: (typeof STAFF_FEATURE_GROUPS)[number],
|
group: (typeof STAFF_FEATURE_GROUPS)[number],
|
||||||
organizationType: OrgType,
|
organizationType: OrgType,
|
||||||
@@ -30,18 +40,21 @@ export function resolveStaffFeatureLabel(
|
|||||||
return t(group.labelKey);
|
return t(group.labelKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyFeaturePermissionState(): FeaturePermState {
|
export function emptyFeaturePermissionState(organizationType?: OrgType): FeaturePermState {
|
||||||
const s: FeaturePermState = {};
|
const s: FeaturePermState = {};
|
||||||
for (const g of STAFF_FEATURE_GROUPS) {
|
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
|
||||||
s[g.edit] = { read: false, edit: false };
|
s[g.edit] = { read: false, edit: false };
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
|
export function featureStateFromPermissionNames(
|
||||||
|
names: string[],
|
||||||
|
organizationType?: OrgType,
|
||||||
|
): FeaturePermState {
|
||||||
const set = new Set(names);
|
const set = new Set(names);
|
||||||
const s = emptyFeaturePermissionState();
|
const s = emptyFeaturePermissionState(organizationType);
|
||||||
for (const g of STAFF_FEATURE_GROUPS) {
|
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
|
||||||
const hasEdit = set.has(g.edit);
|
const hasEdit = set.has(g.edit);
|
||||||
const hasRead = set.has(g.read) || hasEdit;
|
const hasRead = set.has(g.read) || hasEdit;
|
||||||
s[g.edit] = { read: hasRead, edit: hasEdit };
|
s[g.edit] = { read: hasRead, edit: hasEdit };
|
||||||
@@ -73,7 +86,7 @@ export function formatAccessSummary(
|
|||||||
if (!permissionNames?.length) return t('noTabAccess');
|
if (!permissionNames?.length) return t('noTabAccess');
|
||||||
const set = new Set(permissionNames);
|
const set = new Set(permissionNames);
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const g of STAFF_FEATURE_GROUPS) {
|
for (const g of staffFeatureGroupsForOrgType(organizationType)) {
|
||||||
const hasEdit = set.has(g.edit);
|
const hasEdit = set.has(g.edit);
|
||||||
const hasRead = set.has(g.read) || hasEdit;
|
const hasRead = set.has(g.read) || hasEdit;
|
||||||
if (!hasRead) continue;
|
if (!hasRead) continue;
|
||||||
|
|||||||
@@ -11,51 +11,73 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
FileText,
|
FileText,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Package,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import type { OrgTypeName } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
||||||
import { canAccessAppointmentsSection, canViewTab } from '@/components/shared/permissions';
|
import {
|
||||||
|
canAccessAppointmentsSection,
|
||||||
|
canViewCases,
|
||||||
|
canViewTab,
|
||||||
|
} from '@/components/shared/permissions';
|
||||||
import {
|
import {
|
||||||
counterpartOrganizationType,
|
counterpartOrganizationType,
|
||||||
organizationTypeIcon,
|
organizationTypeIcon,
|
||||||
} from '@/components/shared/organizationTypeIcon';
|
} from '@/components/shared/organizationTypeIcon';
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
read: string;
|
||||||
|
orgTypes: OrgTypeName[];
|
||||||
|
};
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const pendingConnectionsCount = usePendingConnectionsCount();
|
const pendingConnectionsCount = usePendingConnectionsCount();
|
||||||
|
const orgType = currentOrganization?.type;
|
||||||
|
|
||||||
|
const menu = useMemo((): MenuItem[] => {
|
||||||
const menu = useMemo(
|
const items: MenuItem[] = [
|
||||||
() => [
|
{ name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
{ name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
|
{ name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
{ name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
|
|
||||||
{
|
{
|
||||||
name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'),
|
name: orgType === 'LAB' ? t('clinics') : t('labs'),
|
||||||
path: '/organizations',
|
path: '/organizations',
|
||||||
icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)),
|
icon: organizationTypeIcon(counterpartOrganizationType(orgType)),
|
||||||
read: 'TAB_ORGANIZATIONS_READ' as const,
|
read: 'TAB_ORGANIZATIONS_READ',
|
||||||
|
orgTypes: ['CLINIC', 'LAB'],
|
||||||
},
|
},
|
||||||
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
|
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
|
{ name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
||||||
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
|
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
|
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
|
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
],
|
{ name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
[currentOrganization?.type, t],
|
];
|
||||||
);
|
return items;
|
||||||
|
}, [orgType, t]);
|
||||||
|
|
||||||
const visibleMenu = useMemo(
|
const visibleMenu = useMemo(
|
||||||
() =>
|
() =>
|
||||||
menu.filter((item) => {
|
menu.filter((item) => {
|
||||||
|
if (!orgType || !item.orgTypes.includes(orgType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (item.path === '/appointments') {
|
if (item.path === '/appointments') {
|
||||||
return canAccessAppointmentsSection(currentOrganization);
|
return canAccessAppointmentsSection(currentOrganization);
|
||||||
}
|
}
|
||||||
|
if (item.path === '/cases') {
|
||||||
|
return canViewCases(currentOrganization);
|
||||||
|
}
|
||||||
return canViewTab(currentOrganization, item.read);
|
return canViewTab(currentOrganization, item.read);
|
||||||
}),
|
}),
|
||||||
[currentOrganization, menu],
|
[currentOrganization, menu, orgType],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user