diff --git a/i18n/en.pot b/i18n/en.pot index 061683d7..238be270 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2025-10-14T17:59:40.618Z\n" -"PO-Revision-Date: 2025-10-14T17:59:40.618Z\n" +"POT-Creation-Date: 2025-10-15T16:41:05.367Z\n" +"PO-Revision-Date: 2025-10-15T16:41:05.367Z\n" msgid "Events - Create/update" msgstr "" @@ -176,6 +176,9 @@ msgstr "" msgid "Import strategy" msgstr "" +msgid "Comment" +msgstr "" + msgid "File" msgstr "" @@ -230,6 +233,24 @@ msgstr "" msgid "All" msgstr "" +msgid "Comment for all imported data values" +msgstr "" + +msgid "" +"You can add an optional comment below. If provided, it will be associated " +"with every data value that is created, updated, or deleted during this " +"import." +msgstr "" + +msgid "Comment (optional)" +msgstr "" + +msgid "CONTINUE WITHOUT COMMENT" +msgstr "" + +msgid "SAVE AND CONTINUE" +msgstr "" + msgid "" "There has been an error. You can either retry or contact your administrator " "if you think there has been an un recoverable error" diff --git a/i18n/es.po b/i18n/es.po index c93ad785..ee4a106a 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-10-14T17:59:40.618Z\n" +"POT-Creation-Date: 2025-10-15T16:41:05.367Z\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -198,6 +198,9 @@ msgstr "Estado" msgid "Import strategy" msgstr "Importar datos" +msgid "Comment" +msgstr "" + msgid "File" msgstr "" @@ -259,6 +262,24 @@ msgstr "" msgid "All" msgstr "Todos" +msgid "Comment for all imported data values" +msgstr "" + +msgid "" +"You can add an optional comment below. If provided, it will be associated " +"with every data value that is created, updated, or deleted during this " +"import." +msgstr "" + +msgid "Comment (optional)" +msgstr "" + +msgid "CONTINUE WITHOUT COMMENT" +msgstr "" + +msgid "SAVE AND CONTINUE" +msgstr "" + msgid "" "There has been an error. You can either retry or contact your administrator " "if you think there has been an un recoverable error" diff --git a/i18n/fr.po b/i18n/fr.po index 475bfb24..08ec3c89 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load App\n" -"POT-Creation-Date: 2025-10-14T17:59:40.618Z\n" +"POT-Creation-Date: 2025-10-15T16:41:05.367Z\n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -192,6 +192,9 @@ msgstr "" msgid "Import strategy" msgstr "Importer des données" +msgid "Comment" +msgstr "" + msgid "File" msgstr "" @@ -253,6 +256,24 @@ msgstr "" msgid "All" msgstr "Tout" +msgid "Comment for all imported data values" +msgstr "" + +msgid "" +"You can add an optional comment below. If provided, it will be associated " +"with every data value that is created, updated, or deleted during this " +"import." +msgstr "" + +msgid "Comment (optional)" +msgstr "" + +msgid "CONTINUE WITHOUT COMMENT" +msgstr "" + +msgid "SAVE AND CONTINUE" +msgstr "" + msgid "" "There has been an error. You can either retry or contact your administrator " "if you think there has been an un recoverable error" diff --git a/i18n/pt.po b/i18n/pt.po index 1ae6847b..a1a7f515 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-10-14T17:59:40.618Z\n" +"POT-Creation-Date: 2025-10-15T16:41:05.367Z\n" "Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -200,6 +200,9 @@ msgstr "Estado" msgid "Import strategy" msgstr "Importar dados" +msgid "Comment" +msgstr "" + msgid "File" msgstr "" @@ -261,6 +264,24 @@ msgstr "" msgid "All" msgstr "Tudo" +msgid "Comment for all imported data values" +msgstr "" + +msgid "" +"You can add an optional comment below. If provided, it will be associated " +"with every data value that is created, updated, or deleted during this " +"import." +msgstr "" + +msgid "Comment (optional)" +msgstr "" + +msgid "CONTINUE WITHOUT COMMENT" +msgstr "" + +msgid "SAVE AND CONTINUE" +msgstr "" + msgid "" "There has been an error. You can either retry or contact your administrator " "if you think there has been an un recoverable error" diff --git a/i18n/ru.po b/i18n/ru.po index 760d2331..cb4f6b78 100644 --- a/i18n/ru.po +++ b/i18n/ru.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Bulk Load\n" -"POT-Creation-Date: 2025-10-14T17:59:40.618Z\n" +"POT-Creation-Date: 2025-10-15T16:41:05.367Z\n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -201,6 +201,9 @@ msgstr "Статус" msgid "Import strategy" msgstr "Импортные данные" +msgid "Comment" +msgstr "" + msgid "File" msgstr "" @@ -262,6 +265,24 @@ msgstr "" msgid "All" msgstr "Все" +msgid "Comment for all imported data values" +msgstr "" + +msgid "" +"You can add an optional comment below. If provided, it will be associated " +"with every data value that is created, updated, or deleted during this " +"import." +msgstr "" + +msgid "Comment (optional)" +msgstr "" + +msgid "CONTINUE WITHOUT COMMENT" +msgstr "" + +msgid "SAVE AND CONTINUE" +msgstr "" + msgid "" "There has been an error. You can either retry or contact your administrator " "if you think there has been an un recoverable error" diff --git a/src/domain/entities/HistoryEntry.ts b/src/domain/entities/HistoryEntry.ts index 9aa4a5b4..e7f10690 100644 --- a/src/domain/entities/HistoryEntry.ts +++ b/src/domain/entities/HistoryEntry.ts @@ -139,6 +139,7 @@ export class HistoryEntry { "selectedOrgUnits", "duplicateStrategy", "organisationUnitStrategy", + "comment", ]), }; } diff --git a/src/domain/entities/ImportTemplateConfiguration.ts b/src/domain/entities/ImportTemplateConfiguration.ts index b07f8b93..499d3021 100644 --- a/src/domain/entities/ImportTemplateConfiguration.ts +++ b/src/domain/entities/ImportTemplateConfiguration.ts @@ -6,4 +6,5 @@ export interface ImportTemplateConfiguration { selectedOrgUnits?: string[]; duplicateStrategy?: DuplicateImportStrategy; organisationUnitStrategy?: OrganisationUnitImportStrategy; + comment?: string; } diff --git a/src/domain/usecases/ImportTemplateUseCase.ts b/src/domain/usecases/ImportTemplateUseCase.ts index 76fbc904..8bb32bd9 100644 --- a/src/domain/usecases/ImportTemplateUseCase.ts +++ b/src/domain/usecases/ImportTemplateUseCase.ts @@ -198,6 +198,7 @@ export class ImportTemplateUseCase implements UseCase { duplicateStrategy = "ERROR", organisationUnitStrategy = "ERROR", settings, + comment, }: ImportTemplateUseCaseParams, dataForm: DataForm, spreadSheet: Blob, @@ -244,6 +245,11 @@ export class ImportTemplateUseCase implements UseCase { }); } + const dataValuesWithComments = + dataForm.type === dataFormTypeMap.dataSets + ? this.applyCommentsToDataValues(dataValues, instanceDataValues, comment) + : dataValues; + const shouldDeleteExistingData = dataForm.type === dataFormTypeMap.dataSets ? this.shouldDeleteAggregatedData(duplicateStrategy) : false; @@ -251,10 +257,13 @@ export class ImportTemplateUseCase implements UseCase { ? await this.instanceRepository.deleteAggregatedData(templateToDataPackage(instanceDataValues)) : undefined; - const importResult = await this.instanceRepository.importDataPackage(templateToDataPackage(dataValues), { - createAndUpdate: duplicateStrategy === "IMPORT_WITHOUT_DELETE" || duplicateStrategy === "ERROR", - multiTextTeiDelimiter: this.getMultiTextTeiDelimiter(template), - }); + const importResult = await this.instanceRepository.importDataPackage( + templateToDataPackage(dataValuesWithComments), + { + createAndUpdate: duplicateStrategy === "IMPORT_WITHOUT_DELETE" || duplicateStrategy === "ERROR", + multiTextTeiDelimiter: this.getMultiTextTeiDelimiter(template), + } + ); const importResultHasErrors = importResult.flatMap(result => result.errors); if (importResultHasErrors.length > 0 || deleteResult) { @@ -612,6 +621,51 @@ export class ImportTemplateUseCase implements UseCase { return importResultsWithErrorsDetails; } + /** + * Returns the `dataPackage` with dataValues with comments populated from `existingDataPackage` and `userComment` + */ + private applyCommentsToDataValues( + dataPackage: TemplateDataPackage, + existingDataPackage: TemplateDataPackage, + userComment: Maybe + ): TemplateDataPackage { + if (dataPackage.type !== dataFormTypeMap.dataSets) { + return dataPackage; + } + const timestamp = moment().format("YYYYMMDD"); + const formattedComment = userComment ? `${timestamp} - BL Import - ${userComment}` : ""; + + const dataEntriesWithComments = dataPackage.dataEntries.map(entry => ({ + ...entry, + dataValues: entry.dataValues.map(dv => { + const existingEntry = existingDataPackage.dataEntries.find( + existing => + existing.orgUnit === entry.orgUnit && + existing.period === entry.period && + existing.dataForm === entry.dataForm + ); + const existingDv = existingEntry?.dataValues.find(existing => existing.dataElement === dv.dataElement); + const existingComment = existingDv?.comment || ""; + if (!formattedComment) { + return { + ...dv, + comment: existingComment, + }; + } else { + return { + ...dv, + comment: existingComment ? `${existingComment.trim()}\n${formattedComment}` : formattedComment, + }; + } + }), + })); + + return { + ...dataPackage, + dataEntries: dataEntriesWithComments, + }; + } + private generateErrorDetails(errors: ErrorMessage[], allowedMessages: CustomErrorMatch[], orgUnits: OrgUnit[]) { return errors.map(error => { const orgUnit = orgUnits.find(ou => ou.id === error.id); diff --git a/src/webapp/components/history/HistoryImportSummary.tsx b/src/webapp/components/history/HistoryImportSummary.tsx index c960826e..81409e12 100644 --- a/src/webapp/components/history/HistoryImportSummary.tsx +++ b/src/webapp/components/history/HistoryImportSummary.tsx @@ -36,10 +36,18 @@ export function HistoryImportSummary({ summary, details }: HistoryImportSummaryP {details && ( -
- {i18n.t("Import strategy")}: - {getImportStrategyLabel(details, summary.dataFormType)} -
+ <> +
+ {i18n.t("Import strategy")}: + {getImportStrategyLabel(details, summary.dataFormType)} +
+ {details.configuration?.comment && ( +
+ {i18n.t("Comment")}: + {details.configuration?.comment} +
+ )} + )}
{i18n.t("File")}: diff --git a/src/webapp/components/import-comment-dialog/ImportCommentDialog.tsx b/src/webapp/components/import-comment-dialog/ImportCommentDialog.tsx new file mode 100644 index 00000000..4d7b5e02 --- /dev/null +++ b/src/webapp/components/import-comment-dialog/ImportCommentDialog.tsx @@ -0,0 +1,64 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@material-ui/core"; +import React, { useState } from "react"; +import i18n from "../../../utils/i18n"; + +export interface ImportCommentDialogProps { + isOpen: boolean; + onContinue: (comment: string | undefined) => void; + onCancel: () => void; +} + +export const ImportCommentDialog: React.FC = ({ isOpen, onContinue, onCancel }) => { + const [comment, setComment] = useState(""); + + const handleCommentChange = (event: React.ChangeEvent) => { + setComment(event.target.value); + }; + + const handleContinueWithoutComment = () => { + onContinue(undefined); + setComment(""); + }; + + const handleSaveAndContinue = () => { + const trimmedComment = comment.trim(); + onContinue(trimmedComment || undefined); + setComment(""); + }; + + const isCommentProvided = comment.trim().length > 0; + + return ( + + {i18n.t("Comment for all imported data values")} + + +

