Compare commits
3 Commits
94b97aa0fa
...
3e81c110a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e81c110a3 | |||
| 2a48946a51 | |||
| feb0b26ad0 |
@@ -0,0 +1,8 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "treatments_organizationId_status_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "treatments" DROP COLUMN "status";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TreatmentStatus";
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Add task priority, timestamps, and Tasks tab permissions
|
||||
|
||||
ALTER TABLE "lab_case_tasks" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 3;
|
||||
ALTER TABLE "lab_case_tasks" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
ALTER TABLE "lab_case_tasks" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE INDEX "lab_case_tasks_assigneeUserId_priority_createdAt_idx"
|
||||
ON "lab_case_tasks"("assigneeUserId", "priority", "createdAt");
|
||||
|
||||
INSERT INTO "features" ("id", "name", "description", "organizationTypeId")
|
||||
VALUES (gen_random_uuid(), 'Tasks', 'Lab task inbox', NULL)
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
|
||||
INSERT INTO "permissions" ("id", "name", "description", "featureId")
|
||||
SELECT gen_random_uuid(), v.name, NULL, f.id
|
||||
FROM (VALUES
|
||||
('TAB_TASKS_READ'),
|
||||
('TAB_TASKS_EDIT')
|
||||
) AS v(name)
|
||||
CROSS JOIN "features" f
|
||||
WHERE f.name = 'Tasks'
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Track when a task was assigned (for sorting and display)
|
||||
|
||||
ALTER TABLE "lab_case_tasks" ADD COLUMN "assignedAt" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX "lab_case_tasks_assignedAt_labCaseId_priority_idx"
|
||||
ON "lab_case_tasks"("assignedAt" DESC, "labCaseId" ASC, "priority" DESC);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
23
backend/src/modules/tasks/dto/tasks.dto.ts
Normal file
23
backend/src/modules/tasks/dto/tasks.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { LabTaskStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateLabTaskDto {
|
||||
@IsEnum(LabTaskStatus)
|
||||
status: LabTaskStatus;
|
||||
}
|
||||
|
||||
export class ListLabTasksDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 50;
|
||||
}
|
||||
32
backend/src/modules/tasks/tasks.controller.ts
Normal file
32
backend/src/modules/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@ApiTags('tasks')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@UseGuards(JwtAuthGuard, LabOrgGuard)
|
||||
@Controller('tasks')
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
|
||||
list(@Query() query: ListLabTasksDto, @Req() req) {
|
||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||
return this.tasksService.list(organizationId, req.user.id, query);
|
||||
}
|
||||
|
||||
@Patch(':taskId')
|
||||
@ApiOperation({ summary: 'Update task status' })
|
||||
updateStatus(
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: UpdateLabTaskDto,
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
|
||||
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/tasks/tasks.module.ts
Normal file
9
backend/src/modules/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
188
backend/src/modules/tasks/tasks.service.ts
Normal file
188
backend/src/modules/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
|
||||
|
||||
const taskListInclude = {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
labCase: {
|
||||
include: {
|
||||
treatment: {
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
patient: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.LabCaseTaskInclude;
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
|
||||
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.LabCaseTaskWhereInput = {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.labCaseTask.findMany({
|
||||
where,
|
||||
include: taskListInclude,
|
||||
orderBy: [
|
||||
{ assignedAt: { sort: 'desc', nulls: 'first' } },
|
||||
{ createdAt: 'desc' },
|
||||
{ labCaseId: 'asc' },
|
||||
{ priority: 'desc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.labCaseTask.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map((task) => this.mapTaskListItem(task)),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
taskId: string,
|
||||
dto: UpdateLabTaskDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const membership = await this.getMembership(actorUserId, labOrganizationId);
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
},
|
||||
include: taskListInclude,
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You can only update tasks assigned to you');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: dto.status },
|
||||
include: taskListInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTaskListItem(updated) };
|
||||
}
|
||||
|
||||
private mapTaskListItem(
|
||||
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||
) {
|
||||
return {
|
||||
id: task.id,
|
||||
labCaseId: task.labCaseId,
|
||||
tooth: task.tooth,
|
||||
treatmentType: task.treatmentType,
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignedAt: task.assignedAt?.toISOString() ?? null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
: null,
|
||||
clinic: task.labCase.treatment.organization,
|
||||
patient: {
|
||||
id: task.labCase.treatment.patient.id,
|
||||
firstName: task.labCase.treatment.patient.firstName,
|
||||
lastName: task.labCase.treatment.patient.lastName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async assertCanReadTasks(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to tasks');
|
||||
}
|
||||
|
||||
private async assertCanEditTasks(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_TASKS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot update tasks');
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
return this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "حساب کاربری",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const t = useTranslations('tasks');
|
||||
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||
const { showError, setError, messages: toastMessages } = useToast();
|
||||
|
||||
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
|
||||
const canView = canViewTasks(currentOrganization);
|
||||
const canEdit = canEditTasks(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: t('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
if (cancelled) return;
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canView, page, showError, setError]);
|
||||
|
||||
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
||||
if (!canEdit) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
setError('');
|
||||
try {
|
||||
await tasksApi.updateStatus(taskId, status);
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingTaskId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskDate(value: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function sortDateForTask(task: LabTaskListItem) {
|
||||
return task.assignedAt ?? task.createdAt;
|
||||
}
|
||||
|
||||
if (!isAuthReady) {
|
||||
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="surface-card p-6 max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
||||
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{isOwner ? t('subtitleOwner') : t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="surface-card min-h-[280px]">
|
||||
{loading && tasks.length === 0 ? (
|
||||
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="p-3 text-sm text-text-muted">
|
||||
{isOwner ? t('emptyListOwner') : t('emptyList')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task) => {
|
||||
const statusEditable =
|
||||
canEdit && (isOwner || task.assigneeUserId === user?.id);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary truncate">
|
||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
||||
{isOwner && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<Badge
|
||||
variant={task.assignee ? 'success' : 'danger'}
|
||||
fixedWidth={false}
|
||||
>
|
||||
{task.assignee
|
||||
? t('assignedTo', { name: task.assignee.name })
|
||||
: t('unassigned')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{statusEditable ? (
|
||||
<select
|
||||
value={task.status}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
||||
}
|
||||
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||
<Badge variant="default" fixedWidth={false}>
|
||||
{t('priorityLabel', { n: task.priority })}
|
||||
</Badge>
|
||||
<TreatmentTypeBadge type={task.treatmentType} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t('pageSummary', {
|
||||
page: pagination.page,
|
||||
totalPages: pagination.totalPages,
|
||||
total: pagination.total,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={page >= pagination.totalPages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToastStack {...toastMessages} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const DASHBOARD_ROUTES: DashboardRouteConfig[] = [
|
||||
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ', orgTypes: ['CLINIC'] },
|
||||
{ prefix: '/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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
3
frontend/src/components/ui/shared/formSelectStyles.ts
Normal file
3
frontend/src/components/ui/shared/formSelectStyles.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/** Shared native select styling — readable in light and dark themes */
|
||||
export const FORM_SELECT_CLASS =
|
||||
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||
43
frontend/src/components/ui/treatment/DetailLabSendBadge.tsx
Normal file
43
frontend/src/components/ui/treatment/DetailLabSendBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
Normal file
25
frontend/src/components/ui/treatment/TreatmentTypeBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
20
frontend/src/lib/api/tasks.ts
Normal file
20
frontend/src/lib/api/tasks.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { apiClient } from './client';
|
||||
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||
|
||||
export const tasksApi = {
|
||||
list: async (params: { page?: number; limit?: number } = {}): Promise<{
|
||||
success: boolean;
|
||||
data: PaginatedLabTasks;
|
||||
}> => {
|
||||
const response = await apiClient.get('/tasks', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStatus: async (
|
||||
taskId: string,
|
||||
status: LabTaskStatus,
|
||||
): Promise<{ success: boolean; data: LabTaskListItem }> => {
|
||||
const response = await apiClient.patch(`/tasks/${taskId}`, { status });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -247,6 +247,24 @@ body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface PastTreatment {
|
||||
appointmentId?: string | null;
|
||||
title: string;
|
||||
treatmentAt: string;
|
||||
status: string;
|
||||
details: PastTreatmentDetail[];
|
||||
labCases: PastLabCase[];
|
||||
documents: TreatmentAttachmentMeta[];
|
||||
|
||||
Reference in New Issue
Block a user