+
+
+
+ );
+}
diff --git a/ui/src/constants/metadata.ts b/ui/src/constants/metadata.ts
index db670a36..58fd5203 100644
--- a/ui/src/constants/metadata.ts
+++ b/ui/src/constants/metadata.ts
@@ -36,12 +36,33 @@ const schoolIdTypeOptions = [
"Unknown",
] as const;
+const healthIdTypeOptions = [
+ "",
+ "dhis2_id",
+ "hims_id",
+ "hfml_id",
+ "Other",
+ "Unknown",
+] as const;
+
const yesNoUnknownOptions = ["", "Yes", "No", "Unknown"] as const;
const requiredFieldErrorMessage = "This field is required";
const notInRangeErrorMessage = "Selection is not within the provided range";
+/** Section headings used as keys on `metadataMapping` and for school metadata grid layout. */
+export const schoolMetadataDatasetSection =
+ "Information about the school dataset";
+export const schoolMetadataNationalSection =
+ "Information about national school data collection practices";
+
+/** Section headings used as keys on `health` and for health metadata grid layout. */
+export const healthMetadataDatasetSection =
+ "Information about the health dataset";
+export const healthMetadataNationalSection =
+ "Information about national health data collection practices";
+
export const metadataMapping: Record = {
"": [
{
@@ -61,7 +82,7 @@ export const metadataMapping: Record = {
validator: z.string().min(1, { message: requiredFieldErrorMessage }),
},
],
- "Information about the school dataset": [
+ [schoolMetadataDatasetSection]: [
{
name: "focal_point_name",
label: "Focal point name",
@@ -128,7 +149,7 @@ export const metadataMapping: Record = {
validator: z.string().optional(),
},
],
- "Information about national school data collection practices": [
+ [schoolMetadataNationalSection]: [
{
name: "frequency_of_school_data_collection",
label: "Frequency of data collection or update",
@@ -186,6 +207,152 @@ export const metadataMapping: Record = {
],
};
+/** Same field `name`s as school mapping (Zod / API JSON keys); health-specific labels and section titles. */
+export const health: Record = {
+ "": [
+ {
+ name: "country",
+ label: "Country",
+ helperText: "",
+ type: "text",
+ required: true,
+ validator: z.string().min(1, { message: requiredFieldErrorMessage }),
+ },
+ {
+ name: "description",
+ label: "Health dataset description",
+ helperText: "e.g. indicator set, time period, collection purpose",
+ type: "text",
+ required: true,
+ validator: z.string().min(1, { message: requiredFieldErrorMessage }),
+ },
+ ],
+ [healthMetadataDatasetSection]: [
+ {
+ name: "focal_point_name",
+ label: "Person uploading / focal point name",
+ helperText: "Name of the person submitting or compiling this dataset",
+ type: "text",
+ required: true,
+ validator: z.string().min(1, { message: requiredFieldErrorMessage }),
+ },
+ {
+ name: "focal_point_contact",
+ label: "Focal point email",
+ helperText: "Email of the person responsible for this upload",
+ type: "text",
+ required: true,
+ validator: z.string().email(),
+ },
+ {
+ name: "data_owner",
+ label: "Health data owner/s",
+ helperText:
+ "e.g. Ministry of Health, national statistics office, implementing partner",
+ type: "text",
+ required: true,
+ validator: z.string().min(1, { message: requiredFieldErrorMessage }),
+ },
+ {
+ name: "year_of_data_collection",
+ label: "Year the data refers to",
+ helperText: "Select year",
+ type: "year",
+ required: true,
+ validator: z.union([
+ z.string().max(0),
+ z.coerce
+ .number()
+ .min(unicefFoundingYear, notInRangeErrorMessage)
+ .max(currentYear, notInRangeErrorMessage),
+ ]),
+ },
+ {
+ name: "modality_of_data_collection",
+ label: "Modality of health data collection",
+ helperText: "Select an option",
+ type: "enum",
+ enum: modalityCollectionOptions,
+ required: false,
+ validator: z.enum(modalityCollectionOptions).optional(),
+ },
+ {
+ name: "school_ids_type",
+ label: "Primary record / facility ID type",
+ helperText: "How records are keyed in this file (if applicable)",
+ type: "enum",
+ enum: healthIdTypeOptions,
+ required: false,
+ validator: z.enum(healthIdTypeOptions).optional(),
+ },
+ {
+ name: "data_quality_issues",
+ label: "Health data gaps / quality issues",
+ helperText:
+ "Describe missing fields, coverage limits, known biases, or linkage issues",
+ type: "text",
+ required: false,
+ validator: z.string().optional(),
+ },
+ ],
+ [healthMetadataNationalSection]: [
+ {
+ name: "frequency_of_school_data_collection",
+ label: "Frequency of health data collection or update",
+ helperText: "Select an option",
+ type: "enum",
+ enum: frequencyCollectionOptions,
+ required: false,
+ validator: z.enum(frequencyCollectionOptions).optional(),
+ },
+ {
+ name: "next_school_data_collection",
+ label: "Date of the next scheduled health data collection",
+ helperText: "MM / YYYY",
+ type: "text",
+ required: false,
+ validator: z
+ .string()
+ .optional()
+ .refine(
+ val => !val?.trim() || /^(0[1-9]|1[0-2])\/\d{4}$/.test(val.trim()),
+ "Use MM/YYYY format (e.g. 01/2025)",
+ )
+ .refine(val => {
+ if (!val?.trim()) return true;
+ const match = val.trim().match(/^(0[1-9]|1[0-2])\/(\d{4})$/);
+ if (!match) return true;
+ const month = parseInt(match[1], 10);
+ const year = parseInt(match[2], 10);
+ return (
+ year > currentYear ||
+ (year === currentYear && month >= currentMonth)
+ );
+ }, "Date must be in the current month or in the future"),
+ },
+ {
+ name: "emis_system",
+ label:
+ "Is there a functioning national health information system (or equivalent EMR/registry)?",
+ helperText: "Select an option",
+ type: "enum",
+ enum: yesNoUnknownOptions,
+ required: false,
+ validator: z.enum(yesNoUnknownOptions).optional(),
+ },
+ {
+ name: "school_contacts",
+ label:
+ "Does the data owner have access to facility or respondent contact details (e.g. phone, email)?",
+ helperText: "Select an option",
+ type: "enum",
+ enum: yesNoUnknownOptions,
+ required: false,
+ validator: z.enum(yesNoUnknownOptions).optional(),
+ },
+ ],
+};
+
export const yearList = [
"",
...Array(currentYear - unicefFoundingYear + 1)
diff --git a/ui/src/routes/upload/$uploadGroup/$uploadType.tsx b/ui/src/routes/upload/$uploadGroup/$uploadType.tsx
index 757dca8b..1cd598ad 100644
--- a/ui/src/routes/upload/$uploadGroup/$uploadType.tsx
+++ b/ui/src/routes/upload/$uploadGroup/$uploadType.tsx
@@ -41,7 +41,9 @@ export const Route = createFileRoute("/upload/$uploadGroup/$uploadType")({
if (
uploadGroup === "other" &&
- !["unstructured", "structured", "schemaless"].includes(uploadType)
+ !["unstructured", "structured", "schemaless", "health"].includes(
+ uploadType,
+ )
) {
throw doRedirect;
}
@@ -89,6 +91,7 @@ function Layout() {
const isCoverage = uploadType === "coverage";
const isGeolocation = uploadType === "geolocation";
+ const isHealth = uploadGroup === "other" && uploadType === "health";
const title = uploadType.replace(/-/g, " ");
const isUnstructured =
@@ -109,7 +112,7 @@ function Layout() {
(isCoverage && hasCoverage) ||
(isGeolocation && hasGeolocation) ||
isAdmin ||
- ((isSchemaless || isSchemalessOptions) && hasAccess);
+ ((isSchemaless || isSchemalessOptions || isHealth) && hasAccess);
useEffect(() => {
return resetUploadSliceState;
@@ -158,6 +161,20 @@ function Layout() {
>
);
+ const HEALTH_DESCRIPTION = (
+ <>
+
+ Upload a health dataset CSV (up to the file size limit shown on the next
+ step). Please add health metadata—including who uploaded the file, the
+ period the data refers to, and a dataset description—before submitting.
+
+
+ Health facility data following the health schema, once uploaded, will be
+ available for querying in Superset and Health Master.
+
+ >
+ );
+
const COVERAGE_DESCRIPTION = (
<>
@@ -186,9 +203,13 @@ function Layout() {
-
{title}
+
+ {isHealth ? "Health dataset" : title}
+
- {title === "geolocation"
+ {isHealth
+ ? HEALTH_DESCRIPTION
+ : title === "geolocation"
? GEOLOCATION_DESCRIPTION
: title === "coverage"
? COVERAGE_DESCRIPTION
@@ -198,7 +219,25 @@ function Layout() {