improvement: users can now comment on a case and it's details and have an option to make it visible for clinics too.
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
-- Migrate legacy single-string labComment into per-case comment rows, then drop the column.
|
||||||
|
|
||||||
|
INSERT INTO "lab_case_comments" (
|
||||||
|
"id",
|
||||||
|
"labCaseId",
|
||||||
|
"authorSide",
|
||||||
|
"body",
|
||||||
|
"visibleToClinic",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
lc."id",
|
||||||
|
'CLINIC'::"LabCaseCommentSide",
|
||||||
|
trim(lc."labComment"),
|
||||||
|
true,
|
||||||
|
COALESCE(lc."sentAt", NOW()),
|
||||||
|
NOW()
|
||||||
|
FROM "lab_cases" lc
|
||||||
|
WHERE lc."labComment" IS NOT NULL AND trim(lc."labComment") <> '';
|
||||||
|
|
||||||
|
ALTER TABLE "lab_cases" DROP COLUMN "labComment";
|
||||||
@@ -193,7 +193,6 @@ model LabCase {
|
|||||||
clientKey String?
|
clientKey String?
|
||||||
sortOrder Int
|
sortOrder Int
|
||||||
destinationOrganizationId String?
|
destinationOrganizationId String?
|
||||||
labComment String?
|
|
||||||
sentAt DateTime?
|
sentAt DateTime?
|
||||||
|
|
||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -447,7 +447,6 @@ export class CasesService {
|
|||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
labComment: lc.labComment,
|
|
||||||
clinic: lc.treatment.organization,
|
clinic: lc.treatment.organization,
|
||||||
patient: lc.treatment.patient,
|
patient: lc.treatment.patient,
|
||||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ export class LabCaseCommentsService {
|
|||||||
return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
|
return { success: true, data: this.mapComment(updated, LabCaseCommentSide.LAB) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Clinic side (connection access is validated by caller) ----------
|
// ---------- Clinic side (connection history) ----------
|
||||||
|
|
||||||
async listForClinic(caseId: string, clinicOrganizationId: string) {
|
async listForClinic(caseId: string, clinicOrganizationId: string) {
|
||||||
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
|
await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true });
|
||||||
const comments = await this.fetchComments(caseId, { visibleOnly: true });
|
const comments = await this.fetchCommentsForClinicViewer(caseId);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
|
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
|
||||||
@@ -91,7 +91,7 @@ export class LabCaseCommentsService {
|
|||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
dto: CreateLabCaseCommentDto,
|
dto: CreateLabCaseCommentDto,
|
||||||
) {
|
) {
|
||||||
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
|
await this.assertClinicOwnsCase(caseId, clinicOrganizationId, { requireSent: true });
|
||||||
const created = await this.prisma.labCaseComment.create({
|
const created = await this.prisma.labCaseComment.create({
|
||||||
data: {
|
data: {
|
||||||
labCaseId: caseId,
|
labCaseId: caseId,
|
||||||
@@ -99,7 +99,6 @@ export class LabCaseCommentsService {
|
|||||||
authorOrganizationId: clinicOrganizationId,
|
authorOrganizationId: clinicOrganizationId,
|
||||||
authorSide: LabCaseCommentSide.CLINIC,
|
authorSide: LabCaseCommentSide.CLINIC,
|
||||||
body: dto.body.trim(),
|
body: dto.body.trim(),
|
||||||
// Clinic-authored comments are inherently visible to the clinic.
|
|
||||||
visibleToClinic: true,
|
visibleToClinic: true,
|
||||||
},
|
},
|
||||||
include: commentInclude,
|
include: commentInclude,
|
||||||
@@ -107,8 +106,60 @@ export class LabCaseCommentsService {
|
|||||||
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Clinic side (treatment dispatch — unsent cases allowed) ----------
|
||||||
|
|
||||||
|
async listForClinicTreatmentCase(
|
||||||
|
caseId: string,
|
||||||
|
clinicOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId);
|
||||||
|
const comments = await this.fetchCommentsForClinicViewer(caseId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.CLINIC)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addForClinicTreatmentCase(
|
||||||
|
caseId: string,
|
||||||
|
clinicOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
dto: CreateLabCaseCommentDto,
|
||||||
|
) {
|
||||||
|
await this.assertClinicTreatmentAccess(caseId, clinicOrganizationId, actorUserId);
|
||||||
|
const created = await this.prisma.labCaseComment.create({
|
||||||
|
data: {
|
||||||
|
labCaseId: caseId,
|
||||||
|
authorUserId: actorUserId,
|
||||||
|
authorOrganizationId: clinicOrganizationId,
|
||||||
|
authorSide: LabCaseCommentSide.CLINIC,
|
||||||
|
body: dto.body.trim(),
|
||||||
|
visibleToClinic: true,
|
||||||
|
},
|
||||||
|
include: commentInclude,
|
||||||
|
});
|
||||||
|
return { success: true, data: this.mapComment(created, LabCaseCommentSide.CLINIC) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async countForCase(caseId: string) {
|
||||||
|
const count = await this.prisma.labCaseComment.count({ where: { labCaseId: caseId } });
|
||||||
|
return { success: true, data: { count } };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
private fetchCommentsForClinicViewer(caseId: string) {
|
||||||
|
return this.prisma.labCaseComment.findMany({
|
||||||
|
where: {
|
||||||
|
labCaseId: caseId,
|
||||||
|
OR: [{ visibleToClinic: true }, { authorSide: LabCaseCommentSide.CLINIC }],
|
||||||
|
},
|
||||||
|
include: commentInclude,
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
|
private fetchComments(caseId: string, opts?: { visibleOnly?: boolean }) {
|
||||||
return this.prisma.labCaseComment.findMany({
|
return this.prisma.labCaseComment.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -121,6 +172,7 @@ export class LabCaseCommentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
|
private mapComment(comment: CommentWithRelations, viewerSide: LabCaseCommentSide) {
|
||||||
|
const showVisibilityStatus = viewerSide === LabCaseCommentSide.LAB;
|
||||||
return {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
body: comment.body,
|
body: comment.body,
|
||||||
@@ -129,10 +181,10 @@ export class LabCaseCommentsService {
|
|||||||
authorOrganizationName: comment.authorOrganization?.name ?? null,
|
authorOrganizationName: comment.authorOrganization?.name ?? null,
|
||||||
visibleToClinic: comment.visibleToClinic,
|
visibleToClinic: comment.visibleToClinic,
|
||||||
createdAt: comment.createdAt.toISOString(),
|
createdAt: comment.createdAt.toISOString(),
|
||||||
// Only lab viewers can toggle visibility, and only on lab-authored comments.
|
|
||||||
canToggleVisibility:
|
canToggleVisibility:
|
||||||
viewerSide === LabCaseCommentSide.LAB &&
|
viewerSide === LabCaseCommentSide.LAB &&
|
||||||
comment.authorSide === LabCaseCommentSide.LAB,
|
comment.authorSide === LabCaseCommentSide.LAB,
|
||||||
|
showVisibilityStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +219,15 @@ export class LabCaseCommentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertClinicOwnsCase(caseId: string, clinicOrganizationId: string) {
|
private async assertClinicOwnsCase(
|
||||||
|
caseId: string,
|
||||||
|
clinicOrganizationId: string,
|
||||||
|
opts?: { requireSent?: boolean },
|
||||||
|
) {
|
||||||
const labCase = await this.prisma.labCase.findFirst({
|
const labCase = await this.prisma.labCase.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: caseId,
|
id: caseId,
|
||||||
sentAt: { not: null },
|
...(opts?.requireSent ? { sentAt: { not: null } } : {}),
|
||||||
treatment: { organizationId: clinicOrganizationId },
|
treatment: { organizationId: clinicOrganizationId },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -180,4 +236,25 @@ export class LabCaseCommentsService {
|
|||||||
throw new NotFoundException('Case not found');
|
throw new NotFoundException('Case not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertClinicTreatmentAccess(
|
||||||
|
caseId: string,
|
||||||
|
clinicOrganizationId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
) {
|
||||||
|
await this.assertClinicOwnsCase(caseId, clinicOrganizationId);
|
||||||
|
const membership = await this.prisma.membership.findFirst({
|
||||||
|
where: { userId: actorUserId, organizationId: clinicOrganizationId, isActive: true },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenException('You are not a member of this organization');
|
||||||
|
}
|
||||||
|
if (membership.isOwner) return;
|
||||||
|
const names = membership.permissions.map((p) => p.permission.name);
|
||||||
|
if (names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('You do not have access to treatment cases');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,11 +71,6 @@ export class SaveLabCaseDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationOrganizationId?: string;
|
destinationOrganizationId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(5000)
|
|
||||||
labComment?: string;
|
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@IsUUID(undefined, { each: true })
|
@IsUUID(undefined, { each: true })
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
SaveTreatmentDraftDto,
|
SaveTreatmentDraftDto,
|
||||||
SaveTreatmentLabCasesDto,
|
SaveTreatmentLabCasesDto,
|
||||||
} from './dto/treatment.dto';
|
} from './dto/treatment.dto';
|
||||||
|
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||||
|
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
|
|
||||||
@ApiTags('treatments')
|
@ApiTags('treatments')
|
||||||
@@ -30,7 +32,10 @@ import { TreatmentsService } from './treatments.service';
|
|||||||
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
@UseGuards(JwtAuthGuard, ClinicOrgGuard)
|
||||||
@Controller('treatments')
|
@Controller('treatments')
|
||||||
export class TreatmentsController {
|
export class TreatmentsController {
|
||||||
constructor(private readonly treatmentsService: TreatmentsService) {}
|
constructor(
|
||||||
|
private readonly treatmentsService: TreatmentsService,
|
||||||
|
private readonly commentsService: LabCaseCommentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('linked-organizations')
|
@Get('linked-organizations')
|
||||||
@ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' })
|
@ApiOperation({ summary: 'List active linked counterpart organizations (TAB_TREATMENT_READ)' })
|
||||||
@@ -194,4 +199,34 @@ export class TreatmentsController {
|
|||||||
req.user.language,
|
req.user.language,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('lab-cases/:labCaseId/comments')
|
||||||
|
@ApiOperation({ summary: 'List comments for a lab case during treatment dispatch' })
|
||||||
|
listLabCaseComments(
|
||||||
|
@Param('labCaseId') labCaseId: string,
|
||||||
|
@Req() req: { user: { id: string; organizationId?: string } },
|
||||||
|
) {
|
||||||
|
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.commentsService.listForClinicTreatmentCase(
|
||||||
|
labCaseId,
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lab-cases/:labCaseId/comments')
|
||||||
|
@ApiOperation({ summary: 'Add a comment to a lab case during treatment dispatch' })
|
||||||
|
addLabCaseComment(
|
||||||
|
@Param('labCaseId') labCaseId: string,
|
||||||
|
@Body() dto: CreateLabCaseCommentDto,
|
||||||
|
@Req() req: { user: { id: string; organizationId?: string } },
|
||||||
|
) {
|
||||||
|
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.commentsService.addForClinicTreatmentCase(
|
||||||
|
labCaseId,
|
||||||
|
organizationId,
|
||||||
|
req.user.id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
import { ClinicOrgGuard } from '../../common/guards/clinic-org.guard';
|
||||||
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module';
|
||||||
|
import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module';
|
||||||
import { TreatmentsController } from './treatments.controller';
|
import { TreatmentsController } from './treatments.controller';
|
||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ProsthesisCatalogModule],
|
imports: [ProsthesisCatalogModule, LabCaseCommentsModule],
|
||||||
controllers: [TreatmentsController],
|
controllers: [TreatmentsController],
|
||||||
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
providers: [TreatmentsService, PrismaService, ClinicOrgGuard],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -397,7 +397,6 @@ export class TreatmentsService {
|
|||||||
clientKey: lc.clientId,
|
clientKey: lc.clientId,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
labComment: lc.labComment?.trim() || null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: await tx.labCase.create({
|
: await tx.labCase.create({
|
||||||
@@ -406,7 +405,6 @@ export class TreatmentsService {
|
|||||||
clientKey: lc.clientId,
|
clientKey: lc.clientId,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
labComment: lc.labComment?.trim() || null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -675,7 +673,6 @@ export class TreatmentsService {
|
|||||||
clientKey: string | null;
|
clientKey: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
destinationOrganizationId: string | null;
|
destinationOrganizationId: string | null;
|
||||||
labComment: string | null;
|
|
||||||
sentAt: Date | null;
|
sentAt: Date | null;
|
||||||
details: Array<{
|
details: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
@@ -754,7 +751,6 @@ export class TreatmentsService {
|
|||||||
clientKey?: string | null;
|
clientKey?: string | null;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
destinationOrganizationId?: string | null;
|
destinationOrganizationId?: string | null;
|
||||||
labComment?: string | null;
|
|
||||||
sentAt?: Date | null;
|
sentAt?: Date | null;
|
||||||
details?: Array<{
|
details?: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
@@ -775,7 +771,6 @@ export class TreatmentsService {
|
|||||||
id: lc.id,
|
id: lc.id,
|
||||||
clientId: lc.clientKey ?? lc.id,
|
clientId: lc.clientKey ?? lc.id,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||||
labComment: lc.labComment ?? null,
|
|
||||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||||
treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [],
|
treatmentDetailIds: lc.details?.map((d) => d.treatmentDetailId) ?? [],
|
||||||
details: (lc.details ?? []).map((d) => ({
|
details: (lc.details ?? []).map((d) => ({
|
||||||
|
|||||||
@@ -351,7 +351,8 @@
|
|||||||
"filterSentTo": "Sent to",
|
"filterSentTo": "Sent to",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"patientMobile": "Mobile",
|
"patientMobile": "Mobile",
|
||||||
"labComment": "Lab comment",
|
"showComments": "Comments",
|
||||||
|
"commentsCount": "Comments ({count})",
|
||||||
"prevPage": "Previous",
|
"prevPage": "Previous",
|
||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
"pageSummary": "Page {page} of {totalPages} ({total} cases)",
|
||||||
@@ -389,6 +390,7 @@
|
|||||||
"sortClinic": "Clinic",
|
"sortClinic": "Clinic",
|
||||||
"sortPatient": "Patient",
|
"sortPatient": "Patient",
|
||||||
"sortImportant": "Important",
|
"sortImportant": "Important",
|
||||||
|
"sortDirection": "Sort direction",
|
||||||
"clearFilters": "Clear filters",
|
"clearFilters": "Clear filters",
|
||||||
"commentsButton": "Comments",
|
"commentsButton": "Comments",
|
||||||
"errorLoadList": "Failed to load tasks.",
|
"errorLoadList": "Failed to load tasks.",
|
||||||
@@ -540,8 +542,6 @@
|
|||||||
"prosthesisColTooth": "Tooth",
|
"prosthesisColTooth": "Tooth",
|
||||||
"prosthesisColDetail": "Detail",
|
"prosthesisColDetail": "Detail",
|
||||||
"prosthesisColType": "Prosthesis type",
|
"prosthesisColType": "Prosthesis type",
|
||||||
"labComment": "Message for the lab",
|
|
||||||
"labCommentPlaceholder": "Optional instructions for this shipment…",
|
|
||||||
"selectLab": "Destination lab",
|
"selectLab": "Destination lab",
|
||||||
"selectLabPlaceholder": "Choose a linked lab…",
|
"selectLabPlaceholder": "Choose a linked lab…",
|
||||||
"sendToLab": "Send to lab",
|
"sendToLab": "Send to lab",
|
||||||
|
|||||||
@@ -351,7 +351,8 @@
|
|||||||
"filterSentTo": "ارسال تا",
|
"filterSentTo": "ارسال تا",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
"patientMobile": "موبایل",
|
"patientMobile": "موبایل",
|
||||||
"labComment": "یادداشت آزمایشگاه",
|
"showComments": "نظرات",
|
||||||
|
"commentsCount": "نظرات ({count})",
|
||||||
"prevPage": "قبلی",
|
"prevPage": "قبلی",
|
||||||
"nextPage": "بعدی",
|
"nextPage": "بعدی",
|
||||||
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
"pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)",
|
||||||
@@ -389,6 +390,7 @@
|
|||||||
"sortClinic": "کلینیک",
|
"sortClinic": "کلینیک",
|
||||||
"sortPatient": "بیمار",
|
"sortPatient": "بیمار",
|
||||||
"sortImportant": "مهم",
|
"sortImportant": "مهم",
|
||||||
|
"sortDirection": "جهت مرتبسازی",
|
||||||
"clearFilters": "پاک کردن فیلترها",
|
"clearFilters": "پاک کردن فیلترها",
|
||||||
"commentsButton": "نظرات",
|
"commentsButton": "نظرات",
|
||||||
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
"errorLoadList": "بارگذاری وظایف ناموفق بود.",
|
||||||
@@ -540,8 +542,6 @@
|
|||||||
"prosthesisColTooth": "دندان",
|
"prosthesisColTooth": "دندان",
|
||||||
"prosthesisColDetail": "جزئیات",
|
"prosthesisColDetail": "جزئیات",
|
||||||
"prosthesisColType": "نوع پروتز",
|
"prosthesisColType": "نوع پروتز",
|
||||||
"labComment": "پیام برای لابراتوار",
|
|
||||||
"labCommentPlaceholder": "دستورالعمل اختیاری برای این محموله…",
|
|
||||||
"selectLab": "لابراتوار مقصد",
|
"selectLab": "لابراتوار مقصد",
|
||||||
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
|
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
|
||||||
"sendToLab": "ارسال به لابراتوار",
|
"sendToLab": "ارسال به لابراتوار",
|
||||||
|
|||||||
@@ -351,7 +351,8 @@
|
|||||||
"filterSentTo": "Verzonden tot",
|
"filterSentTo": "Verzonden tot",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
"patientMobile": "Mobiel",
|
"patientMobile": "Mobiel",
|
||||||
"labComment": "Labnotitie",
|
"showComments": "Opmerkingen",
|
||||||
|
"commentsCount": "Opmerkingen ({count})",
|
||||||
"prevPage": "Vorige",
|
"prevPage": "Vorige",
|
||||||
"nextPage": "Volgende",
|
"nextPage": "Volgende",
|
||||||
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
"pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)",
|
||||||
@@ -389,6 +390,7 @@
|
|||||||
"sortClinic": "Kliniek",
|
"sortClinic": "Kliniek",
|
||||||
"sortPatient": "Patiënt",
|
"sortPatient": "Patiënt",
|
||||||
"sortImportant": "Belangrijk",
|
"sortImportant": "Belangrijk",
|
||||||
|
"sortDirection": "Sorteerrichting",
|
||||||
"clearFilters": "Filters wissen",
|
"clearFilters": "Filters wissen",
|
||||||
"commentsButton": "Opmerkingen",
|
"commentsButton": "Opmerkingen",
|
||||||
"errorLoadList": "Taken laden mislukt.",
|
"errorLoadList": "Taken laden mislukt.",
|
||||||
@@ -540,8 +542,6 @@
|
|||||||
"prosthesisColTooth": "Tand",
|
"prosthesisColTooth": "Tand",
|
||||||
"prosthesisColDetail": "Detail",
|
"prosthesisColDetail": "Detail",
|
||||||
"prosthesisColType": "Prothesetype",
|
"prosthesisColType": "Prothesetype",
|
||||||
"labComment": "Bericht voor het lab",
|
|
||||||
"labCommentPlaceholder": "Optionele instructies voor deze zending…",
|
|
||||||
"selectLab": "Bestemmingslab",
|
"selectLab": "Bestemmingslab",
|
||||||
"selectLabPlaceholder": "Kies een gekoppeld lab…",
|
"selectLabPlaceholder": "Kies een gekoppeld lab…",
|
||||||
"sendToLab": "Versturen naar lab",
|
"sendToLab": "Versturen naar lab",
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { canEditCases } from '@/components/shared/permissions';
|
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
|
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
@@ -30,17 +34,6 @@ import {
|
|||||||
|
|
||||||
const PAGE_SIZE = 20;
|
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 }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
}
|
}
|
||||||
@@ -105,8 +98,10 @@ export default function CasesPage() {
|
|||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
|
const [commentCount, setCommentCount] = useState(0);
|
||||||
|
|
||||||
const canEdit = canEditCases(currentOrganization);
|
const canEdit = canEditCases(currentOrganization);
|
||||||
|
const canEditComments = canEditTasks(currentOrganization);
|
||||||
const locale = user?.language ?? 'en';
|
const locale = user?.language ?? 'en';
|
||||||
|
|
||||||
const treatmentLabel = useCallback(
|
const treatmentLabel = useCallback(
|
||||||
@@ -200,12 +195,21 @@ export default function CasesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCaseId) {
|
if (selectedCaseId) {
|
||||||
void loadDetail(selectedCaseId);
|
void loadDetail(selectedCaseId);
|
||||||
|
void tasksApi
|
||||||
|
.listComments(selectedCaseId)
|
||||||
|
.then((r) => setCommentCount(r.data.length))
|
||||||
|
.catch(() => setCommentCount(0));
|
||||||
} else {
|
} else {
|
||||||
setSelectedCase(null);
|
setSelectedCase(null);
|
||||||
|
setCommentCount(0);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload when selection changes
|
||||||
}, [selectedCaseId]);
|
}, [selectedCaseId]);
|
||||||
|
|
||||||
|
function scrollToComments() {
|
||||||
|
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setClinicId('');
|
setClinicId('');
|
||||||
@@ -408,9 +412,17 @@ export default function CasesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1 border-b border-border pb-3">
|
<header className="space-y-1 border-b border-border pb-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{formatPatientName(selectedCase.patient)}
|
{formatPatientName(selectedCase.patient)}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
||||||
|
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||||
|
{commentCount > 0
|
||||||
|
? t('commentsCount', { count: commentCount })
|
||||||
|
: t('showComments')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{t('patientMobile')}: {selectedCase.patient.mobile}
|
{t('patientMobile')}: {selectedCase.patient.mobile}
|
||||||
</p>
|
</p>
|
||||||
@@ -432,12 +444,6 @@ export default function CasesPage() {
|
|||||||
total={selectedCase.taskProgress.total}
|
total={selectedCase.taskProgress.total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedCase.labComment ? (
|
|
||||||
<p className="text-sm text-text-muted pt-1">
|
|
||||||
<span className="font-medium text-text-primary">{t('labComment')}:</span>{' '}
|
|
||||||
{selectedCase.labComment}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{selectedCase.details.length > 0 && (
|
{selectedCase.details.length > 0 && (
|
||||||
@@ -493,7 +499,7 @@ export default function CasesPage() {
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
task.status}
|
task.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -530,6 +536,34 @@ export default function CasesPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedCaseId ? (
|
||||||
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
|
<LabCaseCommentsPanel
|
||||||
|
caseId={selectedCaseId}
|
||||||
|
canPost={canEditComments}
|
||||||
|
canToggleVisibility={canEditComments}
|
||||||
|
loadComments={async () => {
|
||||||
|
const r = await tasksApi.listComments(selectedCaseId);
|
||||||
|
setCommentCount(r.data.length);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onPost={async (body, visibleToClinic) => {
|
||||||
|
const r = await tasksApi.addComment(selectedCaseId, {
|
||||||
|
body,
|
||||||
|
visibleToClinic,
|
||||||
|
});
|
||||||
|
setCommentCount((n) => n + 1);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onToggleVisibility={async (commentId, visible) => {
|
||||||
|
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onError={toast.showError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
|
import {
|
||||||
|
labTaskStatusSelectClass,
|
||||||
|
labTaskStatusVariant,
|
||||||
|
} from '@/components/ui/lab/labTaskStatusDisplay';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyle,
|
prosthesisTypeBadgeStyle,
|
||||||
@@ -19,8 +22,6 @@ import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
|||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { tasksApi } from '@/lib/api/tasks';
|
import { tasksApi } from '@/lib/api/tasks';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
|
||||||
import type {
|
import type {
|
||||||
LabTaskListItem,
|
LabTaskListItem,
|
||||||
LabTaskStatus,
|
LabTaskStatus,
|
||||||
@@ -28,14 +29,9 @@ import type {
|
|||||||
PaginatedLabTasks,
|
PaginatedLabTasks,
|
||||||
TaskSortField,
|
TaskSortField,
|
||||||
} from '@/types/cases';
|
} from '@/types/cases';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
|
||||||
return status === 'COMPLETED' ? 'success' : 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
}
|
}
|
||||||
@@ -55,7 +51,6 @@ export default function TasksPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
|
||||||
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
|
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -127,10 +122,6 @@ export default function TasksPage() {
|
|||||||
}
|
}
|
||||||
}, [listParams, showError, setError]);
|
}, [listParams, showError, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canView) return;
|
if (!canView) return;
|
||||||
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
||||||
@@ -191,7 +182,7 @@ export default function TasksPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -230,10 +221,11 @@ export default function TasksPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
|
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
|
||||||
className={filterSelectClass}
|
className={`${filterSelectClass} min-w-0 flex-1`}
|
||||||
>
|
>
|
||||||
<option value="date">{t('sortDate')}</option>
|
<option value="date">{t('sortDate')}</option>
|
||||||
<option value="status">{t('sortStatus')}</option>
|
<option value="status">{t('sortStatus')}</option>
|
||||||
@@ -241,17 +233,16 @@ export default function TasksPage() {
|
|||||||
<option value="patient">{t('sortPatient')}</option>
|
<option value="patient">{t('sortPatient')}</option>
|
||||||
<option value="important">{t('sortImportant')}</option>
|
<option value="important">{t('sortImportant')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-xs text-text-muted"> </span>
|
|
||||||
<select
|
<select
|
||||||
value={sortDir}
|
value={sortDir}
|
||||||
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
|
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
|
||||||
className={filterSelectClass}
|
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
|
||||||
|
aria-label={t('sortDirection')}
|
||||||
>
|
>
|
||||||
<option value="desc">↓</option>
|
<option value="desc">↓</option>
|
||||||
<option value="asc">↑</option>
|
<option value="asc">↑</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
@@ -303,12 +294,6 @@ export default function TasksPage() {
|
|||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
|
||||||
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border"
|
|
||||||
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
|
|
||||||
>
|
|
||||||
{task.prosthesisTypeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-text-secondary truncate">
|
<p className="text-[11px] text-text-secondary truncate">
|
||||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||||
@@ -336,7 +321,7 @@ export default function TasksPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
void handleStatusUpdate(task.id, e.target.value as LabTaskStatus)
|
||||||
}
|
}
|
||||||
className={`${FORM_SELECT_CLASS} w-full max-w-[132px]`}
|
className={`${FORM_SELECT_CLASS} w-full max-w-[132px] ${labTaskStatusSelectClass(task.status)}`}
|
||||||
>
|
>
|
||||||
{statusOptions.map((opt) => (
|
{statusOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
@@ -345,7 +330,7 @@ export default function TasksPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
task.status}
|
task.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -369,10 +354,12 @@ export default function TasksPage() {
|
|||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<TreatmentTypeBadge
|
<span
|
||||||
type={task.treatmentType}
|
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
|
||||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
|
||||||
/>
|
>
|
||||||
|
{task.prosthesisTypeLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,13 @@ export function LabCaseCommentsPanel({
|
|||||||
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
||||||
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{comment.visibleToClinic ? (
|
{comment.showVisibilityStatus !== false ? (
|
||||||
|
comment.visibleToClinic ? (
|
||||||
<span className="text-primary">{t('clinicCanSee')}</span>
|
<span className="text-primary">{t('clinicCanSee')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{t('hiddenFromClinic')}</span>
|
<span>{t('hiddenFromClinic')}</span>
|
||||||
)}
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
frontend/src/components/ui/lab/labTaskStatusDisplay.ts
Normal file
17
frontend/src/components/ui/lab/labTaskStatusDisplay.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
||||||
|
import type { LabTaskStatus } from '@/types/cases';
|
||||||
|
|
||||||
|
export function labTaskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||||
|
return status === 'COMPLETED' ? 'success' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labTaskStatusSelectClass(status: LabTaskStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'border-success/60 text-success';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'border-primary/60 text-primary';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
|
import { labTaskStatusVariant } from '@/components/ui/lab/labTaskStatusDisplay';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyle,
|
prosthesisTypeBadgeStyle,
|
||||||
@@ -23,17 +25,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|||||||
|
|
||||||
const PAGE_SIZE = 20;
|
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 }) {
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||||
}
|
}
|
||||||
@@ -96,6 +87,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||||
const [loadingList, setLoadingList] = useState(false);
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
const [commentCount, setCommentCount] = useState(0);
|
||||||
|
|
||||||
const locale = user?.language ?? 'en';
|
const locale = user?.language ?? 'en';
|
||||||
const isClinic = currentOrganization?.type === 'CLINIC';
|
const isClinic = currentOrganization?.type === 'CLINIC';
|
||||||
@@ -154,11 +146,21 @@ export function ConnectionCaseHistoryContent({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCaseId) {
|
if (!selectedCaseId) {
|
||||||
setSelectedCase(null);
|
setSelectedCase(null);
|
||||||
|
setCommentCount(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
void organizationApi
|
||||||
|
.listConnectionCaseComments(connection.id, selectedCaseId)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setCommentCount(r.data.length);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCommentCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoadingDetail(true);
|
setLoadingDetail(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -180,6 +182,10 @@ export function ConnectionCaseHistoryContent({
|
|||||||
};
|
};
|
||||||
}, [selectedCaseId, connection.id, showError, setError]);
|
}, [selectedCaseId, connection.id, showError, setError]);
|
||||||
|
|
||||||
|
function scrollToComments() {
|
||||||
|
document.getElementById('case-comments')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -300,9 +306,19 @@ export function ConnectionCaseHistoryContent({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1 border-b border-border pb-3">
|
<header className="space-y-1 border-b border-border pb-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{formatPatientName(selectedCase.patient)}
|
{formatPatientName(selectedCase.patient)}
|
||||||
</h2>
|
</h2>
|
||||||
|
{isClinic ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={scrollToComments}>
|
||||||
|
<MessageSquare className="h-4 w-4 me-1.5" />
|
||||||
|
{commentCount > 0
|
||||||
|
? tCases('commentsCount', { count: commentCount })
|
||||||
|
: tCases('showComments')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
{tCases('patientMobile')}: {selectedCase.patient.mobile}
|
{tCases('patientMobile')}: {selectedCase.patient.mobile}
|
||||||
</p>
|
</p>
|
||||||
@@ -330,12 +346,6 @@ export function ConnectionCaseHistoryContent({
|
|||||||
total={selectedCase.taskProgress.total}
|
total={selectedCase.taskProgress.total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{selectedCase.details.length > 0 && (
|
{selectedCase.details.length > 0 && (
|
||||||
@@ -395,7 +405,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
{task.stepOrder}. {task.stepLabel}
|
{task.stepOrder}. {task.stepLabel}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||||
task.status}
|
task.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -413,6 +423,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isClinic && selectedCaseId ? (
|
{isClinic && selectedCaseId ? (
|
||||||
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={selectedCaseId}
|
caseId={selectedCaseId}
|
||||||
canPost
|
canPost
|
||||||
@@ -422,6 +433,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
connection.id,
|
connection.id,
|
||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
);
|
);
|
||||||
|
setCommentCount(r.data.length);
|
||||||
return r.data;
|
return r.data;
|
||||||
}}
|
}}
|
||||||
onPost={async (body) => {
|
onPost={async (body) => {
|
||||||
@@ -430,10 +442,12 @@ export function ConnectionCaseHistoryContent({
|
|||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
setCommentCount((n) => n + 1);
|
||||||
return r.data;
|
return r.data;
|
||||||
}}
|
}}
|
||||||
onError={showError}
|
onError={showError}
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { formatCaseSentSummary } from '@/components/treatment/caseSendLabel';
|
|
||||||
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel';
|
||||||
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||||
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||||
|
|
||||||
interface LabCasesDispatchPanelProps {
|
interface LabCasesDispatchPanelProps {
|
||||||
details: TreatmentDetailDraft[];
|
details: TreatmentDetailDraft[];
|
||||||
|
activeDetailId: string;
|
||||||
labCases: LabCaseDraft[];
|
labCases: LabCaseDraft[];
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
@@ -32,6 +34,7 @@ interface LabCasesDispatchPanelProps {
|
|||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
onAddLabCase: () => void;
|
onAddLabCase: () => void;
|
||||||
onSendLabCase: (labCase: LabCaseDraft) => void;
|
onSendLabCase: (labCase: LabCaseDraft) => void;
|
||||||
|
onCommentError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
||||||
@@ -56,25 +59,6 @@ function detailInOtherDraftShipment(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsentLabDetails(
|
|
||||||
details: TreatmentDetailDraft[],
|
|
||||||
labCases: LabCaseDraft[],
|
|
||||||
labDependentCodes: Set<string>,
|
|
||||||
): TreatmentDetailDraft[] {
|
|
||||||
const sent = sentDetailClientIds(labCases);
|
|
||||||
return details.filter((d) => labDependentCodes.has(d.treatmentType) && !sent.has(d.clientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailsAvailableForNewShipment(
|
|
||||||
details: TreatmentDetailDraft[],
|
|
||||||
labCases: LabCaseDraft[],
|
|
||||||
labDependentCodes: Set<string>,
|
|
||||||
): TreatmentDetailDraft[] {
|
|
||||||
return unsentLabDetails(details, labCases, labDependentCodes).filter(
|
|
||||||
(d) => !detailInOtherDraftShipment(d.clientId, labCases, ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectableDetailsForDraftShipment(
|
function selectableDetailsForDraftShipment(
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
labCases: LabCaseDraft[],
|
labCases: LabCaseDraft[],
|
||||||
@@ -93,9 +77,11 @@ function selectableDetailsForDraftShipment(
|
|||||||
function prosthesisTeethRows(
|
function prosthesisTeethRows(
|
||||||
labCase: LabCaseDraft,
|
labCase: LabCaseDraft,
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
|
scopeDetailClientId?: string,
|
||||||
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
||||||
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
|
const rows: Array<{ detailClientId: string; tooth: string; detailNumber: number }> = [];
|
||||||
for (const clientId of labCase.detailClientIds) {
|
for (const clientId of labCase.detailClientIds) {
|
||||||
|
if (scopeDetailClientId && clientId !== scopeDetailClientId) continue;
|
||||||
const detail = details.find((d) => d.clientId === clientId);
|
const detail = details.find((d) => d.clientId === clientId);
|
||||||
if (!detail || detail.treatmentType !== 'prosthesis') continue;
|
if (!detail || detail.treatmentType !== 'prosthesis') continue;
|
||||||
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
|
const detailNumber = details.findIndex((d) => d.clientId === clientId) + 1;
|
||||||
@@ -106,8 +92,12 @@ function prosthesisTeethRows(
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetailDraft[]): boolean {
|
function isProsthesisMapComplete(
|
||||||
const rows = prosthesisTeethRows(labCase, details);
|
labCase: LabCaseDraft,
|
||||||
|
details: TreatmentDetailDraft[],
|
||||||
|
scopeDetailClientId?: string,
|
||||||
|
): boolean {
|
||||||
|
const rows = prosthesisTeethRows(labCase, details, scopeDetailClientId);
|
||||||
if (rows.length === 0) return true;
|
if (rows.length === 0) return true;
|
||||||
return rows.every((row) =>
|
return rows.every((row) =>
|
||||||
labCase.toothProsthesis.some(
|
labCase.toothProsthesis.some(
|
||||||
@@ -121,6 +111,7 @@ function isProsthesisMapComplete(labCase: LabCaseDraft, details: TreatmentDetail
|
|||||||
|
|
||||||
export function LabCasesDispatchPanel({
|
export function LabCasesDispatchPanel({
|
||||||
details,
|
details,
|
||||||
|
activeDetailId,
|
||||||
labCases,
|
labCases,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
@@ -137,6 +128,7 @@ export function LabCasesDispatchPanel({
|
|||||||
sendBusyId,
|
sendBusyId,
|
||||||
onAddLabCase,
|
onAddLabCase,
|
||||||
onSendLabCase,
|
onSendLabCase,
|
||||||
|
onCommentError,
|
||||||
}: LabCasesDispatchPanelProps) {
|
}: LabCasesDispatchPanelProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
@@ -152,27 +144,35 @@ export function LabCasesDispatchPanel({
|
|||||||
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
|
.map((id) => activeLinkedOrganizations.find((o) => o.id === id))
|
||||||
.filter(Boolean) as LinkedOrganizationOption[];
|
.filter(Boolean) as LinkedOrganizationOption[];
|
||||||
|
|
||||||
const labEligibleDetails = useMemo(
|
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null;
|
||||||
() => details.filter((d) => labDependentCodes.has(d.treatmentType)),
|
const isLabDependentDetail = Boolean(
|
||||||
[details, labDependentCodes],
|
activeDetail && labDependentCodes.has(activeDetail.treatmentType),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canAddLabShipment = useMemo(
|
const labCaseForActiveDetail =
|
||||||
() => detailsAvailableForNewShipment(details, labCases, labDependentCodes).length > 0,
|
labCases.find((lc) => lc.detailClientIds.includes(activeDetailId)) ?? null;
|
||||||
[details, labCases, labDependentCodes],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeLabCase =
|
const activeLabCase =
|
||||||
labCases.find((lc) => lc.clientId === activeLabCaseId) ?? labCases[0] ?? null;
|
labCaseForActiveDetail ??
|
||||||
|
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||||
|
|
||||||
|
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
||||||
|
const canAddLabShipment =
|
||||||
|
!detailAlreadyInShipment &&
|
||||||
|
!detailInOtherDraftShipment(activeDetailId, labCases, '') &&
|
||||||
|
!sentDetailClientIds(labCases).has(activeDetailId);
|
||||||
|
|
||||||
const sent = Boolean(activeLabCase?.sentAt);
|
const sent = Boolean(activeLabCase?.sentAt);
|
||||||
|
|
||||||
const activeLabOrgName = activeLabCase?.destinationOrganizationId
|
const activeLabOrgName = activeLabCase?.destinationOrganizationId
|
||||||
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
? orgs.find((o) => o.id === activeLabCase.destinationOrganizationId)?.name
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const prosthesisRows = activeLabCase ? prosthesisTeethRows(activeLabCase, details) : [];
|
const prosthesisRows = activeLabCase
|
||||||
|
? prosthesisTeethRows(activeLabCase, details, activeDetailId)
|
||||||
|
: [];
|
||||||
const prosthesisComplete = activeLabCase
|
const prosthesisComplete = activeLabCase
|
||||||
? isProsthesisMapComplete(activeLabCase, details)
|
? isProsthesisMapComplete(activeLabCase, details, activeDetailId)
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,6 +196,11 @@ export function LabCasesDispatchPanel({
|
|||||||
};
|
};
|
||||||
}, [activeLabCase?.destinationOrganizationId]);
|
}, [activeLabCase?.destinationOrganizationId]);
|
||||||
|
|
||||||
|
// Hide dispatch when the selected treatment detail is not lab-dependent.
|
||||||
|
if (!activeDetail || !isLabDependentDetail) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function detailNumber(d: TreatmentDetailDraft) {
|
function detailNumber(d: TreatmentDetailDraft) {
|
||||||
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
const idx = details.findIndex((row) => row.clientId === d.clientId);
|
||||||
return idx >= 0 ? idx + 1 : 0;
|
return idx >= 0 ? idx + 1 : 0;
|
||||||
@@ -269,22 +274,15 @@ export function LabCasesDispatchPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labEligibleDetails.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="surface-card p-4 space-y-2">
|
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
|
||||||
<p className="text-xs text-text-muted">{t('noLabDetails')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const includedInActiveShipment = activeLabCase
|
const includedInActiveShipment = activeLabCase
|
||||||
? labEligibleDetails.filter((d) => activeLabCase.detailClientIds.includes(d.clientId))
|
? [activeDetail]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const pickableForActiveDraft =
|
const pickableForActiveDraft =
|
||||||
activeLabCase && !sent
|
activeLabCase && !sent
|
||||||
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase)
|
? selectableDetailsForDraftShipment(details, labCases, labDependentCodes, activeLabCase).filter(
|
||||||
|
(d) => d.clientId === activeDetailId,
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -308,44 +306,9 @@ export function LabCasesDispatchPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{labCases.length === 0 ? (
|
{!detailAlreadyInShipment ? (
|
||||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
||||||
) : (
|
) : activeLabCase ? (
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{labCases.map((lc, idx) => {
|
|
||||||
const sentSummary = formatCaseSentSummary(
|
|
||||||
lc.sends,
|
|
||||||
{
|
|
||||||
organizationIds: lc.destinationOrganizationId ? [lc.destinationOrganizationId] : [],
|
|
||||||
sentAt: lc.sentAt ?? null,
|
|
||||||
orgs,
|
|
||||||
},
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={lc.clientId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onActiveLabCaseChange(lc.clientId)}
|
|
||||||
className={`
|
|
||||||
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
||||||
${
|
|
||||||
lc.clientId === activeLabCase?.clientId
|
|
||||||
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
|
||||||
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{t('labShipmentLabel', { n: idx + 1 })}
|
|
||||||
{sentSummary ? ` · ${sentSummary}` : ''}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeLabCase && (
|
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<>
|
<>
|
||||||
@@ -369,13 +332,20 @@ export function LabCasesDispatchPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeLabCase.labComment.trim() ? (
|
{activeLabCase.id ? (
|
||||||
<div>
|
<LabCaseCommentsPanel
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('labComment')}</p>
|
caseId={activeLabCase.id}
|
||||||
<p className="text-sm text-text-primary mt-1 whitespace-pre-wrap">
|
canPost={false}
|
||||||
{activeLabCase.labComment}
|
canToggleVisibility={false}
|
||||||
</p>
|
loadComments={async () => {
|
||||||
</div>
|
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onPost={async () => {
|
||||||
|
throw new Error('Read-only');
|
||||||
|
}}
|
||||||
|
onError={onCommentError}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeLabOrgName ? (
|
{activeLabOrgName ? (
|
||||||
@@ -423,17 +393,22 @@ export function LabCasesDispatchPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block text-xs font-medium text-text-secondary">
|
{activeLabCase.id ? (
|
||||||
{t('labComment')}
|
<LabCaseCommentsPanel
|
||||||
<textarea
|
caseId={activeLabCase.id}
|
||||||
value={activeLabCase.labComment}
|
canPost={canEdit && !disabled}
|
||||||
onChange={(e) => updateActiveLabCase({ labComment: e.target.value })}
|
canToggleVisibility={false}
|
||||||
placeholder={t('labCommentPlaceholder')}
|
loadComments={async () => {
|
||||||
rows={3}
|
const r = await treatmentsApi.listLabCaseComments(activeLabCase.id!);
|
||||||
disabled={disabled}
|
return r.data;
|
||||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y"
|
}}
|
||||||
|
onPost={async (body) => {
|
||||||
|
const r = await treatmentsApi.addLabCaseComment(activeLabCase.id!, { body });
|
||||||
|
return r.data;
|
||||||
|
}}
|
||||||
|
onError={onCommentError}
|
||||||
/>
|
/>
|
||||||
</label>
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||||
@@ -581,9 +556,7 @@ export function LabCasesDispatchPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ function labCaseDraftsToPast(
|
|||||||
id: lc.id ?? lc.clientId,
|
id: lc.id ?? lc.clientId,
|
||||||
clientId: lc.clientId,
|
clientId: lc.clientId,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId,
|
destinationOrganizationId: lc.destinationOrganizationId,
|
||||||
labComment: lc.labComment || null,
|
|
||||||
sentAt: lc.sentAt ?? null,
|
sentAt: lc.sentAt ?? null,
|
||||||
treatmentDetailIds: lc.detailClientIds
|
treatmentDetailIds: lc.detailClientIds
|
||||||
.map((cid) => details.find((d) => d.clientId === cid)?.id)
|
.map((cid) => details.find((d) => d.clientId === cid)?.id)
|
||||||
@@ -132,7 +131,6 @@ function newLabCaseDraft(): LabCaseDraft {
|
|||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
: `lab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
destinationOrganizationId: null,
|
destinationOrganizationId: null,
|
||||||
labComment: '',
|
|
||||||
detailClientIds: [],
|
detailClientIds: [],
|
||||||
toothProsthesis: [],
|
toothProsthesis: [],
|
||||||
sentAt: null,
|
sentAt: null,
|
||||||
@@ -175,7 +173,6 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
|||||||
clientId: lc.clientId,
|
clientId: lc.clientId,
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId,
|
destinationOrganizationId: lc.destinationOrganizationId,
|
||||||
labComment: lc.labComment ?? '',
|
|
||||||
detailClientIds: lc.details.map((d) => d.clientId),
|
detailClientIds: lc.details.map((d) => d.clientId),
|
||||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||||
detailClientId:
|
detailClientId:
|
||||||
@@ -391,6 +388,12 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||||
|
|
||||||
|
// Sync active lab shipment when the selected treatment detail changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const match = labCaseDrafts.find((lc) => lc.detailClientIds.includes(activeDetailId));
|
||||||
|
setActiveLabCaseId(match?.clientId ?? null);
|
||||||
|
}, [activeDetailId, labCaseDrafts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectionLocked(false);
|
setSelectionLocked(false);
|
||||||
}, [selectedDay]);
|
}, [selectedDay]);
|
||||||
@@ -788,18 +791,18 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
);
|
);
|
||||||
|
|
||||||
const persistLabCases = useCallback(
|
const persistLabCases = useCallback(
|
||||||
async (savedTreatment: PastTreatment) => {
|
async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => {
|
||||||
if (!selectedAppointment) throw new Error('No appointment selected');
|
if (!selectedAppointment) throw new Error('No appointment selected');
|
||||||
|
|
||||||
|
const drafts = draftsOverride ?? labCaseDrafts;
|
||||||
const detailIdByClientId = new Map(
|
const detailIdByClientId = new Map(
|
||||||
savedTreatment.details.map((d) => [d.clientId, d.id]),
|
savedTreatment.details.map((d) => [d.clientId, d.id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload = labCaseDrafts.map((lc) => ({
|
const payload = drafts.map((lc) => ({
|
||||||
clientId: lc.clientId,
|
clientId: lc.clientId,
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
|
destinationOrganizationId: lc.destinationOrganizationId ?? undefined,
|
||||||
labComment: lc.labComment.trim() || undefined,
|
|
||||||
treatmentDetailIds: lc.detailClientIds
|
treatmentDetailIds: lc.detailClientIds
|
||||||
.map((clientId) => detailIdByClientId.get(clientId))
|
.map((clientId) => detailIdByClientId.get(clientId))
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
@@ -834,6 +837,40 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
[labCaseDrafts, selectedAppointment],
|
[labCaseDrafts, selectedAppointment],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAddLabCase = useCallback(async () => {
|
||||||
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
|
|
||||||
|
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||||
|
const next: LabCaseDraft = {
|
||||||
|
...newLabCaseDraft(),
|
||||||
|
detailClientIds:
|
||||||
|
activeDetail && labDependentCodes.has(activeDetail.treatmentType)
|
||||||
|
? [activeDetailId]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const updatedLabCases = [...labCaseDrafts, next];
|
||||||
|
setLabCaseDrafts(updatedLabCases);
|
||||||
|
setActiveLabCaseId(next.clientId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await persistDraft({ force: true });
|
||||||
|
await persistLabCases(saved, updatedLabCases);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeDetailId,
|
||||||
|
canEditTreatmentForDay,
|
||||||
|
details,
|
||||||
|
labCaseDrafts,
|
||||||
|
labDependentCodes,
|
||||||
|
persistDraft,
|
||||||
|
persistLabCases,
|
||||||
|
selectedAppointment,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSendLabCase = useCallback(
|
const handleSendLabCase = useCallback(
|
||||||
async (labCase: LabCaseDraft) => {
|
async (labCase: LabCaseDraft) => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
@@ -1041,6 +1078,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
|
|
||||||
<LabCasesDispatchPanel
|
<LabCasesDispatchPanel
|
||||||
details={details}
|
details={details}
|
||||||
|
activeDetailId={activeDetailId}
|
||||||
labCases={labCaseDrafts}
|
labCases={labCaseDrafts}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
@@ -1064,12 +1102,9 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sendBusyId={sendBusyId}
|
sendBusyId={sendBusyId}
|
||||||
onAddLabCase={() => {
|
onAddLabCase={() => void handleAddLabCase()}
|
||||||
const next = newLabCaseDraft();
|
|
||||||
setLabCaseDrafts((prev) => [...prev, next]);
|
|
||||||
setActiveLabCaseId(next.clientId);
|
|
||||||
}}
|
|
||||||
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
onSendLabCase={(lc) => void handleSendLabCase(lc)}
|
||||||
|
onCommentError={showError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
import type { LabCaseComment } from '@/types/cases';
|
||||||
import type {
|
import type {
|
||||||
LabCaseResponse,
|
LabCaseResponse,
|
||||||
LinkedOrganizationOption,
|
LinkedOrganizationOption,
|
||||||
@@ -82,6 +83,21 @@ export const treatmentsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listLabCaseComments: async (
|
||||||
|
labCaseId: string,
|
||||||
|
): Promise<{ success: boolean; data: LabCaseComment[] }> => {
|
||||||
|
const response = await apiClient.get(`/treatments/lab-cases/${labCaseId}/comments`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addLabCaseComment: async (
|
||||||
|
labCaseId: string,
|
||||||
|
payload: { body: string },
|
||||||
|
): Promise<{ success: boolean; data: LabCaseComment }> => {
|
||||||
|
const response = await apiClient.post(`/treatments/lab-cases/${labCaseId}/comments`, payload);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
|
getAttachmentFileBlob: async (attachmentId: string): Promise<Blob> => {
|
||||||
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
|
const response = await apiClient.get(`/treatments/attachments/${attachmentId}/file`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ export interface LabCaseComment {
|
|||||||
visibleToClinic: boolean;
|
visibleToClinic: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
canToggleVisibility: boolean;
|
canToggleVisibility: boolean;
|
||||||
|
showVisibilityStatus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabCaseDetail {
|
export interface LabCaseDetail {
|
||||||
id: string;
|
id: string;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
labComment: string | null;
|
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: {
|
patient: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export interface PastLabCase {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
destinationOrganizationId: string | null;
|
destinationOrganizationId: string | null;
|
||||||
labComment?: string | null;
|
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
treatmentDetailIds: string[];
|
treatmentDetailIds: string[];
|
||||||
details: Array<{
|
details: Array<{
|
||||||
@@ -143,7 +142,6 @@ export interface LabCaseDraft {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
destinationOrganizationId: string | null;
|
destinationOrganizationId: string | null;
|
||||||
labComment: string;
|
|
||||||
detailClientIds: string[];
|
detailClientIds: string[];
|
||||||
toothProsthesis: LabCaseToothProsthesisDraft[];
|
toothProsthesis: LabCaseToothProsthesisDraft[];
|
||||||
sentAt?: string | null;
|
sentAt?: string | null;
|
||||||
@@ -166,7 +164,6 @@ export interface SaveLabCasePayload {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
destinationOrganizationId?: string;
|
destinationOrganizationId?: string;
|
||||||
labComment?: string;
|
|
||||||
treatmentDetailIds: string[];
|
treatmentDetailIds: string[];
|
||||||
toothProsthesis?: Array<{
|
toothProsthesis?: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
@@ -185,7 +182,6 @@ export interface LabCaseResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
destinationOrganizationId: string | null;
|
destinationOrganizationId: string | null;
|
||||||
labComment: string | null;
|
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
treatmentDetailIds: string[];
|
treatmentDetailIds: string[];
|
||||||
details: Array<{
|
details: Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user