Compare commits

...

3 Commits

52 changed files with 2161 additions and 666 deletions

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX IF EXISTS "treatments_organizationId_status_idx";
-- AlterTable
ALTER TABLE "treatments" DROP COLUMN "status";
-- DropEnum
DROP TYPE "TreatmentStatus";

View File

@@ -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;

View File

@@ -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);

View File

@@ -114,11 +114,6 @@ model Appointment {
@@map("appointments")
}
enum TreatmentStatus {
DRAFT
COMPLETED
}
enum LabTaskStatus {
PENDING
IN_PROGRESS
@@ -126,13 +121,12 @@ enum LabTaskStatus {
}
model Treatment {
id String @id @default(uuid())
id String @id @default(uuid())
organizationId String
patientId String
appointmentId String? @unique
appointmentId String? @unique
providerUserId String
title String
status TreatmentStatus @default(DRAFT)
treatmentAt DateTime
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -145,7 +139,6 @@ model Treatment {
updatedAt DateTime @updatedAt
@@index([patientId, treatmentAt])
@@index([organizationId, status])
@@map("treatments")
}
@@ -260,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")
}

View File

@@ -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'],

View File

@@ -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(),

View File

@@ -13,7 +13,12 @@ const CLINIC_ONLY_PERMISSIONS = new Set<string>([
'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(
(p) => !CLINIC_ONLY_PERMISSIONS.has(p) && !LAB_ONLY_PERMISSIONS.has(p),

View File

@@ -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<string, string> = {
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',
};

View File

@@ -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',

View File

@@ -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,

View File

@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { TreatmentCatalogModule } from '../treatment-catalog/treatment-catalog.module';
import { CasesController } from './cases.controller';
import { CasesService } from './cases.service';
@Module({
imports: [TreatmentCatalogModule],
controllers: [CasesController],
providers: [CasesService, PrismaService, LabOrgGuard],
exports: [CasesService],
})
export class CasesModule {}

View File

@@ -156,6 +156,86 @@ export class CasesService {
};
}
/** Cases exchanged between one clinic and one lab (Organizations connection history). */
async listBetweenOrganizations(
clinicOrganizationId: string,
labOrganizationId: string,
query: ListLabCasesDto,
) {
if (query.treatmentType) {
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseWhereInput = {
...this.buildListWhere(labOrganizationId, query),
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
};
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
where,
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
details: {
include: {
detail: { select: { treatmentType: true } },
},
},
tasks: { select: { id: true, status: true } },
},
orderBy: [{ sentAt: 'desc' }],
skip,
take: limit,
}),
this.prisma.labCase.count({ where }),
]);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async getOneBetweenOrganizations(
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
},
include: labCaseListInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return { success: true, data: this.mapLabCaseDetail(labCase) };
}
async getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
@@ -200,14 +280,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 +307,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 +471,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 +508,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 +521,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 +531,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) {

View File

@@ -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;
}
}

View File

@@ -18,6 +18,7 @@ import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { RespondConnectionRequestDto } from './dto/respond-connection-request.dto';
import { OrganizationService } from './organization.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
/**
* Counterpart orgs (clinic↔lab).
@@ -121,6 +122,40 @@ export class OrganizationController {
return this.organizationService.deleteConnection(req.user.id, organizationId, connectionId);
}
@Get('connections/:connectionId/cases')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List cases exchanged with a connected organization' })
listConnectionCases(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Query() query: ListLabCasesDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.listConnectionCases(
req.user.id,
organizationId,
connectionId,
query,
);
}
@Get('connections/:connectionId/cases/:caseId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get one case exchanged with a connected organization' })
getConnectionCase(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('connectionId') connectionId: string,
@Param('caseId') caseId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.getConnectionCase(
req.user.id,
organizationId,
connectionId,
caseId,
);
}
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CasesModule } from '../cases/cases.module';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
imports: [CasesModule],
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})

View File

@@ -9,6 +9,8 @@ import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabCasesDto } from '../cases/dto/cases.dto';
import { CasesService } from '../cases/cases.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateConnectionRequestDto } from './dto/create-connection-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
@@ -28,7 +30,10 @@ import { RespondConnectionRequestDto } from './dto/respond-connection-request.dt
*/
@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly casesService: CasesService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -340,6 +345,64 @@ export class OrganizationService {
};
}
async listConnectionCases(
userId: string,
organizationId: string,
connectionId: string,
query: ListLabCasesDto,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
const result = await this.casesService.listBetweenOrganizations(
clinicOrganizationId,
labOrganizationId,
query,
);
return {
...result,
data: {
...result.data,
counterpart,
},
};
}
async getConnectionCase(
userId: string,
organizationId: string,
connectionId: string,
caseId: string,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
const result = await this.casesService.getOneBetweenOrganizations(
caseId,
clinicOrganizationId,
labOrganizationId,
);
return {
...result,
data: {
...result.data,
counterpart,
},
};
}
/** Re-issue a shareable URL for a pending invitation (rotates token; previous URL stops working). */
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
@@ -640,6 +703,58 @@ export class OrganizationService {
};
}
private async resolveActiveConnectionParties(
connectionId: string,
organizationId: string,
actor: {
organization: { type: { name: string } };
},
) {
const link = await this.prisma.organizationLink.findFirst({
where: {
id: connectionId,
status: LinkStatus.ACTIVE,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
include: {
organizationA: { select: { id: true, name: true, type: true } },
organizationB: { select: { id: true, name: true, type: true } },
},
});
if (!link) {
throw new NotFoundException('Connected organization not found');
}
const counterpart =
link.organizationAId === organizationId ? link.organizationB : link.organizationA;
const orgType = actor.organization.type.name;
if (orgType === 'CLINIC') {
if (counterpart.type.name !== 'LAB') {
throw new BadRequestException('Counterpart organization is not a lab');
}
return {
clinicOrganizationId: organizationId,
labOrganizationId: counterpart.id,
counterpart: { id: counterpart.id, name: counterpart.name },
};
}
if (orgType === 'LAB') {
if (counterpart.type.name !== 'CLINIC') {
throw new BadRequestException('Counterpart organization is not a clinic');
}
return {
clinicOrganizationId: counterpart.id,
labOrganizationId: organizationId,
counterpart: { id: counterpart.id, name: counterpart.name },
};
}
throw new BadRequestException('Unknown organization type');
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },

View 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;
}

View 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);
}
}

View 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 {}

View 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 } } },
});
}
}

View File