+ {i18n.t( + "You can add an optional comment below. If provided, it will be associated with every data value that is created, updated, or deleted during this import." + )} +

+ +
+ + + + + +
+ ); +}; diff --git a/src/webapp/pages/import-template/ImportTemplatePage.tsx b/src/webapp/pages/import-template/ImportTemplatePage.tsx index 2ae835ee..fcd60de1 100644 --- a/src/webapp/pages/import-template/ImportTemplatePage.tsx +++ b/src/webapp/pages/import-template/ImportTemplatePage.tsx @@ -13,6 +13,7 @@ import { ImportTemplateUseCaseParams } from "../../../domain/usecases/ImportTemp import i18n from "../../../utils/i18n"; import ModalDialog, { ModalDialogProps } from "../../components/modal-dialog/ModalDialog"; import SyncSummaryDialog from "../../components/sync-summary/SyncSummaryDialog"; +import { ImportCommentDialog } from "../../components/import-comment-dialog/ImportCommentDialog"; import { useAppContext } from "../../contexts/app-context"; import { orgUnitListParams } from "../../utils/template"; import { RouteComponentProps } from "../Router"; @@ -43,6 +44,8 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { const [importState, setImportState] = useState(); const [messages, setMessages] = useState([]); const [dialogProps, updateDialog] = useState(); + const [showCommentDialog, setShowCommentDialog] = useState(false); + const [pendingImportParams, setPendingImportParams] = useState(); useEffect(() => { compositionRoot.orgUnits.getUserRoots().then(setOrgUnitTreeRootIds); @@ -101,7 +104,15 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { throw new Error(i18n.t("Select at least one organisation unit to import data")); } - await startImport({ file, settings, useBuilderOrgUnits, selectedOrgUnits }); + const baseParams = { file, settings, useBuilderOrgUnits, selectedOrgUnits }; + + if (importState.dataForm.type === "dataSets") { + loading.show(false); + setPendingImportParams(baseParams); + setShowCommentDialog(true); + } else { + await startImport(baseParams); + } } catch (reason: any) { console.error(reason); snackbar.error(reason.message || reason.toString()); @@ -185,27 +196,22 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { updateTooltipText, } = dataValues.type === "dataSets" ? dataSetConfig : programConfig; + const handleDuplicateAction = async ( + duplicateStrategy: "IMPORT" | "IMPORT_WITHOUT_DELETE" | "IGNORE" + ) => { + updateDialog(undefined); + const importParams = { ...params, duplicateStrategy }; + loading.show(true, i18n.t("Importing data...")); + await startImport(importParams); + loading.reset(); + }; + updateDialog({ title, description: message, - onSave: async () => { - updateDialog(undefined); - loading.show(true, i18n.t("Importing data...")); - await startImport({ ...params, duplicateStrategy: "IMPORT" }); - loading.reset(); - }, - onUpdate: async () => { - updateDialog(undefined); - loading.show(true, i18n.t("Importing data...")); - await startImport({ ...params, duplicateStrategy: "IMPORT_WITHOUT_DELETE" }); - loading.reset(); - }, - onInfoAction: async () => { - updateDialog(undefined); - loading.show(true, i18n.t("Importing data...")); - await startImport({ ...params, duplicateStrategy: "IGNORE" }); - loading.reset(); - }, + onSave: async () => handleDuplicateAction("IMPORT"), + onUpdate: async () => handleDuplicateAction("IMPORT_WITHOUT_DELETE"), + onInfoAction: async () => handleDuplicateAction("IGNORE"), onCancel: () => { updateDialog(undefined); }, @@ -223,7 +229,7 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { case "INVALID_ORG_UNITS": { - const { invalidDataValues } = error; + const { invalidDataValues, dataValues } = error; const totalInvalid = _.flatMap( invalidDataValues.dataEntries, @@ -241,10 +247,16 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { }, onSave: async () => { updateDialog(undefined); - await startImport({ + const importParams = { ...params, - organisationUnitStrategy: "IGNORE", - }); + organisationUnitStrategy: "IGNORE" as const, + }; + if (dataValues.type === "dataSets") { + setPendingImportParams(importParams); + setShowCommentDialog(true); + } else { + await startImport(importParams); + } }, onInfoAction: () => { downloadInvalidOrganisations(invalidDataValues); @@ -272,6 +284,21 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { }); }; + const handleCommentDialogContinue = async (comment: string | undefined) => { + setShowCommentDialog(false); + if (pendingImportParams) { + loading.show(true, i18n.t("Importing data...")); + await startImport({ ...pendingImportParams, comment }); + loading.reset(); + setPendingImportParams(undefined); + } + }; + + const handleCommentDialogCancel = () => { + setShowCommentDialog(false); + setPendingImportParams(undefined); + }; + const downloadInvalidOrganisations = (dataPackage: TemplateDataPackage) => { const object = compositionRoot.form.convertDataPackage(templateToDataPackage(dataPackage)); const json = JSON.stringify(object, null, 4); @@ -303,6 +330,12 @@ export default function ImportTemplatePage({ settings }: RouteComponentProps) { {syncResults && } + +

{i18n.t("Bulk data import")}