improvement: delete action added for unsent treatment details. some edit controls added to details and shipments.

This commit is contained in:
2026-07-16 22:45:37 +03:30
parent 334b7841ae
commit cf07b4d8a8
7 changed files with 82 additions and 1 deletions

View File

@@ -64,6 +64,7 @@ export const ErrorCode = {
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS', CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED', APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT', APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
BAD_REQUEST: 'BAD_REQUEST', BAD_REQUEST: 'BAD_REQUEST',
INTERNAL_ERROR: 'INTERNAL_ERROR', INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const; } as const;

View File

@@ -1,9 +1,11 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpStatus,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client'; import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/client';
import { createReadStream, existsSync, mkdirSync } from 'fs'; import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -908,6 +910,25 @@ export class TreatmentsService {
throw new BadRequestException('At least one file is required'); throw new BadRequestException('At least one file is required');
} }
const existingDetail = await this.prisma.treatmentDetail.findFirst({
where: {
clientKey: detailClientKey,
treatment: {
appointmentId,
organizationId,
...treatmentProviderScopeWhere(actorUserId),
},
},
select: {
labCaseLink: {
select: { labCase: { select: { sentAt: true } } },
},
},
});
if (existingDetail?.labCaseLink?.labCase.sentAt) {
throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT);
}
const orgDir = join(this.uploadRoot, organizationId); const orgDir = join(this.uploadRoot, organizationId);
mkdirSync(orgDir, { recursive: true }); mkdirSync(orgDir, { recursive: true });

View File

@@ -660,6 +660,7 @@
"detailsTitle": "Treatment details", "detailsTitle": "Treatment details",
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
"addDetail": "Add detail", "addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "sent", "detailSentBadge": "sent",
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
@@ -1015,6 +1016,7 @@
"CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.", "CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.",
"APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.",
"APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.",
"TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.",
"BAD_REQUEST": "The request could not be processed.", "BAD_REQUEST": "The request could not be processed.",
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later." "INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
} }

View File

@@ -661,6 +661,7 @@
"detailsTitle": "جزئیات درمان", "detailsTitle": "جزئیات درمان",
"detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.",
"addDetail": "افزودن جزئیات", "addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"detailLabel": "جزئیات {n}", "detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده", "detailSentBadge": "ارسال‌شده",
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
@@ -1016,6 +1017,7 @@
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", "CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.",
"APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.",
"APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.",
"TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.",
"BAD_REQUEST": "درخواست قابل پردازش نبود.", "BAD_REQUEST": "درخواست قابل پردازش نبود.",
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
} }

View File

@@ -660,6 +660,7 @@
"detailsTitle": "Behandeldetails", "detailsTitle": "Behandeldetails",
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
"addDetail": "Detail toevoegen", "addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "verzonden", "detailSentBadge": "verzonden",
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
@@ -1015,6 +1016,7 @@
"CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.", "CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.",
"APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.",
"APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.",
"TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.",
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
} }

View File

@@ -32,6 +32,7 @@ interface TreatmentDetailsEditorProps {
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean; uploadBusy: boolean;
onAddDetail: () => void; onAddDetail: () => void;
onRemoveDetail: () => void;
onUploadFiles: (files: FileList | null) => void; onUploadFiles: (files: FileList | null) => void;
} }
@@ -48,9 +49,11 @@ export function TreatmentDetailsEditor({
saveStatus, saveStatus,
uploadBusy, uploadBusy,
onAddDetail, onAddDetail,
onRemoveDetail,
onUploadFiles, onUploadFiles,
}: TreatmentDetailsEditorProps) { }: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment'); const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null); const attachmentInputRef = useRef<HTMLInputElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0]; const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
@@ -58,6 +61,7 @@ export function TreatmentDetailsEditor({
const locked = isDetailLocked(activeDetail); const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked; const readOnly = disabled || locked;
const canRemoveDetail = canEdit && !disabled && !locked && details.length > 1;
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail) const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
? treatmentTypeColor( ? treatmentTypeColor(
activeDetail.treatmentType, activeDetail.treatmentType,
@@ -206,6 +210,21 @@ export function TreatmentDetailsEditor({
</ul> </ul>
)} )}
</div> </div>
{canRemoveDetail ? (
<div className="pt-1">
<Button
type="button"
variant="danger"
onClick={onRemoveDetail}
disabled={uploadBusy}
fullWidth
className="sm:w-auto"
>
{tCommon('delete')}
</Button>
</div>
) : null}
</div> </div>
{canEdit && saveStatus !== 'idle' && ( {canEdit && saveStatus !== 'idle' && (

View File

@@ -1249,6 +1249,8 @@ export function TreatmentWorkspace({
const uploadForDetail = useCallback( const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => { async (detailClientId: string, files: FileList | File[]) => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
if (target && isDetailLocked(target)) return;
const list = files instanceof FileList ? Array.from(files) : files; const list = files instanceof FileList ? Array.from(files) : files;
if (!list.length) return; if (!list.length) return;
@@ -1273,7 +1275,7 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(null); setUploadBusyDetailId(null);
} }
}, },
[canEditTreatmentForDay, selectedAppointment, showSuccess, showError, t], [canEditTreatmentForDay, isDetailLocked, selectedAppointment, showSuccess, showError, t, tErrors],
); );
const persistLabCases = useCallback( const persistLabCases = useCallback(
@@ -1373,9 +1375,40 @@ export function TreatmentWorkspace({
selectedAppointment, selectedAppointment,
showError, showError,
t, t,
tErrors,
], ],
); );
const handleRemoveActiveDetail = useCallback(() => {
if (!canEditTreatmentForDay) return;
const idx = details.findIndex((d) => d.clientId === activeDetailId);
if (idx < 0) return;
const active = details[idx];
if (!active || isDetailLocked(active) || details.length <= 1) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const removedId = active.clientId;
const nextDetails = details.filter((d) => d.clientId !== removedId);
const nextActive =
nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? nextDetails[0]?.clientId;
setDetails(nextDetails);
if (nextActive) setActiveDetailId(nextActive);
if (labCaseDrafts.some((lc) => lc.detailClientId === removedId)) {
handleLabCasesChange(
withoutEmptyLabCaseDrafts(labCaseDrafts.filter((lc) => lc.detailClientId !== removedId)),
);
}
}, [
activeDetailId,
canEditTreatmentForDay,
details,
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
t,
]);
const handleAddLabCase = useCallback(async () => { const handleAddLabCase = useCallback(async () => {
if (!canEditTreatmentForDay || !selectedAppointment) return; if (!canEditTreatmentForDay || !selectedAppointment) return;
@@ -1779,6 +1812,7 @@ export function TreatmentWorkspace({
setDetails((prev) => [...prev, next]); setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId); setActiveDetailId(next.clientId);
}} }}
onRemoveDetail={handleRemoveActiveDetail}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
/> />