@@ -1,5 +1,3 @@
import { TreatmentStatus } from '@prisma/client';
const FDI_TOOTH_IDS = new Set([
'11', '12', '13', '14', '15', '16', '17', '18',
'21', '22', '23', '24', '25', '26', '27', '28',
@@ -39,7 +37,3 @@ export function generateTreatmentTitle(
return parts.join(' · ');
}
export function mapTreatmentStatusForApi(status: TreatmentStatus): string {
return status === TreatmentStatus.DRAFT ? 'draft' : 'completed';
}

View File

@@ -40,7 +40,7 @@ export class TreatmentsController {
}
@Get('patients/:patientId/history')
@ApiOperation({ summary: 'List completed treatments for a patient (TAB_TREATMENT_READ)' })
@ApiOperation({ summary: 'List treatments for a patient (draft and completed, TAB_TREATMENT_READ)' })
listPatientHistory(
@Param('patientId') patientId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 20,

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus, TreatmentStatus } from '@prisma/client';
import { LinkStatus } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
@@ -17,7 +17,6 @@ import {
} from './dto/treatment.dto';
import {
generateTreatmentTitle,
mapTreatmentStatusForApi,
normalizeTeeth,
} from './treatment.utils';
@@ -117,7 +116,7 @@ export class TreatmentsService {
where: {
patientId,
organizationId,
status: TreatmentStatus.COMPLETED,
details: { some: {} },
},
include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }],
@@ -144,7 +143,6 @@ export class TreatmentsService {
where: {
appointmentId: appointment.id,
organizationId,
status: TreatmentStatus.DRAFT,
},
include: treatmentInclude,
});
@@ -196,7 +194,6 @@ export class TreatmentsService {
treatmentAt: appointment.startAt,
patientId: appointment.patientId,
providerUserId: appointment.providerUserId,
status: TreatmentStatus.DRAFT,
},
})
: await tx.treatment.create({
@@ -206,7 +203,6 @@ export class TreatmentsService {
appointmentId: appointment.id,
providerUserId: appointment.providerUserId,
title,
status: TreatmentStatus.DRAFT,
treatmentAt: appointment.startAt,
},
});
@@ -314,7 +310,7 @@ export class TreatmentsService {
);
const treatment = await this.prisma.treatment.findFirst({
where: { appointmentId: appointment.id, organizationId, status: TreatmentStatus.DRAFT },
where: { appointmentId: appointment.id, organizationId },
select: { id: true },
});
@@ -605,7 +601,6 @@ export class TreatmentsService {
patientId: string;
appointmentId: string | null;
title: string;
status: TreatmentStatus;
treatmentAt: Date;
details: Array<{
id: string;
@@ -660,7 +655,6 @@ export class TreatmentsService {
appointmentId: treatment.appointmentId,
title: treatment.title,
treatmentAt: treatment.treatmentAt.toISOString(),
status: mapTreatmentStatusForApi(treatment.status),
details: treatment.details.map((d) => this.mapDetail(d)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
documents,

View File

@@ -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",
@@ -416,7 +443,6 @@
"loadingAppointments": "Loading appointments…",
"selectDayWithAppointment": "Select a day with at least one appointment.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
"successDraftSaved": "Treatment draft saved.",
"errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.",
"successFilesUploaded": "{count} file(s) uploaded successfully.",
@@ -428,7 +454,7 @@
"errorSaveDraft": "Failed to save treatment draft.",
"errorSendCase": "Failed to send case.",
"errorCaseMustSave": "Case must be saved before sending.",
"draftTitle": "Draft · {patientName}",
"treatmentPlanTitle": "Treatment · {patientName}",
"hiddenMessage": "Appointments are hidden.",
"showAppointments": "Show appointments",
"appointmentsTitle": "My appointments",
@@ -483,36 +509,30 @@
"successLabShipmentsSaved": "Lab shipments saved.",
"errorSaveLabShipments": "Failed to save lab shipments.",
"errorLabCaseNeedsDetails": "Select at least one treatment detail for this shipment.",
"saveDraft": "Save treatment draft",
"unsavedChanges": "Unsaved changes",
"draftSaved": "Draft saved",
"saveStatusSaving": "Saving…",
"saveStatusSaved": "All changes saved",
"saveStatusError": "Could not save — check your connection",
"sendSavesFirst": "Sending is per case and saves first automatically.",
"historyTitle": "Previous treatments",
"historySubtitle": "Completed treatments for this patient. Each case is listed separately.",
"historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.",
"loadingHistory": "Loading history…",
"historyEmpty": "No prior treatments for this patient.",
"statusLabel": "Status:",
"historyCaseLabel": "Case {n} · {type}",
"historyEmpty": "No other treatments recorded for this patient yet.",
"historyDetailLabel": "Detail {n} · {type}",
"previewTitle": "Treatment preview",
"openTreatment": "Open",
"selectAppointment": "Select an appointment to preview its treatment.",
"detailCount": "{n} detail(s)",
"detailSummary": "Detail {n}: {type}",
"detailAttachmentCount": "{n, plural, one {# file} other {# files}}",
"detailNotSentToLab": "Not sent to lab",
"detailPendingLabSend": "This lab detail has not been sent yet.",
"teethLabel": "Teeth:",
"teethNone": "None selected",
"reviewDetails": "Review details",
"previewTitle": "Treatment preview",
"previewDraft": "Preview current draft",
"selectAppointment": "Select an appointment to preview its draft.",
"caseCount": "{n} case(s)",
"attachmentCount": "{n} attachment(s)",
"caseSummary": "Case {n}: {type}",
"teethPrefix": "· Teeth",
"moreCases": "+ {n} more case(s)",
"previewDialogTitle": "Treatment preview",
"previewDialogSubtitle": "Review cases, attachments, and send destinations.",
"previewDialogSubtitlePhase4": "Review treatment details and attachments.",
"previewLabDispatchHint": "Use the lab dispatch panel in the workspace to send work to labs.",
"historicalReadonlyNotice": "You are viewing a past treatment (read-only).",
"errorNoAppointmentForTreatment": "This treatment has no linked appointment and cannot be opened.",
"noCases": "No cases in this treatment.",
"noDetails": "No treatment details in this draft.",
"noDetails": "No treatment details yet.",
"typeLabel": "Type:",
"commentsLabel": "Comments:",
"commentsEmpty": "Comments: —",
@@ -586,7 +606,16 @@
"continueArrow": "Continue →",
"planLabel": "Plan: {name} • {maxUsers} users",
"counterpartClinic": "Clinic",
"counterpartLab": "Lab"
"counterpartLab": "Lab",
"viewCaseHistory": "View case history",
"caseHistoryBackToConnections": "← Back to connections",
"caseHistoryTitle": "Case history with {name}",
"caseHistorySubtitleClinic": "Cases you sent to this lab, including lab workflow status for each step.",
"caseHistorySubtitleLab": "Cases received from this clinic, including task status for each step.",
"caseHistoryEmpty": "No cases exchanged with this organization yet.",
"caseHistorySentToLab": "Sent to {name}",
"caseHistoryErrorLoadList": "Failed to load case history.",
"caseHistoryErrorLoadDetail": "Failed to load case details."
},
"settings": {
"accountTitle": "Account",

View File

@@ -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": "نوبت‌ها",
@@ -416,7 +443,6 @@
"loadingAppointments": "در حال بارگذاری نوبت‌ها...",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
"confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
"successDraftSaved": "پیش‌نویس درمان ذخیره شد.",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.",
"successFilesUploaded": "{count} فایل با موفقیت بارگذاری شد.",
@@ -428,7 +454,7 @@
"errorSaveDraft": "ذخیره پیش‌نویس درمان ناموفق بود.",
"errorSendCase": "ارسال پرونده ناموفق بود.",
"errorCaseMustSave": "پرونده باید قبل از ارسال ذخیره شود.",
"draftTitle": "پیش‌نویس · {patientName}",
"treatmentPlanTitle": "درمان · {patientName}",
"hiddenMessage": "نوبت‌ها پنهان هستند.",
"showAppointments": "نمایش نوبت‌ها",
"appointmentsTitle": "نوبت‌های من",
@@ -483,35 +509,29 @@
"successLabShipmentsSaved": "محموله‌های لاب ذخیره شد.",
"errorSaveLabShipments": "ذخیره محموله‌های لاب ناموفق بود.",
"errorLabCaseNeedsDetails": "حداقل یک جزئیات درمان برای این محموله انتخاب کنید.",
"saveDraft": "ذخیره پیش‌نویس درمان",
"unsavedChanges": "تغییرات ذخیره‌نشده",
"draftSaved": "پیش‌نویس ذخیره شد",
"saveStatusSaving": "در حال ذخیره…",
"saveStatusSaved": "همه تغییرات ذخیره شد",
"saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید",
"sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.",
"historyTitle": "درمان‌های قبلی",
"historySubtitle": "درمان‌های تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.",
"historySubtitle": "برای پیش‌نمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیش‌نمایش برای بارگذاری در فضای کاری استفاده کنید.",
"loadingHistory": "در حال بارگذاری تاریخچه...",
"historyEmpty": "هیچ درمان قبلی برای این بیمار وجود ندارد.",
"statusLabel": "وضعیت:",
"historyCaseLabel": "پرونده {n} · {type}",
"historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.",
"historyDetailLabel": "جزئیات {n} · {type}",
"previewTitle": "پیش‌نمایش درمان",
"openTreatment": "باز کردن",
"selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.",
"detailCount": "{n} جزئیات",
"detailSummary": "جزئیات {n}: {type}",
"detailAttachmentCount": "{n} فایل",
"detailNotSentToLab": "به لاب ارسال نشده",
"detailPendingLabSend": "این جزئیات لاب هنوز ارسال نشده است.",
"historicalReadonlyNotice": "در حال مشاهده یک درمان گذشته (فقط خواندنی) هستید.",
"errorNoAppointmentForTreatment": "این درمان نوبت مرتبطی ندارد و قابل باز کردن نیست.",
"teethLabel": "دندان‌ها:",
"teethNone": "هیچکدام انتخاب نشده",
"reviewDetails": "بررسی جزئیات",
"previewTitle": "پیش‌نمایش درمان",
"previewDraft": "پیش‌نمایش پیش‌نویس فعلی",
"selectAppointment": "یک نوبت را برای پیش‌نمایش پیش‌نویس آن انتخاب کنید.",
"caseCount": "{n} پرونده",
"attachmentCount": "{n} پیوست",
"caseSummary": "پرونده {n}: {type}",
"teethPrefix": "· دندان‌ها",
"moreCases": "+ {n} پرونده دیگر",
"previewDialogTitle": "پیش‌نمایش درمان",
"previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.",
"previewDialogSubtitlePhase4": "بررسی جزئیات درمان و پیوست‌ها.",
"previewLabDispatchHint": "برای ارسال کار به لابراتوار از بخش ارسال لاب در فضای کاری استفاده کنید.",
"noDetails": "جزئیات درمانی در این پیش‌نویس وجود ندارد.",
"noDetails": "هنوز جزئیات درمانی وجود ندارد.",
"noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.",
"typeLabel": "نوع:",
"commentsLabel": "نظرات:",
@@ -586,7 +606,16 @@
"continueArrow": "ادامه →",
"planLabel": "طرح: {name} • {maxUsers} کاربر",
"counterpartClinic": "کلینیک",
"counterpartLab": "لابراتوار"
"counterpartLab": "لابراتوار",
"viewCaseHistory": "مشاهده تاریخچه پرونده‌ها",
"caseHistoryBackToConnections": "← بازگشت به اتصالات",
"caseHistoryTitle": "تاریخچه پرونده با {name}",
"caseHistorySubtitleClinic": "پرونده‌هایی که به این لابراتوار ارسال کرده‌اید، شامل وضعیت گردش کار لابراتوار برای هر مرحله.",
"caseHistorySubtitleLab": "پرونده‌های دریافتی از این کلینیک، شامل وضعیت وظایف برای هر مرحله.",
"caseHistoryEmpty": "هنوز پرونده‌ای با این سازمان رد و بدل نشده است.",
"caseHistorySentToLab": "ارسال شده به {name}",
"caseHistoryErrorLoadList": "بارگذاری تاریخچه پرونده ناموفق بود.",
"caseHistoryErrorLoadDetail": "بارگذاری جزئیات پرونده ناموفق بود."
},
"settings": {
"accountTitle": "حساب کاربری",

View File

@@ -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",
@@ -416,7 +443,6 @@
"loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
"successDraftSaved": "Behandelconcept opgeslagen.",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
"successFilesUploaded": "{count} bestand(en) succesvol geüpload.",
@@ -428,7 +454,7 @@
"errorSaveDraft": "Behandelconcept opslaan mislukt.",
"errorSendCase": "Case verzenden mislukt.",
"errorCaseMustSave": "Case moet worden opgeslagen voor verzending.",
"draftTitle": "Concept · {patientName}",
"treatmentPlanTitle": "Behandeling · {patientName}",
"hiddenMessage": "Afspraken zijn verborgen.",
"showAppointments": "Afspraken tonen",
"appointmentsTitle": "Mijn afspraken",
@@ -483,36 +509,30 @@
"successLabShipmentsSaved": "Labzendingen opgeslagen.",
"errorSaveLabShipments": "Labzendingen opslaan mislukt.",
"errorLabCaseNeedsDetails": "Selecteer minimaal één behandeldetail voor deze zending.",
"saveDraft": "Behandelconcept opslaan",
"unsavedChanges": "Niet-opgeslagen wijzigingen",
"draftSaved": "Concept opgeslagen",
"saveStatusSaving": "Opslaan…",
"saveStatusSaved": "Alle wijzigingen opgeslagen",
"saveStatusError": "Opslaan mislukt — controleer uw verbinding",
"sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.",
"historyTitle": "Eerdere behandelingen",
"historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.",
"historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.",
"loadingHistory": "Geschiedenis laden...",
"historyEmpty": "Geen eerdere behandelingen voor deze patiënt.",
"statusLabel": "Status:",
"historyCaseLabel": "Case {n} · {type}",
"historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.",
"historyDetailLabel": "Detail {n} · {type}",
"previewTitle": "Behandelvoorbeeld",
"openTreatment": "Openen",
"selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.",
"detailCount": "{n} detail(s)",
"detailSummary": "Detail {n}: {type}",
"detailAttachmentCount": "{n, plural, one {# bestand} other {# bestanden}}",
"detailNotSentToLab": "Niet naar lab verzonden",
"detailPendingLabSend": "Dit labdetail is nog niet verzonden.",
"historicalReadonlyNotice": "U bekijkt een eerdere behandeling (alleen-lezen).",
"errorNoAppointmentForTreatment": "Deze behandeling heeft geen gekoppelde afspraak en kan niet worden geopend.",
"teethLabel": "Tanden:",
"teethNone": "Geen geselecteerd",
"reviewDetails": "Details bekijken",
"previewTitle": "Behandelvoorbeeld",
"previewDraft": "Bekijk huidig concept",
"selectAppointment": "Selecteer een afspraak om het concept te bekijken.",
"caseCount": "{n} case(s)",
"attachmentCount": "{n} bijlage(n)",
"caseSummary": "Case {n}: {type}",
"teethPrefix": "· Tanden",
"moreCases": "+ {n} meer case(s)",
"previewDialogTitle": "Behandelvoorbeeld",
"previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.",
"previewDialogSubtitlePhase4": "Bekijk behandeldetails en bijlagen.",
"previewLabDispatchHint": "Gebruik het lab-dispatchpaneel in de werkruimte om werk naar labs te sturen.",
"noCases": "Geen casussen in deze behandeling.",
"noDetails": "Geen behandeldetails in dit concept.",
"noDetails": "Nog geen behandeldetails.",
"typeLabel": "Type:",
"commentsLabel": "Opmerkingen:",
"commentsEmpty": "Opmerkingen: —",
@@ -586,7 +606,16 @@
"continueArrow": "Doorgaan →",
"planLabel": "Plan: {name} • {maxUsers} gebruikers",
"counterpartClinic": "Kliniek",
"counterpartLab": "Laboratorium"
"counterpartLab": "Laboratorium",
"viewCaseHistory": "Casusgeschiedenis bekijken",
"caseHistoryBackToConnections": "← Terug naar verbindingen",
"caseHistoryTitle": "Casusgeschiedenis met {name}",
"caseHistorySubtitleClinic": "Cases die u naar dit lab hebt gestuurd, inclusief lab-workflowstatus per stap.",
"caseHistorySubtitleLab": "Cases ontvangen van deze kliniek, inclusief taakstatus per stap.",
"caseHistoryEmpty": "Nog geen cases uitgewisseld met deze organisatie.",
"caseHistorySentToLab": "Verzonden naar {name}",
"caseHistoryErrorLoadList": "Casusgeschiedenis laden mislukt.",
"caseHistoryErrorLoadDetail": "Casusdetails laden mislukt."
},
"settings": {
"accountTitle": "Account",

View File

@@ -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<string | null>(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 (
<div className="space-y-4">
@@ -475,24 +497,29 @@ export default function CasesPage() {
{group.tasks.map((task) => (
<li
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>
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
<select
value={task.status}
value={task.priority}
disabled={!canEdit || updatingTaskId === task.id}
onChange={(e) =>
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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{PRIORITY_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
@@ -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}
>
<option value="">{t('unassigned')}</option>
{members.map((member) => (

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useToast } from '@/lib/hooks/useToast';
import { Check, Trash2, UserPlus, X } from 'lucide-react';
import { Check, History, Trash2, UserPlus, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { notifyPendingConnectionsChanged } from '@/lib/hooks/usePendingConnectionsCount';
import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy';
@@ -16,6 +16,7 @@ import {
import { invitationTargetFromConnectionRow } from '@/components/invitations/organizationInviteLinks';
import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton';
import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog';
import { ConnectionCaseHistoryContent } from '@/components/ui/organizations/ConnectionCaseHistoryContent';
import { Button } from '@/components/ui/shared/Button';
import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shared/Badge';
import { Input } from '@/components/ui/shared/Input';
@@ -90,6 +91,9 @@ export default function OrganizationsPage() {
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
const [caseHistoryConnection, setCaseHistoryConnection] = useState<CounterpartItemDto | null>(
null,
);
const {
copiedId,
@@ -289,6 +293,15 @@ export default function OrganizationsPage() {
return <p className="text-sm text-text-secondary">{t('loadingOrganization')}</p>;
}
if (caseHistoryConnection) {
return (
<ConnectionCaseHistoryContent
connection={caseHistoryConnection}
onBack={() => setCaseHistoryConnection(null)}
/>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
@@ -426,16 +439,27 @@ export default function OrganizationsPage() {
</>
)}
{row.status === 'ACTIVE' && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
onClick={() => void deleteConnection(row.id)}
aria-label={t('removeConnection')}
title={t('removeConnection')}
>
<Trash2 className="w-4 h-4" />
</button>
<>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={() => setCaseHistoryConnection(row)}
aria-label={t('viewCaseHistory')}
title={t('viewCaseHistory')}
>
<History className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id}
onClick={() => void deleteConnection(row.id)}
aria-label={t('removeConnection')}
title={t('removeConnection')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</td>

View 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>
);
}

View File

@@ -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');
}

View File

@@ -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;

View File

@@ -0,0 +1,407 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
import { useAuth } from '@/lib/hooks/useAuth';
import { useToast } from '@/lib/hooks/useToast';
import { organizationApi } from '@/lib/api/organization';
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { SearchBar } from '@/components/ui/shared/SearchBar';
import { ToastStack } from '@/components/ui/shared/Toast';
import type { CounterpartItemDto } from '@/lib/api/organization';
import type { LabCaseDetail, LabCaseListItem, LabTaskStatus } from '@/types/cases';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
const PAGE_SIZE = 20;
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();
}
function formatDateTime(value: string | null, locale: string) {
if (!value) return '—';
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function TaskProgressBar({ completed, total }: { completed: number; total: number }) {
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-text-muted">
<span>
{completed}/{total}
</span>
<span>{pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
interface ConnectionCaseHistoryContentProps {
connection: CounterpartItemDto;
onBack: () => void;
}
export function ConnectionCaseHistoryContent({
connection,
onBack,
}: ConnectionCaseHistoryContentProps) {
const t = useTranslations('organizations');
const tCases = useTranslations('cases');
const tTreatment = useTranslations('treatment');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [cases, setCases] = useState<LabCaseListItem[]>([]);
const [pagination, setPagination] = useState({
page: 1,
limit: PAGE_SIZE,
total: 0,
totalPages: 1,
});
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const locale = user?.language ?? 'en';
const isClinic = currentOrganization?.type === 'CLINIC';
const tRef = useRef(t);
tRef.current = t;
const treatmentLabel = useCallback(
(type: string) => {
const key = TREATMENT_TYPE_KEYS[type as keyof typeof TREATMENT_TYPE_KEYS];
return key ? tTreatment(key) : type;
},
[tTreatment],
);
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
{ value: 'PENDING', label: tCases('statusPending') },
{ value: 'IN_PROGRESS', label: tCases('statusInProgress') },
{ value: 'COMPLETED', label: tCases('statusCompleted') },
],
[tCases],
);
useEffect(() => {
let cancelled = false;
const timeout = setTimeout(() => {
void (async () => {
setLoadingList(true);
setError('');
try {
const response = await organizationApi.listConnectionCases(connection.id, {
q: search.trim() || undefined,
page,
limit: PAGE_SIZE,
});
if (cancelled) return;
setCases(response.data.items);
setPagination(response.data.pagination);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList')));
} finally {
if (!cancelled) setLoadingList(false);
}
})();
}, search ? 300 : 0);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [search, page, connection.id, showError, setError]);
useEffect(() => {
if (!selectedCaseId) {
setSelectedCase(null);
return;
}
let cancelled = false;
void (async () => {
setLoadingDetail(true);
setError('');
try {
const response = await organizationApi.getConnectionCase(connection.id, selectedCaseId);
if (cancelled) return;
setSelectedCase(response.data);
} catch (error: unknown) {
if (cancelled) return;
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail')));
setSelectedCase(null);
} finally {
if (!cancelled) setLoadingDetail(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCaseId, connection.id, showError, setError]);
return (
<div className="space-y-6">
<div>
<button
type="button"
onClick={onBack}
className="text-sm text-primary hover:opacity-90"
>
{t('caseHistoryBackToConnections')}
</button>
</div>
<div>
<h1 className="text-2xl font-semibold text-text-primary">
{t('caseHistoryTitle', { name: connection.organizationName })}
</h1>
<p className="text-sm text-text-secondary mt-1">
{isClinic ? t('caseHistorySubtitleClinic') : t('caseHistorySubtitleLab')}
</p>
</div>
<ToastStack {...toastMessages} />
<div className="grid gap-4 lg:grid-cols-[minmax(300px,380px)_1fr]">
<section className="rounded-lg border border-border bg-surface p-4 space-y-3 flex flex-col min-h-0">
<SearchBar
embedded
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder={tCases('searchPlaceholder')}
/>
<div className="flex-1 min-h-0">
{loadingList ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : cases.length === 0 ? (
<p className="text-sm text-text-muted">{t('caseHistoryEmpty')}</p>
) : (
<ul className="space-y-2 max-h-[55vh] overflow-y-auto pr-1">
{cases.map((item) => {
const isActive = item.id === selectedCaseId;
return (
<li key={item.id}>
<button
type="button"
onClick={() => setSelectedCaseId(item.id)}
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="font-medium text-text-primary">
{formatPatientName(item.patient)}
</div>
<div className="text-xs text-text-muted mt-0.5">{item.patient.mobile}</div>
{!isClinic ? (
<div className="text-xs text-text-muted mt-0.5">{item.clinic.name}</div>
) : null}
<div className="text-xs text-text-muted mt-1">
{formatDateTime(item.sentAt, locale)}
</div>
<div className="text-xs text-text-muted mt-1 truncate">
{item.treatmentTypes.map(treatmentLabel).join(', ')}
</div>
<div className="mt-2">
<TaskProgressBar
completed={item.taskProgress.completed}
total={item.taskProgress.total}
/>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
{pagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-border">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loadingList}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{tCases('prevPage')}
</Button>
<span className="text-xs text-text-muted text-center">
{tCases('pageSummary', {
page: pagination.page,
totalPages: pagination.totalPages,
total: pagination.total,
})}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= pagination.totalPages || loadingList}
onClick={() => setPage((p) => p + 1)}
>
{tCases('nextPage')}
</Button>
</div>
) : null}
</section>
<section className="rounded-lg border border-border bg-surface p-4 min-h-[420px]">
{!selectedCaseId ? (
<p className="text-sm text-text-muted">{tCases('selectCaseHint')}</p>
) : loadingDetail || !selectedCase ? (
<p className="text-sm text-text-muted">{tCommon('loading')}</p>
) : (
<div className="space-y-4">
<header className="space-y-1 border-b border-border pb-3">
<h2 className="text-lg font-semibold text-text-primary">
{formatPatientName(selectedCase.patient)}
</h2>
<p className="text-sm text-text-muted">
{tCases('patientMobile')}: {selectedCase.patient.mobile}
</p>
{!isClinic ? (
<p className="text-sm text-text-muted">
{tCases('fromClinic', { name: selectedCase.clinic.name })}
</p>
) : (
<p className="text-sm text-text-muted">
{t('caseHistorySentToLab', { name: connection.organizationName })}
</p>
)}
<p className="text-sm text-text-muted">
{tCases('sentAt', { date: formatDateTime(selectedCase.sentAt, locale) })}
</p>
<div className="pt-1 max-w-xs">
<p className="text-sm text-text-muted mb-1">
{tCases('taskProgressLabel', {
completed: selectedCase.taskProgress.completed,
total: selectedCase.taskProgress.total,
})}
</p>
<TaskProgressBar
completed={selectedCase.taskProgress.completed}
total={selectedCase.taskProgress.total}
/>
</div>
{selectedCase.labComment ? (
<p className="text-sm text-text-muted pt-1">
<span className="font-medium text-text-primary">{tCases('labComment')}:</span>{' '}
{selectedCase.labComment}
</p>
) : null}
</header>
{selectedCase.details.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-text-primary">
{tCases('treatmentDetails')}
</h3>
<ul className="space-y-2 text-sm">
{selectedCase.details.map((detail) => (
<li
key={detail.id}
className="rounded-md bg-background border border-border p-2"
>
<div className="font-medium">{treatmentLabel(detail.treatmentType)}</div>
<div className="text-text-muted">
{tCases('teethLabel')}: {detail.teeth.join(', ') || '—'}
</div>
{detail.comment ? (
<div className="text-text-muted mt-1">{detail.comment}</div>
) : null}
</li>
))}
</ul>
</div>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium text-text-primary">{tCases('tasksByTooth')}</h3>
{selectedCase.tasksByTooth.length === 0 ? (
<p className="text-sm text-text-muted">{tCases('noTasks')}</p>
) : (
selectedCase.tasksByTooth.map((group) => (
<div
key={`${group.tooth}-${group.treatmentType}`}
className="rounded-md border border-border p-3 space-y-2"
>
<div className="text-sm font-medium text-text-primary">
{tCases('toothGroupTitle', {
tooth: group.tooth,
type: treatmentLabel(group.treatmentType),
})}
</div>
<ul className="space-y-2">
{group.tasks.map((task) => (
<li
key={task.id}
className="flex flex-wrap items-center gap-2 text-sm rounded bg-background p-2"
>
<span className="min-w-0 flex-1">
{task.stepOrder}. {task.stepLabel}
</span>
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
{statusOptions.find((opt) => opt.value === task.status)?.label ??
task.status}
</Badge>
</li>
))}
</ul>
</div>
))
)}
</div>
</div>
)}
</section>
</div>
</div>
);
}

View File

@@ -28,18 +28,13 @@ export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
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}

View File

@@ -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],

View 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';

View File

@@ -0,0 +1,43 @@
'use client';
import { useTranslations } from 'next-intl';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/ui/treatment/treatmentStatusStyles';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
interface DetailLabSendBadgeProps {
detail: Pick<
PastTreatmentDetail,
'treatmentType' | 'sentAt' | 'sends' | 'destinationOrganizationId'
>;
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
className?: string;
}
export function DetailLabSendBadge({
detail,
labDependentCodes,
orgs,
className = '',
}: DetailLabSendBadgeProps) {
const t = useTranslations('treatment');
if (!labDependentCodes.has(detail.treatmentType)) {
return null;
}
if (detail.sentAt) {
return (
<CaseSentLabel
treatmentCase={detail}
orgs={orgs}
className={`${labSentBadgeClass} ${className}`.trim()}
/>
);
}
return (
<span className={`${labNotSentBadgeClass} ${className}`.trim()}>{t('detailNotSentToLab')}</span>
);
}

View File

@@ -1,39 +1,29 @@
'use client';
import { useTranslations } from 'next-intl';
import { FileText } from 'lucide-react';
import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine';
import type { PastTreatment } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
interface PastTreatmentsPanelProps {
items: PastTreatment[];
loading?: boolean;
onReviewTreatment?: (treatment: PastTreatment) => void;
selectedPreviewId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
}
export function PastTreatmentsPanel({
items,
loading,
onReviewTreatment,
selectedPreviewId,
onSelectTreatment,
}: PastTreatmentsPanelProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
return (
<div className="surface-card p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('historyTitle')}</h3>
<p className="text-[11px] text-text-muted mt-0.5">
{t('historySubtitle')}
</p>
<p className="text-[11px] text-text-muted mt-0.5">{t('historySubtitle')}</p>
</div>
{loading && <p className="text-sm text-text-muted">{t('loadingHistory')}</p>}
@@ -42,95 +32,60 @@ export function PastTreatmentsPanel({
<p className="text-sm text-text-muted">{t('historyEmpty')}</p>
)}
<div className="space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
{items.map((treatment) => (
<article
key={treatment.id}
className="border border-border/70 rounded-[var(--radius-md)] p-2.5 bg-background-secondary/40 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{treatment.title}</p>
<p className="text-[11px] text-text-secondary capitalize mt-0.5">
{t('statusLabel')} {treatment.status}
</p>
</div>
<div className="space-y-1.5 max-h-[min(420px,50vh)] overflow-y-auto pr-1">
{items.map((treatment) => {
const isSelected = selectedPreviewId === treatment.id;
return (
<article
key={treatment.id}
role="button"
tabIndex={0}
onClick={() => onSelectTreatment?.(treatment)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectTreatment?.(treatment);
}
}}
className={`
border rounded-[var(--radius-sm)] px-2 py-1.5 cursor-pointer transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
isSelected
? 'border-primary bg-primary/5'
: 'border-border/60 bg-background-secondary/30 hover:border-border hover:bg-background-secondary/50'
}
`}
>
<time
className="text-[11px] text-text-muted tabular-nums shrink-0"
className="text-xs font-medium text-text-primary tabular-nums block"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString()}
{new Date(treatment.treatmentAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</div>
<div className="space-y-1.5">
{treatment.details.map((c, idx) => {
const attachments = c.attachmentMetas ?? [];
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
return (
<div
key={c.id}
className="border border-border/60 rounded-[var(--radius-sm)] px-2.5 py-2 bg-background-secondary/30 space-y-1"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-text-primary capitalize">
{t('historyCaseLabel', { n: idx + 1, type: typeLabel })}
</p>
{c.sentAt && (
<CaseSentLabel
treatmentCase={c}
className="text-[10px] text-text-muted shrink-0 text-right"
/>
)}
{treatment.details.length === 0 ? (
<p className="text-[10px] text-text-muted mt-1">{t('noDetails')}</p>
) : (
<div className="mt-1.5 divide-y divide-border/50 border-t border-border/40 pointer-events-none">
{treatment.details.map((detail, idx) => (
<div key={detail.clientId ?? detail.id} className="py-1.5">
<TreatmentHistoryDetailLine
detail={detail}
detailNumber={idx + 1}
/>
</div>
<p className="text-[11px] text-text-secondary">
{t('teethLabel')} {c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
</p>
{c.notes?.trim() && (
<p className="text-[11px] text-text-muted line-clamp-2">{c.notes}</p>
)}
<div>
<p className="text-[10px] uppercase tracking-wide text-text-muted mb-1">
{t('attachments')}
</p>
{attachments.length === 0 ? (
<p className="text-[11px] text-text-muted">{tCommon('none')}</p>
) : (
<ul className="space-y-0.5">
{attachments.map((doc) => (
<li
key={doc.id}
className="flex items-center gap-1.5 text-[11px] text-text-secondary"
>
<FileText className="w-3 h-3 shrink-0 icon-flat" aria-hidden />
<span className="truncate">{doc.fileName}</span>
<span className="text-text-muted tabular-nums shrink-0">
{(doc.sizeBytes / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
})}
</div>
{onReviewTreatment && (
<div className="pt-1 flex justify-end">
<button
type="button"
onClick={() => onReviewTreatment(treatment)}
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
>
{t('reviewDetails')}
</button>
</div>
)}
</article>
))}
))}
</div>
)}
</article>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,57 @@
'use client';
import { useTranslations } from 'next-intl';
import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment';
interface TreatmentDetailSummaryRowProps {
detail: PastTreatmentDetail;
detailNumber: number;
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
compact?: boolean;
}
export function TreatmentDetailSummaryRow({
detail,
detailNumber,
labDependentCodes,
orgs,
compact = false,
}: TreatmentDetailSummaryRowProps) {
const t = useTranslations('treatment');
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
const attachmentCount = detail.attachmentMetas?.length ?? 0;
return (
<div
className={`rounded-[var(--radius-sm)] border border-border/60 bg-background-secondary/30 ${
compact ? 'px-2.5 py-2' : 'px-3 py-2'
} space-y-1`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<span className={`text-text-muted tabular-nums ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailLabel', { n: detailNumber })}
</span>
<TreatmentTypeBadge type={detail.treatmentType} />
</div>
<DetailLabSendBadge detail={detail} labDependentCodes={labDependentCodes} orgs={orgs} />
</div>
<p className={`text-text-secondary ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('teethLabel')} {teeth}
</p>
{attachmentCount > 0 && (
<p className={`text-text-muted ${compact ? 'text-[11px]' : 'text-xs'}`}>
{t('detailAttachmentCount', { n: attachmentCount })}
</p>
)}
{detail.notes?.trim() && (
<p className={`text-text-muted line-clamp-2 ${compact ? 'text-[11px]' : 'text-xs'}`}>
{detail.notes}
</p>
)}
</div>
);
}

View File

@@ -4,6 +4,11 @@ import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import {
autosaveStatusClass,
labPendingBannerClass,
labSentBannerClass,
} from '@/components/ui/treatment/treatmentStatusStyles';
import type { TreatmentDetailDraft } from '@/types/treatment';
import { TREATMENT_TYPE_COLORS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
@@ -13,12 +18,12 @@ interface TreatmentDetailsEditorProps {
onActiveDetailChange: (id: string) => void;
onDetailsChange: (details: TreatmentDetailDraft[]) => void;
isDetailLocked: (detail: TreatmentDetailDraft) => boolean;
labDependentCodes: Set<string>;
disabled: boolean;
canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean;
onAddDetail: () => void;
onPreview: () => void;
onUploadFiles: (files: FileList | null) => void;
}
@@ -28,16 +33,15 @@ export function TreatmentDetailsEditor({
onActiveDetailChange,
onDetailsChange,
isDetailLocked,
labDependentCodes,
disabled,
canEdit,
saveStatus,
uploadBusy,
onAddDetail,
onPreview,
onUploadFiles,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
@@ -46,6 +50,8 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = TREATMENT_TYPE_COLORS[activeDetail.treatmentType];
const isLabDependent = labDependentCodes.has(activeDetail.treatmentType);
const showPendingLabHint = isLabDependent && !locked && !readOnly;
return (
<div className="surface-card p-4 space-y-4">
@@ -54,14 +60,9 @@ export function TreatmentDetailsEditor({
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" disabled={!canEdit || disabled} onClick={onPreview}>
{tCommon('preview')}
</Button>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddDetail}>
{t('addDetail')}
</Button>
</div>
<Button type="button" variant="primary" disabled={!canEdit || disabled} onClick={onAddDetail}>
{t('addDetail')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
@@ -88,9 +89,10 @@ export function TreatmentDetailsEditor({
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{locked && (
<p className="text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-2 py-1.5">
{t('detailLockedInShipment')}
</p>
<p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>
)}
{showPendingLabHint && (
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
)}
<label className="block text-xs font-medium text-text-secondary">
@@ -173,9 +175,7 @@ export function TreatmentDetailsEditor({
{canEdit && saveStatus !== 'idle' && (
<p
className={`text-xs pt-2 border-t border-border/60 ${
saveStatus === 'error' ? 'text-red-500' : 'text-text-muted'
}`}
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
role="status"
aria-live="polite"
>

View File

@@ -0,0 +1,32 @@
'use client';
import { useTranslations } from 'next-intl';
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
import type { PastTreatmentDetail } from '@/types/treatment';
interface TreatmentHistoryDetailLineProps {
detail: PastTreatmentDetail;
detailNumber: number;
}
export function TreatmentHistoryDetailLine({
detail,
detailNumber,
}: TreatmentHistoryDetailLineProps) {
const t = useTranslations('treatment');
const teeth = detail.teeth.length ? [...detail.teeth].sort().join(', ') : t('teethNone');
const attachmentCount = detail.attachmentMetas?.length ?? 0;
return (
<div className="flex items-center gap-2 min-w-0 text-[11px] leading-tight">
<span className="text-text-muted tabular-nums shrink-0">{detailNumber}.</span>
<TreatmentTypeBadge type={detail.treatmentType} />
<span className="text-text-secondary truncate min-w-0">{teeth}</span>
{attachmentCount > 0 && (
<span className="text-text-muted shrink-0 tabular-nums">
{t('detailAttachmentCount', { n: attachmentCount })}
</span>
)}
</div>
);
}

View File

@@ -1,106 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { FileText } from 'lucide-react';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
interface TreatmentLatestAttachmentPreviewProps {
attachment?: TreatmentAttachmentMeta | null;
className?: string;
}
function isImageMime(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
function isPdfMime(mimeType: string): boolean {
return mimeType === 'application/pdf';
}
export function TreatmentLatestAttachmentPreview({
attachment,
className = '',
}: TreatmentLatestAttachmentPreviewProps) {
const tCommon = useTranslations('common');
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [loading, setLoading] = useState(false);
const canRenderPreview = attachment
? isImageMime(attachment.mimeType) || isPdfMime(attachment.mimeType)
: false;
useEffect(() => {
if (!attachment || !canRenderPreview) {
setPreviewUrl(null);
setLoadFailed(false);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setLoadFailed(false);
setPreviewUrl(null);
void treatmentsApi
.getAttachmentFileBlob(attachment.id)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachment, canRenderPreview]);
return (
<div
className={`aspect-square w-[6rem] shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/50 ${className}`}
title={attachment?.fileName}
>
{!attachment ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
{tCommon('none')}
</div>
) : loading ? (
<div className="flex h-full w-full items-center justify-center text-[10px] text-text-muted">
{tCommon('loadingEllipsis')}
</div>
) : loadFailed || !canRenderPreview || !previewUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-1.5 text-center">
<FileText className="h-4 w-4 shrink-0 icon-flat text-text-muted" aria-hidden />
<span className="line-clamp-2 text-[9px] leading-tight text-text-secondary">
{attachment.fileName}
</span>
</div>
) : isImageMime(attachment.mimeType) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={attachment.fileName}
className="h-full w-full object-fill"
/>
) : (
<iframe
src={previewUrl}
title={attachment.fileName}
className="h-full w-full border-0"
/>
)}
</div>
);
}

View File

@@ -2,71 +2,61 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import type { PastTreatment } from '@/types/treatment';
const TREATMENT_TYPE_KEYS = {
consultation: 'typeConsultation',
filling: 'typeFilling',
endo: 'typeEndo',
visit: 'typeVisit',
hygiene: 'typeHygiene',
} as const;
import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow';
import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment';
interface TreatmentPreviewCardProps {
draft: PastTreatment | null;
disabled?: boolean;
onPreview: () => void;
treatment: PastTreatment | null;
labDependentCodes: Set<string>;
orgs?: LinkedOrganizationOption[];
openDisabled?: boolean;
onOpen: () => void;
}
export function TreatmentPreviewCard({ draft, disabled, onPreview }: TreatmentPreviewCardProps) {
export function TreatmentPreviewCard({
treatment,
labDependentCodes,
orgs,
openDisabled = false,
onOpen,
}: TreatmentPreviewCardProps) {
const t = useTranslations('treatment');
const attachmentCount = draft
? draft.details.reduce((n, c) => n + (c.attachmentMetas?.length ?? 0), 0)
: 0;
return (
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">{t('previewTitle')}</h3>
<Button type="button" variant="primary" disabled={disabled || !draft} onClick={onPreview}>
{t('previewDraft')}
<Button type="button" variant="primary" disabled={openDisabled || !treatment} onClick={onOpen}>
{t('openTreatment')}
</Button>
</div>
{!draft ? (
{!treatment ? (
<p className="text-sm text-text-muted">{t('selectAppointment')}</p>
) : (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{draft.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0 capitalize">{draft.status}</span>
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
<time
className="text-xs text-text-muted tabular-nums shrink-0"
dateTime={treatment.treatmentAt}
>
{new Date(treatment.treatmentAt).toLocaleDateString()}
</time>
</div>
<p className="text-xs text-text-secondary">
{t('caseCount', { n: draft.details.length })} ·{' '}
{t('attachmentCount', { n: attachmentCount })}
</p>
<div className="space-y-2">
{draft.details.slice(0, 2).map((c, idx) => {
const typeKey = TREATMENT_TYPE_KEYS[c.treatmentType as keyof typeof TREATMENT_TYPE_KEYS];
const typeLabel = typeKey ? t(typeKey) : c.treatmentType;
return (
<div
key={c.id}
className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2 text-xs text-text-secondary"
>
<span className="text-text-primary font-medium capitalize">
{t('caseSummary', { n: idx + 1, type: typeLabel })}
</span>
{c.teeth.length > 0 && (
<span className="ml-1 tabular-nums">
{t('teethPrefix')} {[...c.teeth].sort().join(', ')}
</span>
)}
</div>
);
})}
{draft.details.length > 2 && (
<p className="text-xs text-text-muted">{t('moreCases', { n: draft.details.length - 2 })}</p>
<div className="space-y-2 max-h-[min(280px,40vh)] overflow-y-auto pr-1">
{treatment.details.length === 0 ? (
<p className="text-xs text-text-muted">{t('noDetails')}</p>
) : (
treatment.details.map((detail, idx) => (
<TreatmentDetailSummaryRow
key={detail.clientId ?? detail.id}
detail={detail}
detailNumber={idx + 1}
labDependentCodes={labDependentCodes}
orgs={orgs}
compact
/>
))
)}
</div>
</div>

View File

@@ -1,180 +0,0 @@
'use client';
import { useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Loader2, Paperclip } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import type { LinkedOrganizationOption, PastTreatment, PastTreatmentCase } from '@/types/treatment';
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
import { TreatmentLatestAttachmentPreview } from '@/components/ui/treatment/TreatmentLatestAttachmentPreview';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
export type TreatmentPreviewMode = 'readonly' | 'editable';
interface TreatmentPreviewDialogProps {
open: boolean;
onClose: () => void;
treatment: PastTreatment | null;
mode: TreatmentPreviewMode;
orgs?: LinkedOrganizationOption[];
uploadBusyCaseId?: string | null;
onAttach?: (caseKey: string, files: FileList) => void | Promise<void>;
}
function caseKey(c: PastTreatmentCase): string {
return c.clientId ?? c.id;
}
const caseActionIconClass =
'inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1.5 text-text-secondary transition-colors hover:bg-background-card/80 hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40';
export function TreatmentPreviewDialog({
open,
onClose,
treatment,
mode,
orgs = [],
uploadBusyCaseId,
onAttach,
}: TreatmentPreviewDialogProps) {
const t = useTranslations('treatment');
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
if (!open || !treatment) return null;
const editable = mode === 'editable';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-[min(56rem,calc(100vw-15rem))] max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="treatment-preview-title"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 id="treatment-preview-title" className="text-lg font-semibold text-text-primary pr-2">
{t('previewDialogTitle')}
</h2>
<p className="text-xs text-text-muted mt-0.5">{t('previewDialogSubtitlePhase4')}</p>
</div>
<DialogCloseButton onClick={onClose} />
</div>
<div className="border border-border/70 rounded-[var(--radius-md)] p-4 bg-background-secondary/40 space-y-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{treatment.title}</p>
<span className="text-xs text-text-muted tabular-nums shrink-0">
{new Date(treatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">
{t('statusLabel')} {treatment.status}
</p>
{editable && (
<p className="text-xs text-text-muted">{t('previewLabDispatchHint')}</p>
)}
{treatment.details.length === 0 ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>
) : (
<div className="space-y-2">
{treatment.details.map((c, idx) => {
const key = caseKey(c);
const attachments = c.attachmentMetas ?? [];
const latestAttachment =
attachments.length > 0 ? attachments[attachments.length - 1] : null;
const sent = Boolean(c.sentAt);
const actionsEnabled = editable && !sent;
const comment = c.notes?.trim() ?? '';
const attachBusy = uploadBusyCaseId === key;
const typeKey = treatmentTypeLabelKey(c.treatmentType);
const typeLabel = t(typeKey as 'typeConsultation');
return (
<div
key={key}
className="rounded-[var(--radius-md)] border border-border/60 px-3 py-2 bg-background-secondary/30"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-text-primary">
{t('detailLabel', { n: idx + 1 })}
</p>
{actionsEnabled && onAttach && (
<div className="flex items-center gap-0.5">
<input
ref={(el) => {
fileInputsRef.current[key] = el;
}}
type="file"
multiple
className="sr-only"
aria-hidden
onChange={(e) => {
if (e.target.files?.length) {
void onAttach(key, e.target.files);
}
e.target.value = '';
}}
/>
<button
type="button"
className={caseActionIconClass}
disabled={attachBusy}
aria-label={t('attachFilesShort')}
title={t('attachFilesShort')}
onClick={() => fileInputsRef.current[key]?.click()}
>
{attachBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Paperclip className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</div>
)}
</div>
<p className="text-[11px] text-text-secondary capitalize">
{t('typeLabel')} {typeLabel}
</p>
<p className="text-[11px] text-text-secondary">
{t('teethLabel')}{' '}
{c.teeth.length ? [...c.teeth].sort().join(', ') : t('teethNone')}
</p>
{comment ? (
<p className="text-[11px] text-text-muted line-clamp-2" title={comment}>
{t('commentsLabel')} {comment}
</p>
) : (
<p className="text-text-muted text-[11px]">{t('commentsEmpty')}</p>
)}
</div>
<div className="flex min-w-[6rem] flex-col items-end gap-1">
{sent && (
<CaseSentLabel
treatmentCase={c}
orgs={orgs}
className="text-[10px] text-text-muted text-right"
/>
)}
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{t('attachments')}
</p>
<TreatmentLatestAttachmentPreview attachment={latestAttachment} />
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { TREATMENT_TYPE_KEYS, treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
interface TreatmentTypeBadgeProps {
type: string;
className?: string;
}
export function TreatmentTypeBadge({ type, className = '' }: TreatmentTypeBadgeProps) {
const t = useTranslations('treatment');
const typeKey = treatmentTypeLabelKey(type);
const label =
type in TREATMENT_TYPE_KEYS ? t(typeKey as 'typeEndo') : type;
return (
<span
className={`inline-flex items-center justify-center box-border rounded-md border min-h-[1.75rem] px-2.5 py-1 text-xs font-medium capitalize leading-none shrink-0 ${purposeStyle(type)} ${className}`.trim()}
>
{label}
</span>
);
}

View File

@@ -8,10 +8,6 @@ import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatc
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
import { TreatmentPreviewCard } from '@/components/ui/treatment/TreatmentPreviewCard';
import {
TreatmentPreviewDialog,
type TreatmentPreviewMode,
} from '@/components/ui/treatment/TreatmentPreviewDialog';
import { ToastStack } from '@/components/ui/shared/Toast';
import { treatmentTypeLabelKey } from '@/components/ui/treatment/treatmentTypeDisplay';
import {
@@ -40,6 +36,57 @@ import type {
TreatmentDetailDraft,
} from '@/types/treatment';
type WorkspaceMode = 'live' | 'historical';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
}
function labCaseDraftsToPast(
labCaseDrafts: LabCaseDraft[],
details: TreatmentDetailDraft[],
): PastLabCase[] {
return labCaseDrafts.map((lc) => ({
id: lc.id ?? lc.clientId,
clientId: lc.clientId,
destinationOrganizationId: lc.destinationOrganizationId,
labComment: lc.labComment || null,
sentAt: lc.sentAt ?? null,
treatmentDetailIds: lc.detailClientIds
.map((cid) => details.find((d) => d.clientId === cid)?.id)
.filter((id): id is string => Boolean(id)),
details: lc.detailClientIds.map((cid) => {
const d = details.find((x) => x.clientId === cid);
return {
id: d?.id ?? cid,
clientId: cid,
treatmentType: d?.treatmentType ?? 'consultation',
teeth: d?.teeth ?? [],
};
}),
sends: lc.sends ?? [],
}));
}
function buildWorkspaceSnapshot(
appointment: TreatmentAppointment,
details: TreatmentDetailDraft[],
labCaseDrafts: LabCaseDraft[],
title: string,
id?: string,
): PastTreatment {
return {
...detailsToPreviewTreatment(details, {
id: id ?? `preview-${appointment.id}`,
title,
patientId: appointment.patientId,
treatmentAt: appointment.startAt,
}),
appointmentId: appointment.id,
labCases: labCaseDraftsToPast(labCaseDrafts, details),
};
}
function newDetail(): TreatmentDetailDraft {
return {
clientId:
@@ -134,14 +181,13 @@ function isDetailsDirty(
function detailsToPreviewTreatment(
details: TreatmentDetailDraft[],
meta: { title: string; patientId: string; treatmentAt: string; status: string; id?: string },
meta: { title: string; patientId: string; treatmentAt: string; id?: string },
): PastTreatment {
return {
id: meta.id ?? 'current-draft',
patientId: meta.patientId,
title: meta.title,
treatmentAt: meta.treatmentAt,
status: meta.status,
details: details.map((d, idx) => ({
id: d.id ?? d.clientId ?? `draft-${idx + 1}`,
clientId: d.clientId,
@@ -181,6 +227,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [history, setHistory] = useState<PastTreatment[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPatientId, setHistoryPatientId] = useState<string | null>(null);
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [labDependentCodes, setLabDependentCodes] = useState<Set<string>>(new Set());
@@ -191,6 +238,8 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const [activeLabCaseId, setActiveLabCaseId] = useState<string | null>(null);
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle');
const [selectedPreviewId, setSelectedPreviewId] = useState<string | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('live');
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
@@ -203,16 +252,17 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const saveInFlightRef = useRef(false);
const saveQueuedRef = useRef(false);
const draftHydratingRef = useRef(false);
const workspaceModeRef = useRef(workspaceMode);
workspaceModeRef.current = workspaceMode;
const labCaseDraftsRef = useRef(labCaseDrafts);
labCaseDraftsRef.current = labCaseDrafts;
const skipNextGetDraftRef = useRef(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewTreatment, setPreviewTreatment] = useState<PastTreatment | null>(null);
const [previewMode, setPreviewMode] = useState<TreatmentPreviewMode>('readonly');
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientIds.includes(detail.clientId)),
@@ -236,7 +286,66 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
[selectedDay, todayStart],
);
const canEditTreatmentForDay = canEdit && Boolean(selectedAppointment) && !isViewingPastDay;
const canEditTreatmentForDay =
canEdit &&
Boolean(selectedAppointment) &&
!isViewingPastDay &&
workspaceMode === 'live';
const historyPanelItems = useMemo(() => {
return history.filter((item) => {
if (
workspaceMode === 'live' &&
selectedAppointmentId &&
item.appointmentId === selectedAppointmentId
) {
return false;
}
return true;
});
}, [history, selectedAppointmentId, workspaceMode]);
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
return buildWorkspaceSnapshot(
selectedAppointment,
details,
labCaseDrafts,
t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
'current-draft',
);
}, [details, labCaseDrafts, selectedAppointment, t]);
const previewTreatment = useMemo(() => {
if (!selectedPreviewId) return currentDraftPreview;
return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview;
}, [selectedPreviewId, historyPanelItems, currentDraftPreview]);
const isPreviewAlreadyOpen = useMemo(() => {
if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false;
if (selectedAppointmentId !== previewTreatment.appointmentId) return false;
if (workspaceMode === 'historical') return true;
if (workspaceMode === 'live' && selectedPreviewId === null) return true;
if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true;
return false;
}, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const mapped = treatment.details.map(mapDetailFromApi);
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
});
setSavedSnapshot(serializeDetails(mapped));
const mappedLabCases = (treatment.labCases ?? []).map(mapLabCaseDraftFromApi);
setLabCaseDrafts(mappedLabCases);
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
const activeDetail = useMemo(
() => details.find((d) => d.clientId === activeDetailId) ?? details[0],
@@ -245,18 +354,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
return detailsToPreviewTreatment(details, {
title: t('draftTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: new Date().toISOString(),
status: 'draft',
});
}, [details, selectedAppointment, t]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
@@ -332,15 +429,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}, [showError, t]);
useEffect(() => {
if (!selectedAppointment) {
setHistory([]);
return;
if (selectedAppointment?.patientId) {
setHistoryPatientId(selectedAppointment.patientId);
}
}, [selectedAppointment?.patientId]);
useEffect(() => {
if (!historyPatientId) return;
let cancelled = false;
setHistoryLoading(true);
void (async () => {
try {
const response = await treatmentsApi.listPatientHistory(selectedAppointment.patientId);
const response = await treatmentsApi.listPatientHistory(historyPatientId);
if (!cancelled) setHistory(response.data);
} catch (error: unknown) {
if (!cancelled) {
@@ -353,11 +453,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
return () => {
cancelled = true;
};
}, [selectedAppointment?.patientId, showError, t]);
}, [historyPatientId, showError, t]);
useEffect(() => {
const appointmentId = selectedAppointment?.id;
if (!appointmentId) return;
if (!appointmentId || workspaceMode !== 'live') return;
if (skipNextGetDraftRef.current) {
skipNextGetDraftRef.current = false;
return;
}
let cancelled = false;
draftHydratingRef.current = true;
@@ -405,7 +510,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
cancelled = true;
draftHydratingRef.current = false;
};
}, [selectedAppointment?.id, showError, t]);
}, [selectedAppointment?.id, workspaceMode, showError, t]);
const persistDraft = useCallback(
async (options?: { force?: boolean }) => {
@@ -416,12 +521,11 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
if (!options?.force && !dirty) {
return detailsToPreviewTreatment(currentDetails, {
title: t('draftTitle', {
title: t('treatmentPlanTitle', {
patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
}),
patientId: selectedAppointment.patientId,
treatmentAt: selectedAppointment.startAt,
status: 'draft',
});
}
@@ -479,13 +583,24 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
}
}, [selectedAppointment, persistDraft, showError, t]);
const refreshHistory = useCallback(async (patientId: string) => {
try {
const response = await treatmentsApi.listPatientHistory(patientId);
setHistory(response.data);
} catch (error: unknown) {
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
}
}, [showError, t]);
const flushDraftSave = useCallback(async (): Promise<boolean> => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
if (!selectedAppointment || !canEditTreatmentForDay) return true;
if (workspaceModeRef.current !== 'live' || !selectedAppointment || !canEditTreatmentForDay) {
return true;
}
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -497,11 +612,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
try {
await runDraftSave();
if (historyPatientId) {
await refreshHistory(historyPatientId);
}
return true;
} catch {
return window.confirm(t('confirmDiscard'));
}
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]);
}, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]);
useEffect(() => {
if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) {
@@ -528,16 +646,22 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
};
}, [details, isDirty, canEditTreatmentForDay, selectedAppointment?.id, runDraftSave]);
const resetToLiveContext = useCallback(() => {
setWorkspaceMode('live');
setSelectedPreviewId(null);
}, []);
const onPickAppointment = useCallback(
(id: string) => {
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
resetToLiveContext();
setSelectionLocked(true);
setSelectedAppointmentId(id);
})();
},
[flushDraftSave],
[flushDraftSave, resetToLiveContext],
);
const onSelectDay = useCallback(
@@ -545,12 +669,56 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
const patientIdToRefresh = historyPatientId;
resetToLiveContext();
setSelectedDay(day);
if (patientIdToRefresh) {
await refreshHistory(patientIdToRefresh);
}
})();
},
[flushDraftSave],
[flushDraftSave, resetToLiveContext, historyPatientId, refreshHistory],
);
const handleSelectPreviewTreatment = useCallback((treatment: PastTreatment) => {
setSelectedPreviewId(treatment.id);
}, []);
const handleOpenTreatment = useCallback(() => {
void (async () => {
const treatment = previewTreatment;
if (!treatment?.appointmentId) {
showError(t('errorNoAppointmentForTreatment'));
return;
}
if (isPreviewAlreadyOpen) return;
const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true;
if (!ok) return;
const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart);
setWorkspaceMode(isHistorical ? 'historical' : 'live');
setSelectedPreviewId(treatment.id);
setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt)));
setSelectionLocked(true);
setSelectedAppointmentId(treatment.appointmentId);
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
hydrateFromTreatment(treatment);
draftHydratingRef.current = false;
})();
}, [
previewTreatment,
isPreviewAlreadyOpen,
flushDraftSave,
hydrateFromTreatment,
showError,
t,
todayStart,
]);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -685,17 +853,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
],
);
const openPreview = useCallback((treatment: PastTreatment, mode: TreatmentPreviewMode) => {
setPreviewTreatment(treatment);
setPreviewMode(mode);
setPreviewOpen(true);
}, []);
const openCurrentDraftPreview = useCallback(() => {
if (!currentDraftPreview) return;
openPreview(currentDraftPreview, canEditTreatmentForDay ? 'editable' : 'readonly');
}, [currentDraftPreview, canEditTreatmentForDay, openPreview]);
if (!canView) {
return (
<div className="surface-card p-6 max-w-xl">
@@ -727,7 +884,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
loading={apptsLoading}
/>
{isViewingPastDay && (
{workspaceMode === 'historical' && (
<p className="text-sm text-emerald-700 dark:text-emerald-400 rounded-[var(--radius-md)] border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
{t('historicalReadonlyNotice')}
</p>
)}
{isViewingPastDay && workspaceMode === 'live' && (
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
{t('pastDayNotice')}
</p>
@@ -757,15 +920,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
)}
<TreatmentPreviewCard
draft={currentDraftPreview}
disabled={!selectedAppointment}
onPreview={openCurrentDraftPreview}
treatment={previewTreatment}
labDependentCodes={labDependentCodes}
orgs={orgs}
openDisabled={isPreviewAlreadyOpen}
onOpen={handleOpenTreatment}
/>
<PastTreatmentsPanel
items={history}
items={historyPanelItems}
loading={historyLoading}
onReviewTreatment={(item) => openPreview(item, 'readonly')}
selectedPreviewId={selectedPreviewId}
onSelectTreatment={handleSelectPreviewTreatment}
/>
</div>
@@ -793,6 +959,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
@@ -802,7 +969,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
}}
onPreview={openCurrentDraftPreview}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/>
@@ -839,18 +1005,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
/>
</div>
</div>
<TreatmentPreviewDialog
open={previewOpen}
onClose={() => setPreviewOpen(false)}
treatment={
previewMode === 'editable' && currentDraftPreview ? currentDraftPreview : previewTreatment
}
mode={previewMode}
orgs={orgs}
uploadBusyCaseId={uploadBusyDetailId}
onAttach={(caseKey, files) => uploadForDetail(caseKey, files)}
/>
</div>
);
}

View File

@@ -0,0 +1,26 @@
export const labSentBadgeClass =
'inline-flex items-center rounded-[var(--radius-sm)] border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400';
export const labNotSentBadgeClass =
'inline-flex items-center rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400';
export const labSentBannerClass =
'text-xs rounded-[var(--radius-sm)] border border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 px-2 py-1.5';
export const labPendingBannerClass =
'text-xs rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1.5';
export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string {
switch (status) {
case 'dirty':
return 'text-amber-600 dark:text-amber-400';
case 'saving':
return 'text-text-muted animate-pulse';
case 'saved':
return 'text-emerald-600 dark:text-emerald-400';
case 'error':
return 'text-red-500';
default:
return 'text-text-muted';
}
}

View File

@@ -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;

View File

@@ -1,4 +1,9 @@
import { apiClient } from './client';
import type {
LabCaseDetail,
ListLabCasesParams,
PaginatedLabCases,
} from '@/types/cases';
export interface CounterpartSearchResultDto {
id: string;
@@ -130,4 +135,30 @@ export const organizationApi = {
const response = await apiClient.post('/organizations/invitations/accept', body);
return response.data;
},
listConnectionCases: async (
connectionId: string,
params: ListLabCasesParams = {},
): Promise<{
success: boolean;
data: PaginatedLabCases & { counterpart: { id: string; name: string } };
}> => {
const response = await apiClient.get(`/organizations/connections/${connectionId}/cases`, {
params,
});
return response.data;
},
getConnectionCase: async (
connectionId: string,
caseId: string,
): Promise<{
success: boolean;
data: LabCaseDetail & { counterpart: { id: string; name: string } };
}> => {
const response = await apiClient.get(
`/organizations/connections/${connectionId}/cases/${caseId}`,
);
return response.data;
},
};

View 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;
},
};

View File

@@ -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);

View File

@@ -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;
};
}

View File

@@ -108,7 +108,6 @@ export interface PastTreatment {
appointmentId?: string | null;
title: string;
treatmentAt: string;
status: string;
details: PastTreatmentDetail[];
labCases: PastLabCase[];
documents: TreatmentAttachmentMeta[];