feature: Tasks tab added for lab organizations. tasks now can be assigned and their status can be updated by the assignee.
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
-- Add task priority, timestamps, and Tasks tab permissions
|
||||||
|
|
||||||
|
ALTER TABLE "lab_case_tasks" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 3;
|
||||||
|
ALTER TABLE "lab_case_tasks" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
ALTER TABLE "lab_case_tasks" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX "lab_case_tasks_assigneeUserId_priority_createdAt_idx"
|
||||||
|
ON "lab_case_tasks"("assigneeUserId", "priority", "createdAt");
|
||||||
|
|
||||||
|
INSERT INTO "features" ("id", "name", "description", "organizationTypeId")
|
||||||
|
VALUES (gen_random_uuid(), 'Tasks', 'Lab task 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_TASKS_READ'),
|
||||||
|
('TAB_TASKS_EDIT')
|
||||||
|
) AS v(name)
|
||||||
|
CROSS JOIN "features" f
|
||||||
|
WHERE f.name = 'Tasks'
|
||||||
|
ON CONFLICT ("name") DO NOTHING;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Track when a task was assigned (for sorting and display)
|
||||||
|
|
||||||
|
ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
CREATE INDEX "lab_case_tasks_assignedAt_labCaseId_priority_idx"
|
||||||
|
ON "lab_case_tasks"("assignedAt" DESC, "labCaseId" ASC, "priority" DESC);
|
||||||
@@ -253,14 +253,21 @@ model LabCaseTask {
|
|||||||
stepOrder Int
|
stepOrder Int
|
||||||
stepLabel String
|
stepLabel String
|
||||||
assigneeUserId String?
|
assigneeUserId String?
|
||||||
|
assignedAt DateTime?
|
||||||
|
priority Int @default(3)
|
||||||
status LabTaskStatus @default(PENDING)
|
status LabTaskStatus @default(PENDING)
|
||||||
|
|
||||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||||
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
||||||
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
|
assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([labCaseId, tooth, treatmentType, stepOrder])
|
@@unique([labCaseId, tooth, treatmentType, stepOrder])
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
|
@@index([assigneeUserId, priority, createdAt])
|
||||||
|
@@index([assignedAt, labCaseId, priority])
|
||||||
@@map("lab_case_tasks")
|
@@map("lab_case_tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ async function main() {
|
|||||||
name: 'Cases',
|
name: 'Cases',
|
||||||
permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'],
|
permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Tasks',
|
||||||
|
permissions: ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Billing',
|
name: 'Billing',
|
||||||
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
|
permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { OrganizationModule } from './modules/organization/organization.module';
|
|||||||
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
import { AppointmentsModule } from './modules/appointments/appointments.module';
|
||||||
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
import { TreatmentsModule } from './modules/treatments/treatments.module';
|
||||||
import { CasesModule } from './modules/cases/cases.module';
|
import { CasesModule } from './modules/cases/cases.module';
|
||||||
|
import { TasksModule } from './modules/tasks/tasks.module';
|
||||||
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -27,6 +28,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca
|
|||||||
AppointmentsModule,
|
AppointmentsModule,
|
||||||
TreatmentsModule,
|
TreatmentsModule,
|
||||||
CasesModule,
|
CasesModule,
|
||||||
|
TasksModule,
|
||||||
StaffModule,
|
StaffModule,
|
||||||
OrganizationModule,
|
OrganizationModule,
|
||||||
AdminModule.forRoot(),
|
AdminModule.forRoot(),
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ const CLINIC_ONLY_PERMISSIONS = new Set<string>([
|
|||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const LAB_ONLY_PERMISSIONS = new Set<string>(['TAB_CASES_READ', 'TAB_CASES_EDIT']);
|
const LAB_ONLY_PERMISSIONS = new Set<string>([
|
||||||
|
'TAB_CASES_READ',
|
||||||
|
'TAB_CASES_EDIT',
|
||||||
|
'TAB_TASKS_READ',
|
||||||
|
'TAB_TASKS_EDIT',
|
||||||
|
]);
|
||||||
|
|
||||||
const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter(
|
const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter(
|
||||||
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),
|
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const ALL_TAB_PERMISSIONS = [
|
|||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
'TAB_CASES_READ',
|
'TAB_CASES_READ',
|
||||||
'TAB_CASES_EDIT',
|
'TAB_CASES_EDIT',
|
||||||
|
'TAB_TASKS_READ',
|
||||||
|
'TAB_TASKS_EDIT',
|
||||||
'TAB_BILLING_READ',
|
'TAB_BILLING_READ',
|
||||||
'TAB_BILLING_EDIT',
|
'TAB_BILLING_EDIT',
|
||||||
'TAB_REPORTS_READ',
|
'TAB_REPORTS_READ',
|
||||||
@@ -43,6 +45,7 @@ const EDIT_TO_READ: Record<string, string> = {
|
|||||||
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_CASES_EDIT: 'TAB_CASES_READ',
|
||||||
|
TAB_TASKS_EDIT: 'TAB_TASKS_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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const ALL_PERMISSIONS = [
|
|||||||
'TAB_TREATMENT_EDIT',
|
'TAB_TREATMENT_EDIT',
|
||||||
'TAB_CASES_READ',
|
'TAB_CASES_READ',
|
||||||
'TAB_CASES_EDIT',
|
'TAB_CASES_EDIT',
|
||||||
|
'TAB_TASKS_READ',
|
||||||
|
'TAB_TASKS_EDIT',
|
||||||
'TAB_BILLING_READ',
|
'TAB_BILLING_READ',
|
||||||
'TAB_BILLING_EDIT',
|
'TAB_BILLING_EDIT',
|
||||||
'TAB_REPORTS_READ',
|
'TAB_REPORTS_READ',
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class CasesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/tasks/:taskId')
|
@Patch(':id/tasks/:taskId')
|
||||||
@ApiOperation({ summary: 'Update task assignee or status' })
|
@ApiOperation({ summary: 'Update task assignee or priority' })
|
||||||
updateTask(
|
updateTask(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('taskId') taskId: string,
|
@Param('taskId') taskId: string,
|
||||||
|
|||||||
@@ -200,14 +200,19 @@ export class CasesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
|
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
|
||||||
await this.ensureLabMember(dto.assigneeUserId, labOrganizationId);
|
await this.ensureAssignableMember(dto.assigneeUserId, labOrganizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.labCaseTask.update({
|
const updated = await this.prisma.labCaseTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: {
|
data: {
|
||||||
...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}),
|
...(dto.assigneeUserId !== undefined
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
? {
|
||||||
|
assigneeUserId: dto.assigneeUserId,
|
||||||
|
assignedAt: dto.assigneeUserId === null ? null : new Date(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(dto.priority !== undefined ? { priority: dto.priority } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
assignee: { select: { id: true, name: true, email: true } },
|
assignee: { select: { id: true, name: true, email: true } },
|
||||||
@@ -222,18 +227,27 @@ export class CasesService {
|
|||||||
|
|
||||||
const memberships = await this.prisma.membership.findMany({
|
const memberships = await this.prisma.membership.findMany({
|
||||||
where: { organizationId: labOrganizationId, isActive: true },
|
where: { organizationId: labOrganizationId, isActive: true },
|
||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
},
|
||||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: memberships.map((m) => ({
|
data: memberships
|
||||||
userId: m.user.id,
|
.filter((m) => {
|
||||||
name: m.user.name,
|
if (m.isOwner) return true;
|
||||||
email: m.user.email,
|
const names = m.permissions.map((p) => p.permission.name);
|
||||||
isOwner: m.isOwner,
|
return names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT');
|
||||||
})),
|
})
|
||||||
|
.map((m) => ({
|
||||||
|
userId: m.user.id,
|
||||||
|
name: m.user.name,
|
||||||
|
email: m.user.email,
|
||||||
|
isOwner: m.isOwner,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +391,10 @@ export class CasesService {
|
|||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
|
priority: number;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
|
assignedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
assignee: { id: string; name: string; email: string } | null;
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -411,7 +428,10 @@ export class CasesService {
|
|||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
|
priority: number;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
|
assignedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
assignee: { id: string; name: string; email: string } | null;
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -421,6 +441,9 @@ export class CasesService {
|
|||||||
stepOrder: task.stepOrder,
|
stepOrder: task.stepOrder,
|
||||||
stepLabel: task.stepLabel,
|
stepLabel: task.stepLabel,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||||
|
createdAt: task.createdAt.toISOString(),
|
||||||
assigneeUserId: task.assigneeUserId,
|
assigneeUserId: task.assigneeUserId,
|
||||||
assignee: task.assignee
|
assignee: task.assignee
|
||||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||||
@@ -428,14 +451,20 @@ export class CasesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLabMember(userId: string, labOrganizationId: string) {
|
private async ensureAssignableMember(userId: string, labOrganizationId: string) {
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId, organizationId: labOrganizationId, isActive: true },
|
where: { userId, organizationId: labOrganizationId, isActive: true },
|
||||||
select: { id: true },
|
include: { permissions: { include: { permission: true } } },
|
||||||
});
|
});
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new BadRequestException('Assignee must be an active member of this lab');
|
throw new BadRequestException('Assignee must be an active member of this lab');
|
||||||
}
|
}
|
||||||
|
if (membership.isOwner) return;
|
||||||
|
const names = membership.permissions.map((p) => p.permission.name);
|
||||||
|
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Assignee must have access to the Tasks tab');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
|
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator';
|
||||||
import { LabTaskStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
export class UpdateLabCaseTaskDto {
|
export class UpdateLabCaseTaskDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -9,8 +8,11 @@ export class UpdateLabCaseTaskDto {
|
|||||||
assigneeUserId?: string | null;
|
assigneeUserId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(LabTaskStatus)
|
@Transform(({ value }) => Number(value))
|
||||||
status?: LabTaskStatus;
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListLabCasesDto {
|
export class ListLabCasesDto {
|
||||||
|
|||||||
23
backend/src/modules/tasks/dto/tasks.dto.ts
Normal file
23
backend/src/modules/tasks/dto/tasks.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { LabTaskStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpdateLabTaskDto {
|
||||||
|
@IsEnum(LabTaskStatus)
|
||||||
|
status: LabTaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListLabTasksDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit = 50;
|
||||||
|
}
|
||||||
32
backend/src/modules/tasks/tasks.controller.ts
Normal file
32
backend/src/modules/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
|
@ApiTags('tasks')
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@UseGuards(JwtAuthGuard, LabOrgGuard)
|
||||||
|
@Controller('tasks')
|
||||||
|
export class TasksController {
|
||||||
|
constructor(private readonly tasksService: TasksService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
||||||
|
list(@Query() query: ListLabTasksDto, @Req() req) {
|
||||||
|
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.tasksService.list(organizationId, req.user.id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':taskId')
|
||||||
|
@ApiOperation({ summary: 'Update task status' })
|
||||||
|
updateStatus(
|
||||||
|
@Param('taskId') taskId: string,
|
||||||
|
@Body() dto: UpdateLabTaskDto,
|
||||||
|
@Req() req,
|
||||||
|
) {
|
||||||
|
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/modules/tasks/tasks.module.ts
Normal file
9
backend/src/modules/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TasksController } from './tasks.controller';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TasksController],
|
||||||
|
providers: [TasksService],
|
||||||
|
})
|
||||||
|
export class TasksModule {}
|
||||||
188
backend/src/modules/tasks/tasks.service.ts
Normal file
188
backend/src/modules/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { LabTaskStatus, Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||||
|
|
||||||
|
const taskListInclude = {
|
||||||
|
assignee: { select: { id: true, name: true, email: true } },
|
||||||
|
labCase: {
|
||||||
|
include: {
|
||||||
|
treatment: {
|
||||||
|
include: {
|
||||||
|
organization: { select: { id: true, name: true } },
|
||||||
|
patient: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.LabCaseTaskInclude;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TasksService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||||
|
if (!user?.organizationId) {
|
||||||
|
throw new BadRequestException('Organization is not selected');
|
||||||
|
}
|
||||||
|
return user.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
|
||||||
|
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
|
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenException('You are not a member of this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: Prisma.LabCaseTaskWhereInput = {
|
||||||
|
labCase: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.labCaseTask.findMany({
|
||||||
|
where,
|
||||||
|
include: taskListInclude,
|
||||||
|
orderBy: [
|
||||||
|
{ assignedAt: { sort: 'desc', nulls: 'first' } },
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
{ labCaseId: 'asc' },
|
||||||
|
{ priority: 'desc' },
|
||||||
|
{ stepOrder: 'asc' },
|
||||||
|
{ id: 'asc' },
|
||||||
|
],
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.labCaseTask.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: items.map((task) => this.mapTaskListItem(task)),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
taskId: string,
|
||||||
|
dto: UpdateLabTaskDto,
|
||||||
|
labOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
||||||
|
|
||||||
|
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenException('You are not a member of this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await this.prisma.labCaseTask.findFirst({
|
||||||
|
where: {
|
||||||
|
id: taskId,
|
||||||
|
labCase: {
|
||||||
|
sentAt: { not: null },
|
||||||
|
sends: { some: { organizationId: labOrganizationId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: taskListInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException('Task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
|
||||||
|
throw new ForbiddenException('You can only update tasks assigned to you');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.labCaseTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { status: dto.status },
|
||||||
|
include: taskListInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: this.mapTaskListItem(updated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapTaskListItem(
|
||||||
|
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
labCaseId: task.labCaseId,
|
||||||
|
tooth: task.tooth,
|
||||||
|
treatmentType: task.treatmentType,
|
||||||
|
stepOrder: task.stepOrder,
|
||||||
|
stepLabel: task.stepLabel,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||||
|
createdAt: task.createdAt.toISOString(),
|
||||||
|
assigneeUserId: task.assigneeUserId,
|
||||||
|
assignee: task.assignee
|
||||||
|
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||||
|
: null,
|
||||||
|
clinic: task.labCase.treatment.organization,
|
||||||
|
patient: {
|
||||||
|
id: task.labCase.treatment.patient.id,
|
||||||
|
firstName: task.labCase.treatment.patient.firstName,
|
||||||
|
lastName: task.labCase.treatment.patient.lastName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanReadTasks(userId: string, organizationId: string) {
|
||||||
|
const m = await this.getMembership(userId, organizationId);
|
||||||
|
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')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('You do not have access to tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanEditTasks(userId: string, organizationId: string) {
|
||||||
|
const m = await this.getMembership(userId, organizationId);
|
||||||
|
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')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('You cannot update tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMembership(userId: string, organizationId: string) {
|
||||||
|
return this.prisma.membership.findFirst({
|
||||||
|
where: { userId, organizationId, isActive: true },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"appointment": "Appointment",
|
"appointment": "Appointment",
|
||||||
"treatment": "Treatment",
|
"treatment": "Treatment",
|
||||||
"cases": "Cases",
|
"cases": "Cases",
|
||||||
|
"tasks": "Tasks",
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"clinics": "Clinics",
|
"clinics": "Clinics",
|
||||||
@@ -263,6 +264,7 @@
|
|||||||
"featureAppointment": "Appointment",
|
"featureAppointment": "Appointment",
|
||||||
"featureTreatment": "Treatment",
|
"featureTreatment": "Treatment",
|
||||||
"featureCases": "Cases",
|
"featureCases": "Cases",
|
||||||
|
"featureTasks": "Tasks",
|
||||||
"featureBilling": "Billing",
|
"featureBilling": "Billing",
|
||||||
"featureReports": "Reports",
|
"featureReports": "Reports",
|
||||||
"noTabAccess": "No tab access",
|
"noTabAccess": "No tab access",
|
||||||
@@ -348,7 +350,32 @@
|
|||||||
"labComment": "Lab comment",
|
"labComment": "Lab comment",
|
||||||
"prevPage": "Previous",
|
"prevPage": "Previous",
|
||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)"
|
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||||
|
"priorityLabel": "Priority",
|
||||||
|
"statusLabel": "Status"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "Tasks",
|
||||||
|
"subtitle": "Your assigned lab tasks. Update status as you work through each step.",
|
||||||
|
"subtitleOwner": "All lab tasks in the organization. Assign tasks from Cases; update status on your own assignments here.",
|
||||||
|
"loading": "Loading tasks…",
|
||||||
|
"emptyList": "No tasks assigned to you yet.",
|
||||||
|
"emptyListOwner": "No tasks in the lab inbox yet.",
|
||||||
|
"noPermissionTitle": "Tasks",
|
||||||
|
"noPermissionBody": "You do not have permission to view tasks for this organization.",
|
||||||
|
"fromClinic": "From {name}",
|
||||||
|
"patientLabel": "Patient",
|
||||||
|
"taskDate": "{date}",
|
||||||
|
"priorityLabel": "Priority {n}",
|
||||||
|
"toothLabel": "Tooth {tooth}",
|
||||||
|
"unassigned": "Unassigned",
|
||||||
|
"assignedTo": "Assigned to {name}",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusInProgress": "In progress",
|
||||||
|
"statusCompleted": "Completed",
|
||||||
|
"errorLoadList": "Failed to load tasks.",
|
||||||
|
"errorUpdateTask": "Failed to update task.",
|
||||||
|
"pageSummary": "Page {page} of {totalPages} ({total} tasks)"
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "Appointments",
|
"title": "Appointments",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"appointment": "نوبتها",
|
"appointment": "نوبتها",
|
||||||
"treatment": "درمان",
|
"treatment": "درمان",
|
||||||
"cases": "پروندهها",
|
"cases": "پروندهها",
|
||||||
|
"tasks": "وظایف",
|
||||||
"billing": "صورتحساب",
|
"billing": "صورتحساب",
|
||||||
"reports": "گزارشها",
|
"reports": "گزارشها",
|
||||||
"clinics": "کلینیکها",
|
"clinics": "کلینیکها",
|
||||||
@@ -263,6 +264,7 @@
|
|||||||
"featureAppointment": "نوبتها",
|
"featureAppointment": "نوبتها",
|
||||||
"featureTreatment": "درمان",
|
"featureTreatment": "درمان",
|
||||||
"featureCases": "پروندهها",
|
"featureCases": "پروندهها",
|
||||||
|
"featureTasks": "وظایف",
|
||||||
"featureBilling": "صورتحساب",
|
"featureBilling": "صورتحساب",
|
||||||
"featureReports": "گزارشها",
|
"featureReports": "گزارشها",
|
||||||
"noTabAccess": "دسترسی به برگهها وجود ندارد",
|
"noTabAccess": "دسترسی به برگهها وجود ندارد",
|
||||||
@@ -348,7 +350,32 @@
|
|||||||
"labComment": "یادداشت آزمایشگاه",
|
"labComment": "یادداشت آزمایشگاه",
|
||||||
"prevPage": "قبلی",
|
"prevPage": "قبلی",
|
||||||
"nextPage": "بعدی",
|
"nextPage": "بعدی",
|
||||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)"
|
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||||
|
"priorityLabel": "اولویت",
|
||||||
|
"statusLabel": "وضعیت"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "وظایف",
|
||||||
|
"subtitle": "وظایف لاب اختصاصیافته به شما. وضعیت را در حین انجام هر مرحله بهروز کنید.",
|
||||||
|
"subtitleOwner": "همه وظایف لاب در سازمان. تخصیص از بخش پروندهها؛ بهروزرسانی وضعیت برای وظایف خودتان اینجا.",
|
||||||
|
"loading": "در حال بارگذاری وظایف…",
|
||||||
|
"emptyList": "هنوز وظیفهای به شما اختصاص داده نشده است.",
|
||||||
|
"emptyListOwner": "هنوز وظیفهای در صندوق ورودی لاب وجود ندارد.",
|
||||||
|
"noPermissionTitle": "وظایف",
|
||||||
|
"noPermissionBody": "شما مجوز مشاهده وظایف برای این سازمان را ندارید.",
|
||||||
|
"fromClinic": "از {name}",
|
||||||
|
"patientLabel": "بیمار",
|
||||||
|
"taskDate": "{date}",
|
||||||
|
"priorityLabel": "اولویت {n}",
|
||||||
|
"toothLabel": "دندان {tooth}",
|
||||||
|
"unassigned": "اختصاص داده نشده",
|
||||||
|
"assignedTo": "اختصاص به {name}",
|
||||||
|
"statusPending": "در انتظار",
|
||||||
|
"statusInProgress": "در حال انجام",
|
||||||
|
"statusCompleted": "تکمیلشده",
|
||||||
|
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
||||||
|
"errorUpdateTask": "بهروزرسانی وظیفه ناموفق بود.",
|
||||||
|
"pageSummary": "صفحه {page} از {totalPages} ({total} وظیفه)"
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "نوبتها",
|
"title": "نوبتها",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"appointment": "Afspraak",
|
"appointment": "Afspraak",
|
||||||
"treatment": "Behandeling",
|
"treatment": "Behandeling",
|
||||||
"cases": "Dossiers",
|
"cases": "Dossiers",
|
||||||
|
"tasks": "Taken",
|
||||||
"billing": "Facturatie",
|
"billing": "Facturatie",
|
||||||
"reports": "Rapporten",
|
"reports": "Rapporten",
|
||||||
"clinics": "Klinieken",
|
"clinics": "Klinieken",
|
||||||
@@ -263,6 +264,7 @@
|
|||||||
"featureAppointment": "Afspraak",
|
"featureAppointment": "Afspraak",
|
||||||
"featureTreatment": "Behandeling",
|
"featureTreatment": "Behandeling",
|
||||||
"featureCases": "Dossiers",
|
"featureCases": "Dossiers",
|
||||||
|
"featureTasks": "Taken",
|
||||||
"featureBilling": "Facturatie",
|
"featureBilling": "Facturatie",
|
||||||
"featureReports": "Rapporten",
|
"featureReports": "Rapporten",
|
||||||
"noTabAccess": "Geen tabbladtoegang",
|
"noTabAccess": "Geen tabbladtoegang",
|
||||||
@@ -348,7 +350,32 @@
|
|||||||
"labComment": "Labnotitie",
|
"labComment": "Labnotitie",
|
||||||
"prevPage": "Vorige",
|
"prevPage": "Vorige",
|
||||||
"nextPage": "Volgende",
|
"nextPage": "Volgende",
|
||||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)"
|
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||||
|
"priorityLabel": "Prioriteit",
|
||||||
|
"statusLabel": "Status"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"title": "Taken",
|
||||||
|
"subtitle": "Uw toegewezen labtaken. Werk de status bij terwijl u elke stap uitvoert.",
|
||||||
|
"subtitleOwner": "Alle labtaken in de organisatie. Wijs toe via Dossiers; werk hier de status bij voor uw eigen taken.",
|
||||||
|
"loading": "Taken laden…",
|
||||||
|
"emptyList": "Nog geen taken aan u toegewezen.",
|
||||||
|
"emptyListOwner": "Nog geen taken in de lab-inbox.",
|
||||||
|
"noPermissionTitle": "Taken",
|
||||||
|
"noPermissionBody": "U heeft geen toestemming om taken voor deze organisatie te bekijken.",
|
||||||
|
"fromClinic": "Van {name}",
|
||||||
|
"patientLabel": "Patiënt",
|
||||||
|
"taskDate": "{date}",
|
||||||
|
"priorityLabel": "Prioriteit {n}",
|
||||||
|
"toothLabel": "Tand {tooth}",
|
||||||
|
"unassigned": "Niet toegewezen",
|
||||||
|
"assignedTo": "Toegewezen aan {name}",
|
||||||
|
"statusPending": "In afwachting",
|
||||||
|
"statusInProgress": "Bezig",
|
||||||
|
"statusCompleted": "Voltooid",
|
||||||
|
"errorLoadList": "Taken laden mislukt.",
|
||||||
|
"errorUpdateTask": "Taak bijwerken mislukt.",
|
||||||
|
"pageSummary": "Pagina {page} van {totalPages} ({total} taken)"
|
||||||
},
|
},
|
||||||
"appointments": {
|
"appointments": {
|
||||||
"title": "Afspraken",
|
"title": "Afspraken",
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { hasPermission } from '@/components/shared/permissions';
|
import { canEditCases } from '@/components/shared/permissions';
|
||||||
|
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import type {
|
import type {
|
||||||
AssignableMember,
|
AssignableMember,
|
||||||
@@ -28,6 +31,18 @@ const TREATMENT_TYPE_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
||||||
|
|
||||||
|
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'success';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'default';
|
||||||
|
default:
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
@@ -66,6 +81,7 @@ export default function CasesPage() {
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization, user } = useAuth();
|
const { currentOrganization, user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [clinicId, setClinicId] = useState('');
|
const [clinicId, setClinicId] = useState('');
|
||||||
@@ -93,7 +109,7 @@ export default function CasesPage() {
|
|||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT');
|
const canEdit = canEditCases(currentOrganization);
|
||||||
const locale = user?.language ?? 'en';
|
const locale = user?.language ?? 'en';
|
||||||
|
|
||||||
const treatmentLabel = useCallback(
|
const treatmentLabel = useCallback(
|
||||||
@@ -166,6 +182,13 @@ export default function CasesPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const caseIdFromUrl = searchParams.get('caseId');
|
||||||
|
if (caseIdFromUrl) {
|
||||||
|
setSelectedCaseId(caseIdFromUrl);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void loadCases({
|
void loadCases({
|
||||||
@@ -201,7 +224,7 @@ export default function CasesPage() {
|
|||||||
|
|
||||||
async function handleTaskUpdate(
|
async function handleTaskUpdate(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
|
payload: { assigneeUserId?: string | null; priority?: number },
|
||||||
) {
|
) {
|
||||||
if (!selectedCaseId || !canEdit) return;
|
if (!selectedCaseId || !canEdit) return;
|
||||||
|
|
||||||
@@ -225,8 +248,7 @@ export default function CasesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterSelectClass =
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
|
||||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -475,24 +497,29 @@ export default function CasesPage() {
|
|||||||
{group.tasks.map((task) => (
|
{group.tasks.map((task) => (
|
||||||
<li
|
<li
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="grid gap-2 sm:grid-cols-[1fr_160px_180px] items-center text-sm rounded bg-background p-2"
|
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
|
task.status}
|
||||||
|
</Badge>
|
||||||
<select
|
<select
|
||||||
value={task.status}
|
value={task.priority}
|
||||||
disabled={!canEdit || updatingTaskId === task.id}
|
disabled={!canEdit || updatingTaskId === task.id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
void handleTaskUpdate(task.id, {
|
void handleTaskUpdate(task.id, {
|
||||||
status: e.target.value as LabTaskStatus,
|
priority: Number(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
className={FORM_SELECT_CLASS}
|
||||||
|
aria-label={t('priorityLabel')}
|
||||||
>
|
>
|
||||||
{statusOptions.map((opt) => (
|
{PRIORITY_OPTIONS.map((value) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={value} value={value}>
|
||||||
{opt.label}
|
{value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -504,7 +531,7 @@ export default function CasesPage() {
|
|||||||
assigneeUserId: e.target.value || null,
|
assigneeUserId: e.target.value || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
className={FORM_SELECT_CLASS}
|
||||||
>
|
>
|
||||||
<option value="">{t('unassigned')}</option>
|
<option value="">{t('unassigned')}</option>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
|
|||||||
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
|
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
|
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||||
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
|
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
|
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'success';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'default';
|
||||||
|
default:
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const t = useTranslations('tasks');
|
||||||
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||||
|
const { showError, setError, messages: toastMessages } = useToast();
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
||||||
|
page: 1,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canView = canViewTasks(currentOrganization);
|
||||||
|
const canEdit = canEditTasks(currentOrganization);
|
||||||
|
const locale = user?.language ?? 'en';
|
||||||
|
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||||
|
|
||||||
|
const tRef = useRef(t);
|
||||||
|
tRef.current = t;
|
||||||
|
|
||||||
|
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'PENDING', label: t('statusPending') },
|
||||||
|
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||||
|
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canView) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||||
|
if (cancelled) return;
|
||||||
|
setTasks(response.data.items);
|
||||||
|
setPagination(response.data.pagination);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (cancelled) return;
|
||||||
|
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [canView, page, showError, setError]);
|
||||||
|
|
||||||
|
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
||||||
|
if (!canEdit) return;
|
||||||
|
|
||||||
|
setUpdatingTaskId(taskId);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await tasksApi.updateStatus(taskId, status);
|
||||||
|
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||||
|
setTasks(response.data.items);
|
||||||
|
setPagination(response.data.pagination);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||||
|
} finally {
|
||||||
|
setUpdatingTaskId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTaskDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDateForTask(task: LabTaskListItem) {
|
||||||
|
return task.assignedAt ?? task.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthReady) {
|
||||||
|
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div className="surface-card p-6 max-w-xl">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
||||||
|
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{isOwner ? t('subtitleOwner') : t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="surface-card min-h-[280px]">
|
||||||
|
{loading && tasks.length === 0 ? (
|
||||||
|
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="p-3 text-sm text-text-muted">
|
||||||
|
{isOwner ? t('emptyListOwner') : t('emptyList')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const statusEditable =
|
||||||
|
canEdit && (isOwner || task.assigneeUserId === user?.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">
|
||||||
|
{task.stepOrder}. {task.stepLabel}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
|
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||||
|
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||||
|
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<Badge
|
||||||
|
variant={task.assignee ? 'success' : 'danger'}
|
||||||
|
fixedWidth={false}
|
||||||
|
>
|
||||||
|
{task.assignee
|
||||||
|
? t('assignedTo', { name: task.assignee.name })
|
||||||
|
: t('unassigned')}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{statusEditable ? (
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
disabled={updatingTaskId === task.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
||||||
|
}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
|
||||||
|
>
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
|
task.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||||
|
<Badge variant="default" fixedWidth={false}>
|
||||||
|
{t('priorityLabel', { n: task.priority })}
|
||||||
|
</Badge>
|
||||||
|
<TreatmentTypeBadge type={task.treatmentType} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{t('pageSummary', {
|
||||||
|
page: pagination.page,
|
||||||
|
totalPages: pagination.totalPages,
|
||||||
|
total: pagination.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page >= pagination.totalPages || loading}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToastStack {...toastMessages} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
|
|||||||
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
||||||
{ prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
{ prefix: '/cases', permission: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
||||||
|
{ prefix: '/tasks', permission: 'TAB_TASKS_READ', orgTypes: ['LAB'] },
|
||||||
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
{ prefix: '/billing', permission: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
|
{ prefix: '/reports', permission: 'TAB_REPORTS_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
];
|
];
|
||||||
@@ -71,6 +72,14 @@ export function canAccessDashboardRoute(org: Organization | null, pathname: stri
|
|||||||
return canAccessAppointmentsSection(org);
|
return canAccessAppointmentsSection(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.prefix === '/cases') {
|
||||||
|
return canViewCases(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.prefix === '/tasks') {
|
||||||
|
return canViewTasks(org);
|
||||||
|
}
|
||||||
|
|
||||||
return hasPermission(org, route.permission);
|
return hasPermission(org, route.permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +93,14 @@ export function firstAccessibleDashboardPath(org: Organization | null): string {
|
|||||||
if (canAccessAppointmentsSection(org)) return route.prefix;
|
if (canAccessAppointmentsSection(org)) return route.prefix;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (route.prefix === '/cases') {
|
||||||
|
if (canViewCases(org)) return route.prefix;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (route.prefix === '/tasks') {
|
||||||
|
if (canViewTasks(org)) return route.prefix;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (hasPermission(org, route.permission)) return route.prefix;
|
if (hasPermission(org, route.permission)) return route.prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +192,21 @@ export function canEditCases(org: Organization | null): boolean {
|
|||||||
if (org.isOwner) return true;
|
if (org.isOwner) return true;
|
||||||
return hasPermission(org, 'TAB_CASES_EDIT');
|
return hasPermission(org, 'TAB_CASES_EDIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lab task inbox */
|
||||||
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const STAFF_FEATURE_GROUPS = [
|
|||||||
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const },
|
{ labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT', orgTypes: ['CLINIC'] as const },
|
||||||
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const },
|
{ labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT', orgTypes: ['CLINIC'] as const },
|
||||||
{ labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const },
|
{ labelKey: 'featureCases', read: 'TAB_CASES_READ', edit: 'TAB_CASES_EDIT', orgTypes: ['LAB'] as const },
|
||||||
|
{ labelKey: 'featureTasks', read: 'TAB_TASKS_READ', edit: 'TAB_TASKS_EDIT', orgTypes: ['LAB'] as const },
|
||||||
{ labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
{ 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 },
|
{ labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -28,18 +28,13 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
id={selectId}
|
id={selectId}
|
||||||
className={`
|
className={`
|
||||||
w-full appearance-none rounded-[var(--radius-md)] border
|
form-select w-full appearance-none rounded-[var(--radius-md)] border
|
||||||
${error ? 'border-red-500' : 'border-border'}
|
${error ? 'border-red-500' : 'border-border'}
|
||||||
bg-background-secondary/90 text-text-primary
|
bg-background-card text-text-primary
|
||||||
|
|
||||||
pl-4 pr-14 py-2 text-sm
|
pl-4 pr-14 py-2 text-sm
|
||||||
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||||
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
|
||||||
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
|
||||||
|
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Package,
|
Package,
|
||||||
|
ListTodo,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { OrgTypeName } from '@/components/shared/permissions';
|
import type { OrgTypeName } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
@@ -19,6 +20,7 @@ import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCou
|
|||||||
import {
|
import {
|
||||||
canAccessAppointmentsSection,
|
canAccessAppointmentsSection,
|
||||||
canViewCases,
|
canViewCases,
|
||||||
|
canViewTasks,
|
||||||
canViewTab,
|
canViewTab,
|
||||||
} from '@/components/shared/permissions';
|
} from '@/components/shared/permissions';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +57,7 @@ function Sidebar() {
|
|||||||
},
|
},
|
||||||
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
|
{ name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
{ name: t('cases'), path: '/cases', icon: Package, read: 'TAB_CASES_READ', orgTypes: ['LAB'] },
|
||||||
|
{ name: t('tasks'), path: '/tasks', icon: ListTodo, read: 'TAB_TASKS_READ', orgTypes: ['LAB'] },
|
||||||
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
{ name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
{ name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ', orgTypes: ['CLINIC'] },
|
||||||
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
{ name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ', orgTypes: ['CLINIC', 'LAB'] },
|
||||||
@@ -75,6 +78,9 @@ function Sidebar() {
|
|||||||
if (item.path === '/cases') {
|
if (item.path === '/cases') {
|
||||||
return canViewCases(currentOrganization);
|
return canViewCases(currentOrganization);
|
||||||
}
|
}
|
||||||
|
if (item.path === '/tasks') {
|
||||||
|
return canViewTasks(currentOrganization);
|
||||||
|
}
|
||||||
return canViewTab(currentOrganization, item.read);
|
return canViewTab(currentOrganization, item.read);
|
||||||
}),
|
}),
|
||||||
[currentOrganization, menu, orgType],
|
[currentOrganization, menu, orgType],
|
||||||
|
|||||||
3
frontend/src/components/ui/shared/formSelectStyles.ts
Normal file
3
frontend/src/components/ui/shared/formSelectStyles.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/** Shared native select styling — readable in light and dark themes */
|
||||||
|
export const FORM_SELECT_CLASS =
|
||||||
|
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||||
@@ -34,7 +34,7 @@ export const casesApi = {
|
|||||||
updateTask: async (
|
updateTask: async (
|
||||||
caseId: string,
|
caseId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] },
|
payload: { assigneeUserId?: string | null; priority?: number },
|
||||||
): Promise<{ success: boolean; data: LabCaseTask }> => {
|
): Promise<{ success: boolean; data: LabCaseTask }> => {
|
||||||
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
|
const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
20
frontend/src/lib/api/tasks.ts
Normal file
20
frontend/src/lib/api/tasks.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||||
|
|
||||||
|
export const tasksApi = {
|
||||||
|
list: async (params: { page?: number; limit?: number } = {}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedLabTasks;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get('/tasks', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: async (
|
||||||
|
taskId: string,
|
||||||
|
status: LabTaskStatus,
|
||||||
|
): Promise<{ success: boolean; data: LabTaskListItem }> => {
|
||||||
|
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -247,6 +247,24 @@ body {
|
|||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select.form-select,
|
||||||
|
select {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-background-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] select.form-select,
|
||||||
|
:root[data-theme='dark'] select,
|
||||||
|
:root:not([data-theme='light']) select.form-select,
|
||||||
|
:root:not([data-theme='light']) select {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
.surface-card {
|
.surface-card {
|
||||||
background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
|
background: color-mix(in srgb, var(--color-card-background) 92%, transparent);
|
||||||
border: 1px solid var(--color-card-border);
|
border: 1px solid var(--color-card-border);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface LabCaseTask {
|
|||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
stepLabel: string;
|
stepLabel: string;
|
||||||
status: LabTaskStatus;
|
status: LabTaskStatus;
|
||||||
|
priority: number;
|
||||||
|
assignedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
assignee: { id: string; name: string; email: string } | null;
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
}
|
}
|
||||||
@@ -91,3 +94,30 @@ export interface PaginatedLabCases {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LabTaskListItem {
|
||||||
|
id: string;
|
||||||
|
labCaseId: string;
|
||||||
|
tooth: string;
|
||||||
|
treatmentType: string;
|
||||||
|
stepOrder: number;
|
||||||
|
stepLabel: string;
|
||||||
|
status: LabTaskStatus;
|
||||||
|
priority: number;
|
||||||
|
assignedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
assigneeUserId: string | null;
|
||||||
|
assignee: { id: string; name: string; email: string } | null;
|
||||||
|
clinic: { id: string; name: string };
|
||||||
|
patient: { id: string; firstName: string; lastName: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedLabTasks {
|
||||||
|
items: LabTaskListItem[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user