From 2a48946a51b64a7bc3e3522d8ed71832eb047eff Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 28 Jun 2026 22:56:56 +0330 Subject: [PATCH] feature: Tasks tab added for lab organizations. tasks now can be assigned and their status can be updated by the assignee. --- .../migration.sql | 22 ++ .../migration.sql | 6 + backend/prisma/schema.prisma | 7 + backend/prisma/seed.ts | 4 + backend/src/app.module.ts | 2 + backend/src/common/organization-type.ts | 7 +- backend/src/common/permissions.ts | 3 + backend/src/modules/auth/auth.service.ts | 2 + backend/src/modules/cases/cases.controller.ts | 2 +- backend/src/modules/cases/cases.service.ts | 53 +++- backend/src/modules/cases/dto/cases.dto.ts | 12 +- backend/src/modules/tasks/dto/tasks.dto.ts | 23 ++ backend/src/modules/tasks/tasks.controller.ts | 32 +++ backend/src/modules/tasks/tasks.module.ts | 9 + backend/src/modules/tasks/tasks.service.ts | 188 +++++++++++++ frontend/messages/en.json | 29 +- frontend/messages/fa.json | 29 +- frontend/messages/nl.json | 29 +- .../app/[locale]/(dashboard)/cases/page.tsx | 53 +++- .../app/[locale]/(dashboard)/tasks/page.tsx | 258 ++++++++++++++++++ frontend/src/components/shared/permissions.ts | 35 +++ .../components/staff/staff-permission-form.ts | 1 + .../src/components/ui/shared/Dropdown.tsx | 9 +- frontend/src/components/ui/shared/Sidebar.tsx | 6 + .../components/ui/shared/formSelectStyles.ts | 3 + frontend/src/lib/api/cases.ts | 2 +- frontend/src/lib/api/tasks.ts | 20 ++ frontend/src/styles/globals.css | 18 ++ frontend/src/types/cases.ts | 30 ++ 29 files changed, 851 insertions(+), 43 deletions(-) create mode 100644 backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql create mode 100644 backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql create mode 100644 backend/src/modules/tasks/dto/tasks.dto.ts create mode 100644 backend/src/modules/tasks/tasks.controller.ts create mode 100644 backend/src/modules/tasks/tasks.module.ts create mode 100644 backend/src/modules/tasks/tasks.service.ts create mode 100644 frontend/src/app/[locale]/(dashboard)/tasks/page.tsx create mode 100644 frontend/src/components/ui/shared/formSelectStyles.ts create mode 100644 frontend/src/lib/api/tasks.ts diff --git a/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql new file mode 100644 index 0000000..1b2937d --- /dev/null +++ b/backend/prisma/migrations/20260628180000_lab_task_priority_and_tasks_permissions/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql new file mode 100644 index 0000000..e58a202 --- /dev/null +++ b/backend/prisma/migrations/20260628190000_lab_task_assigned_at/migration.sql @@ -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); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b81fde5..e453a29 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -253,14 +253,21 @@ model LabCaseTask { stepOrder Int stepLabel String assigneeUserId String? + assignedAt DateTime? + priority Int @default(3) status LabTaskStatus @default(PENDING) labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@unique([labCaseId, tooth, treatmentType, stepOrder]) @@index([labCaseId, status]) + @@index([assigneeUserId, priority, createdAt]) + @@index([assignedAt, labCaseId, priority]) @@map("lab_case_tasks") } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index d111482..ad968ed 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -94,6 +94,10 @@ async function main() { name: 'Cases', permissions: ['TAB_CASES_READ', 'TAB_CASES_EDIT'], }, + { + name: 'Tasks', + permissions: ['TAB_TASKS_READ', 'TAB_TASKS_EDIT'], + }, { name: 'Billing', permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'], diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3b7d8ec..e67e7f2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { OrganizationModule } from './modules/organization/organization.module'; import { AppointmentsModule } from './modules/appointments/appointments.module'; import { TreatmentsModule } from './modules/treatments/treatments.module'; import { CasesModule } from './modules/cases/cases.module'; +import { TasksModule } from './modules/tasks/tasks.module'; import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-catalog.module'; @Module({ @@ -27,6 +28,7 @@ import { TreatmentCatalogModule } from './modules/treatment-catalog/treatment-ca AppointmentsModule, TreatmentsModule, CasesModule, + TasksModule, StaffModule, OrganizationModule, AdminModule.forRoot(), diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts index d8b59ce..6ddc33a 100644 --- a/backend/src/common/organization-type.ts +++ b/backend/src/common/organization-type.ts @@ -13,7 +13,12 @@ const CLINIC_ONLY_PERMISSIONS = new Set([ 'TAB_TREATMENT_EDIT', ]); -const LAB_ONLY_PERMISSIONS = new Set(['TAB_CASES_READ', 'TAB_CASES_EDIT']); +const LAB_ONLY_PERMISSIONS = new Set([ + 'TAB_CASES_READ', + 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', +]); const SHARED_PERMISSIONS = ALL_TAB_PERMISSIONS.filter( (p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p), diff --git a/backend/src/common/permissions.ts b/backend/src/common/permissions.ts index 4a6f6c8..bd85f5b 100644 --- a/backend/src/common/permissions.ts +++ b/backend/src/common/permissions.ts @@ -14,6 +14,8 @@ export const ALL_TAB_PERMISSIONS = [ 'TAB_TREATMENT_EDIT', 'TAB_CASES_READ', 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', @@ -43,6 +45,7 @@ const EDIT_TO_READ: Record = { TAB_ORGANIZATIONS_EDIT: 'TAB_ORGANIZATIONS_READ', TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ', TAB_CASES_EDIT: 'TAB_CASES_READ', + TAB_TASKS_EDIT: 'TAB_TASKS_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', }; diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 5e1b861..95162ea 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -37,6 +37,8 @@ const ALL_PERMISSIONS = [ 'TAB_TREATMENT_EDIT', 'TAB_CASES_READ', 'TAB_CASES_EDIT', + 'TAB_TASKS_READ', + 'TAB_TASKS_EDIT', 'TAB_BILLING_READ', 'TAB_BILLING_EDIT', 'TAB_REPORTS_READ', diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 7c3a967..d463022 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -50,7 +50,7 @@ export class CasesController { } @Patch(':id/tasks/:taskId') - @ApiOperation({ summary: 'Update task assignee or status' }) + @ApiOperation({ summary: 'Update task assignee or priority' }) updateTask( @Param('id') id: string, @Param('taskId') taskId: string, diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 2ea727a..940ccc9 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -200,14 +200,19 @@ export class CasesService { } 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({ where: { id: taskId }, data: { - ...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}), - ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(dto.assigneeUserId !== undefined + ? { + assigneeUserId: dto.assigneeUserId, + assignedAt: dto.assigneeUserId === null ? null : new Date(), + } + : {}), + ...(dto.priority !== undefined ? { priority: dto.priority } : {}), }, include: { assignee: { select: { id: true, name: true, email: true } }, @@ -222,18 +227,27 @@ export class CasesService { const memberships = await this.prisma.membership.findMany({ 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' }], }); return { success: true, - data: memberships.map((m) => ({ - userId: m.user.id, - name: m.user.name, - email: m.user.email, - isOwner: m.isOwner, - })), + data: memberships + .filter((m) => { + if (m.isOwner) return true; + const names = m.permissions.map((p) => p.permission.name); + 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; stepLabel: string; status: LabTaskStatus; + priority: number; assigneeUserId: string | null; + assignedAt: Date | null; + createdAt: Date; assignee: { id: string; name: string; email: string } | null; }>, ) { @@ -411,7 +428,10 @@ export class CasesService { stepOrder: number; stepLabel: string; status: LabTaskStatus; + priority: number; assigneeUserId: string | null; + assignedAt: Date | null; + createdAt: Date; assignee: { id: string; name: string; email: string } | null; }) { return { @@ -421,6 +441,9 @@ export class CasesService { 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 } @@ -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({ where: { userId, organizationId: labOrganizationId, isActive: true }, - select: { id: true }, + include: { permissions: { include: { permission: true } } }, }); if (!membership) { 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) { diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index 438da73..e7ca9bb 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,6 +1,5 @@ import { Transform } from 'class-transformer'; -import { IsDateString, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; -import { LabTaskStatus } from '@prisma/client'; +import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateIf } from 'class-validator'; export class UpdateLabCaseTaskDto { @IsOptional() @@ -9,8 +8,11 @@ export class UpdateLabCaseTaskDto { assigneeUserId?: string | null; @IsOptional() - @IsEnum(LabTaskStatus) - status?: LabTaskStatus; + @Transform(({ value }) => Number(value)) + @IsInt() + @Min(1) + @Max(5) + priority?: number; } export class ListLabCasesDto { @@ -46,4 +48,4 @@ export class ListLabCasesDto { @Min(1) @Max(100) limit = 20; -} \ No newline at end of file +} diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts new file mode 100644 index 0000000..1b5539d --- /dev/null +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -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; +} diff --git a/backend/src/modules/tasks/tasks.controller.ts b/backend/src/modules/tasks/tasks.controller.ts new file mode 100644 index 0000000..a2125e4 --- /dev/null +++ b/backend/src/modules/tasks/tasks.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts new file mode 100644 index 0000000..76ba304 --- /dev/null +++ b/backend/src/modules/tasks/tasks.module.ts @@ -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 {} diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts new file mode 100644 index 0000000..ce7872a --- /dev/null +++ b/backend/src/modules/tasks/tasks.service.ts @@ -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 } } }, + }); + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 138c125..e482d97 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -52,6 +52,7 @@ "appointment": "Appointment", "treatment": "Treatment", "cases": "Cases", + "tasks": "Tasks", "billing": "Billing", "reports": "Reports", "clinics": "Clinics", @@ -263,6 +264,7 @@ "featureAppointment": "Appointment", "featureTreatment": "Treatment", "featureCases": "Cases", + "featureTasks": "Tasks", "featureBilling": "Billing", "featureReports": "Reports", "noTabAccess": "No tab access", @@ -348,7 +350,32 @@ "labComment": "Lab comment", "prevPage": "Previous", "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": { "title": "Appointments", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index d2a4b92..39012cc 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -52,6 +52,7 @@ "appointment": "نوبت‌ها", "treatment": "درمان", "cases": "پرونده‌ها", + "tasks": "وظایف", "billing": "صورتحساب", "reports": "گزارش‌ها", "clinics": "کلینیک‌ها", @@ -263,6 +264,7 @@ "featureAppointment": "نوبت‌ها", "featureTreatment": "درمان", "featureCases": "پرونده‌ها", + "featureTasks": "وظایف", "featureBilling": "صورتحساب", "featureReports": "گزارش‌ها", "noTabAccess": "دسترسی به برگه‌ها وجود ندارد", @@ -348,7 +350,32 @@ "labComment": "یادداشت آزمایشگاه", "prevPage": "قبلی", "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": { "title": "نوبت‌ها", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 714ffb5..e878c98 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -52,6 +52,7 @@ "appointment": "Afspraak", "treatment": "Behandeling", "cases": "Dossiers", + "tasks": "Taken", "billing": "Facturatie", "reports": "Rapporten", "clinics": "Klinieken", @@ -263,6 +264,7 @@ "featureAppointment": "Afspraak", "featureTreatment": "Behandeling", "featureCases": "Dossiers", + "featureTasks": "Taken", "featureBilling": "Facturatie", "featureReports": "Rapporten", "noTabAccess": "Geen tabbladtoegang", @@ -348,7 +350,32 @@ "labComment": "Labnotitie", "prevPage": "Vorige", "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": { "title": "Afspraken", diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index 207b4c4..9dddf11 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -1,14 +1,17 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { ToastStack } from '@/components/ui/shared/Toast'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; 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 { Button } from '@/components/ui/shared/Button'; +import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import type { AssignableMember, @@ -28,6 +31,18 @@ const TREATMENT_TYPE_KEYS = { } as const; 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 }) { return `${patient.firstName} ${patient.lastName}`.trim(); @@ -66,6 +81,7 @@ export default function CasesPage() { const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); const toast = useToast(); + const searchParams = useSearchParams(); const [search, setSearch] = useState(''); const [clinicId, setClinicId] = useState(''); @@ -93,7 +109,7 @@ export default function CasesPage() { const [loadingDetail, setLoadingDetail] = useState(false); const [updatingTaskId, setUpdatingTaskId] = useState(null); - const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT'); + const canEdit = canEditCases(currentOrganization); const locale = user?.language ?? 'en'; const treatmentLabel = useCallback( @@ -166,6 +182,13 @@ export default function CasesPage() { // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch }, []); + useEffect(() => { + const caseIdFromUrl = searchParams.get('caseId'); + if (caseIdFromUrl) { + setSelectedCaseId(caseIdFromUrl); + } + }, [searchParams]); + useEffect(() => { const timeout = setTimeout(() => { void loadCases({ @@ -201,7 +224,7 @@ export default function CasesPage() { async function handleTaskUpdate( taskId: string, - payload: { assigneeUserId?: string | null; status?: LabTaskStatus }, + payload: { assigneeUserId?: string | null; priority?: number }, ) { if (!selectedCaseId || !canEdit) return; @@ -225,8 +248,7 @@ export default function CasesPage() { } } - const filterSelectClass = - 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary'; + const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`; return (
@@ -475,24 +497,29 @@ export default function CasesPage() { {group.tasks.map((task) => (
  • {task.stepOrder}. {task.stepLabel} + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + @@ -504,7 +531,7 @@ export default function CasesPage() { 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} > {members.map((member) => ( diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx new file mode 100644 index 0000000..775321c --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -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([]); + const [pagination, setPagination] = useState({ + page: 1, + limit: PAGE_SIZE, + total: 0, + totalPages: 1, + }); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + + const 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
    {t('loading')}
    ; + } + + if (!canView) { + return ( +
    +

    {t('noPermissionTitle')}

    +

    {t('noPermissionBody')}

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

    {t('title')}

    +

    + {isOwner ? t('subtitleOwner') : t('subtitle')} +

    +
    + +
    + {loading && tasks.length === 0 ? ( +

    {t('loading')}

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

    + {isOwner ? t('emptyListOwner') : t('emptyList')} +

    + ) : ( +
      + {tasks.map((task) => { + const statusEditable = + canEdit && (isOwner || task.assigneeUserId === user?.id); + + return ( +
    • +
      +

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

      +

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

      +

      + {t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })} + {isOwner && ( + <> + · + + {task.assignee + ? t('assignedTo', { name: task.assignee.name }) + : t('unassigned')} + + + )} +

      +
      + +
      + {statusEditable ? ( + + ) : ( + + {statusOptions.find((opt) => opt.value === task.status)?.label ?? + task.status} + + )} +
      + +
      + + {t('priorityLabel', { n: task.priority })} + + +
      +
    • + ); + })} +
    + )} +
    + + {pagination.totalPages > 1 && ( +
    +

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

    +
    + + +
    +
    + )} + + +
    + ); +} diff --git a/frontend/src/components/shared/permissions.ts b/frontend/src/components/shared/permissions.ts index c35867a..aa0f649 100644 --- a/frontend/src/components/shared/permissions.ts +++ b/frontend/src/components/shared/permissions.ts @@ -16,6 +16,7 @@ export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [ { 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: '/tasks', permission: 'TAB_TASKS_READ', orgTypes: ['LAB'] }, { prefix: '/billing', permission: 'TAB_BILLING_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); } + if (route.prefix === '/cases') { + return canViewCases(org); + } + + if (route.prefix === '/tasks') { + return canViewTasks(org); + } + return hasPermission(org, route.permission); } @@ -84,6 +93,14 @@ export function firstAccessibleDashboardPath(org: Organization | null): string { if (canAccessAppointmentsSection(org)) return route.prefix; 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; } @@ -175,3 +192,21 @@ export function canEditCases(org: Organization | null): boolean { if (org.isOwner) return true; 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'); +} diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index e4312d7..5f4c04a 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -13,6 +13,7 @@ export const STAFF_FEATURE_GROUPS = [ { 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: '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: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT', orgTypes: ['CLINIC', 'LAB'] as const }, ] as const; diff --git a/frontend/src/components/ui/shared/Dropdown.tsx b/frontend/src/components/ui/shared/Dropdown.tsx index 1dc9621..5894f38 100644 --- a/frontend/src/components/ui/shared/Dropdown.tsx +++ b/frontend/src/components/ui/shared/Dropdown.tsx @@ -28,18 +28,13 @@ export const Dropdown = forwardRef( ref={ref} id={selectId} 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'} - bg-background-secondary/90 text-text-primary - + bg-background-card text-text-primary pl-4 pr-14 py-2 text-sm - focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong - disabled:opacity-50 disabled:cursor-not-allowed - transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)] - ${className} `} {...props} diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index d4e2e68..d5a3799 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -12,6 +12,7 @@ import { FileText, CreditCard, Package, + ListTodo, } from 'lucide-react'; import type { OrgTypeName } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -19,6 +20,7 @@ import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCou import { canAccessAppointmentsSection, canViewCases, + canViewTasks, canViewTab, } from '@/components/shared/permissions'; import { @@ -55,6 +57,7 @@ function Sidebar() { }, { 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('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('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'] }, @@ -75,6 +78,9 @@ function Sidebar() { if (item.path === '/cases') { return canViewCases(currentOrganization); } + if (item.path === '/tasks') { + return canViewTasks(currentOrganization); + } return canViewTab(currentOrganization, item.read); }), [currentOrganization, menu, orgType], diff --git a/frontend/src/components/ui/shared/formSelectStyles.ts b/frontend/src/components/ui/shared/formSelectStyles.ts new file mode 100644 index 0000000..7f3bb51 --- /dev/null +++ b/frontend/src/components/ui/shared/formSelectStyles.ts @@ -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'; diff --git a/frontend/src/lib/api/cases.ts b/frontend/src/lib/api/cases.ts index da7496b..2290edf 100644 --- a/frontend/src/lib/api/cases.ts +++ b/frontend/src/lib/api/cases.ts @@ -34,7 +34,7 @@ export const casesApi = { updateTask: async ( caseId: string, taskId: string, - payload: { assigneeUserId?: string | null; status?: LabCaseTask['status'] }, + payload: { assigneeUserId?: string | null; priority?: number }, ): Promise<{ success: boolean; data: LabCaseTask }> => { const response = await apiClient.patch(`/cases/${caseId}/tasks/${taskId}`, payload); return response.data; diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts new file mode 100644 index 0000000..d06875a --- /dev/null +++ b/frontend/src/lib/api/tasks.ts @@ -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; + }, +}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index ccb02d4..3e7998b 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -247,6 +247,24 @@ body { 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 { background: color-mix(in srgb, var(--color-card-background) 92%, transparent); border: 1px solid var(--color-card-border); diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index bd06ded..316d664 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -21,6 +21,9 @@ export interface LabCaseTask { stepOrder: number; stepLabel: string; status: LabTaskStatus; + priority: number; + assignedAt: string | null; + createdAt: string; assigneeUserId: string | null; assignee: { id: string; name: string; email: string } | null; } @@ -91,3 +94,30 @@ export interface PaginatedLabCases { 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; + }; +}