+ {#
+ Generic header block — renders facility name and document title centered,
+ matching the compact card layout. Override in child templates when needed.
+ #}
+ {% block header %}
+ {% if config.facilityName or config.header %}
+
+ {% if config.header %}
+
{{ config.header | t }}
+ {% endif %}
+ {% endif %}
+ {% endblock %}
+
+ {% block content %}{% endblock %}
+
+ {#
+ Generic footer block — renders config.footer text when present.
+ Override when template-specific footer content is needed.
+ #}
+ {% block footer %}
+ {% if config.footer %}
+
+ {{ config.footer }}
+
+ {% endif %}
+ {% endblock %}
+
+
+
diff --git a/print-templates/_base/portrait.html b/print-templates/_base/portrait.html
new file mode 100644
index 000000000..bf9ac5484
--- /dev/null
+++ b/print-templates/_base/portrait.html
@@ -0,0 +1,201 @@
+
+
+
+
+ {#
+ Generic header block — rendered automatically for every template that
+ extends this base. A child template overrides {% block header %} when it
+ needs a completely different layout; otherwise it gets this for free just
+ by setting config.facilityName / config.header in templates.json.
+ #}
+ {% block header %}
+ {% if config.facilityName or config.header %}
+
+ {% endif %}
+ {% endblock %}
+
+ {% block content %}{% endblock %}
+
+ {#
+ Generic footer block — renders config.footer text when present.
+ Override this block to add template-specific elements like signature lines.
+ #}
+ {% block footer %}
+ {% if config.footer %}
+
+ {% endif %}
+ {% endblock %}
+
+
+
diff --git a/print-templates/_i18n/en.json b/print-templates/_i18n/en.json
new file mode 100644
index 000000000..fdce0eb68
--- /dev/null
+++ b/print-templates/_i18n/en.json
@@ -0,0 +1,26 @@
+{
+ "DATE": "Date",
+ "REGISTRATION_CARD": "REGISTRATION CARD",
+ "NOTICE": "Notice",
+ "CAUTION": "Caution",
+ "DATED": "Dated",
+ "REGISTRATION": "Registration",
+ "NAME": "Name",
+ "AGE": "Age",
+ "YEAR_S": "Year(s)",
+ "GENDER": "Gender",
+ "ADDRESS": "Address",
+ "VILLAGE": "Village",
+ "TEHSIL": "Tehsil",
+ "REGISTRATION_INSTITUTION_NAME": "Hospital Bahmni",
+ "BAHMNI": "Bahmni",
+ "REGISTRATION_INSTITUTION_REGISTERED": "(Registered)",
+ "REGISTRATION_INSTITUTION_ADDRESS": "Ganiyari, District - Bilaspur",
+ "NO_MEDICATIONS_PRESCRIBED": "No medications prescribed for this visit",
+ "NOTICE_BRING_CARD": "It is quite important to bring the card to the hospital.",
+ "NOTICE_TAKE_MEDICINES": "Take medicines according to instructions of the doctor.",
+ "NOTICE_REGISTRATION_TIMINGS": "Registration Timings are from 8:30 am to 12:00 pm.",
+ "NOTICE_OPD_DAYS": "OPD days are on Monday, Wednesday, Friday.",
+ "NOTICE_SUNDAY_HOLIDAY": "Sundays are holidays.",
+ "CAUTION_SMOKING": "Smoking, Tobacco chewing can cause cancer, heart diseases and ASTHMA."
+}
diff --git a/print-templates/_i18n/es.json b/print-templates/_i18n/es.json
new file mode 100644
index 000000000..99d500ab6
--- /dev/null
+++ b/print-templates/_i18n/es.json
@@ -0,0 +1,26 @@
+{
+ "DATE": "Fecha",
+ "REGISTRATION_CARD": "TARJETA DE REGISTRO",
+ "NOTICE": "Aviso",
+ "CAUTION": "Precaución",
+ "DATED": "Fechado",
+ "REGISTRATION": "Registro",
+ "NAME": "Nombre",
+ "AGE": "Edad",
+ "YEAR_S": "Año(s)",
+ "GENDER": "Género",
+ "ADDRESS": "Dirección",
+ "VILLAGE": "Pueblo",
+ "TEHSIL": "Tehsil",
+ "REGISTRATION_INSTITUTION_NAME": "Hospital Bahmni",
+ "BAHMNI": "Bahmni",
+ "REGISTRATION_INSTITUTION_REGISTERED": "(Registrado)",
+ "REGISTRATION_INSTITUTION_ADDRESS": "Ganiyari, Distrito - Bilaspur",
+ "NO_MEDICATIONS_PRESCRIBED": "No se prescribieron medicamentos para esta visita",
+ "NOTICE_BRING_CARD": "Es muy importante traer la tarjeta al hospital.",
+ "NOTICE_TAKE_MEDICINES": "Tome los medicamentos según las instrucciones del médico.",
+ "NOTICE_REGISTRATION_TIMINGS": "El horario de registro es de 8:30 a. m. a 12:00 p. m.",
+ "NOTICE_OPD_DAYS": "Los días de consulta externa son lunes, miércoles y viernes.",
+ "NOTICE_SUNDAY_HOLIDAY": "Los domingos son días festivos.",
+ "CAUTION_SMOKING": "Fumar y mascar tabaco puede causar cáncer, enfermedades cardíacas y ASMA."
+}
diff --git a/print-templates/prescriptions/compute.js b/print-templates/prescriptions/compute.js
new file mode 100644
index 000000000..18608b903
--- /dev/null
+++ b/print-templates/prescriptions/compute.js
@@ -0,0 +1,137 @@
+module.exports = {
+ compute: async function ({ context, resolved, ValidationError, fhirPath,translate }) {
+ if (!context?.patientUUID) throw new ValidationError('patientUUID is required');
+ if (!context?.visitUuid) throw new ValidationError('visitUuid is required');
+
+ // Patient demographics
+ const patientBundle = resolved?.patient;
+ const patientName = fhirPath(patientBundle, "Bundle.entry.first().resource.name.first().text") ?? '';
+ const patientId = fhirPath(patientBundle, "Bundle.entry.first().resource.identifier.where(use = 'official').first().value") ?? '';
+ const birthDate = fhirPath(patientBundle, "Bundle.entry.first().resource.birthDate") ?? '';
+ const gender = fhirPath(patientBundle, "Bundle.entry.first().resource.gender") ?? '';
+ const village = fhirPath(patientBundle, "Bundle.entry.first().resource.address.first().city") ?? '';
+ const district = fhirPath(patientBundle, "Bundle.entry.first().resource.address.first().district") ?? '';
+ const postalAddress = fhirPath(patientBundle,
+ "Bundle.entry.first().resource.address.first().extension.where(url = 'http://fhir.openmrs.org/ext/address').extension.where(url = 'http://fhir.openmrs.org/ext/address#address2').valueString") ?? '';
+
+ // Extract resources from the medication bundle by type
+ const mrBundle = resolved?.medicationRequests;
+ const visitRef = `Encounter/${context.visitUuid}`;
+ const medicationResources = toArray(fhirPath(mrBundle, "Bundle.entry.where(resource.resourceType = 'Medication').resource"));
+ const visitEncounters = toArray(fhirPath(mrBundle, `Bundle.entry.where(resource.resourceType = 'Encounter' and resource.partOf.reference = '${visitRef}').resource`));
+ const encounterResources = toArray(fhirPath(mrBundle, "Bundle.entry.where(resource.resourceType = 'Encounter').resource"));
+ const allMedRequests = toArray(fhirPath(mrBundle, "Bundle.entry.where(resource.resourceType = 'MedicationRequest').resource"));
+
+ const medicationMap = new Map(medicationResources.map((m) => [m.id, m.form?.text ?? m.form?.coding?.[0]?.display ?? '']));
+ const visitEncounterIds = new Set(visitEncounters.map((enc) => enc.id));
+ const encounterMap = new Map(encounterResources.map((e) => [e.id, e]));
+
+ // Group visit medications by encounter
+ const byEncounter = allMedRequests
+ .filter((mr) => visitEncounterIds.has(refId(mr.encounter?.reference)))
+ .reduce((map, mr) => {
+ const encId = refId(mr.encounter?.reference);
+ if (!map.has(encId)) map.set(encId, []);
+ map.get(encId).push(mr);
+ return map;
+ }, new Map());
+
+ const firstStart = [...byEncounter.keys()]
+ .map((id) => fhirPath(encounterMap.get(id), 'period.start'))
+ .find(Boolean);
+
+ const medications = [...byEncounter.entries()].map(([encId, meds]) => ({
+ doctorName: fhirPath(encounterMap.get(encId), 'participant.first().individual.display') ?? '',
+ drugOrders: meds.map((mr) => {
+ const stopped = ['stopped', 'cancelled'].includes(mr.status);
+ const baseName = mr.medicationCodeableConcept?.text ?? mr.medicationReference?.display ?? '';
+ const dosageForm = medicationMap.get(refId(mr.medicationReference?.reference)) ?? '';
+
+ return {
+ drugName: dosageForm ? `${baseName} (${dosageForm})` : baseName,
+ dosageInstructions: buildDosageInstructions(mr.dosageInstruction),
+ startDate: mr.authoredOn ?? '',
+ stopped,
+ stoppedDate: stopped ? fhirPath(mr, 'meta.lastUpdated') ?? '' : '',
+ treatmentNotes: parseAdditionalInstructions(mr.dosageInstruction?.[0]?.text),
+ };
+ }),
+ }));
+
+ if (medications.length === 0) throw new ValidationError(translate("NO_MEDICATIONS_PRESCRIBED"));
+
+ return {
+ patientName,
+ patientId,
+ birthDate,
+ gender,
+ village,
+ postalAddress,
+ district,
+ visitDate: firstStart ?? '',
+ medications,
+ };
+ },
+};
+
+// evaluateFhirPath returns the item directly (not array) when there is exactly one result,
+// so always normalise to an array before iterating.
+function toArray(val) {
+ if (val == null) return [];
+ return Array.isArray(val) ? val : [val];
+}
+
+function refId(reference) {
+ return reference?.split('/')?.[1] ?? '';
+}
+
+function buildDosageInstructions(dosageInstruction) {
+ const d = dosageInstruction?.[0];
+ if (!d) return '';
+
+ const parts = [];
+ const doseQty = d.doseAndRate?.[0]?.doseQuantity;
+ if (doseQty?.value != null) parts.push(`${doseQty.value} ${doseQty.unit ?? ''}`.trim());
+
+ const frequency = d.timing?.code?.text;
+ if (frequency) parts.push(frequency);
+
+ const instructions = parseInstructions(d.text);
+ if (instructions) parts.push(instructions);
+
+ if (d.asNeededBoolean) parts.push('SOS');
+
+ const route = d.route?.text;
+ if (route) parts.push(route);
+
+ const repeat = d.timing?.repeat;
+ if (repeat?.duration != null) {
+ return `${parts.join(', ')} - ${repeat.duration} ${durationLabel(repeat.durationUnit)}`;
+ }
+ return parts.join(', ');
+}
+
+function parseInstructions(text) {
+ if (!text) return '';
+ try {
+ const instr = JSON.parse(text)?.instructions ?? '';
+ return instr.toLowerCase() === 'as directed' ? '' : instr;
+ } catch {
+ return '';
+ }
+}
+
+function parseAdditionalInstructions(text) {
+ if (!text) return '';
+ try {
+ return JSON.parse(text)?.additionalInstructions ?? '';
+ } catch {
+ return '';
+ }
+}
+
+function durationLabel(code) {
+ const map = { s: 'Seconds', min: 'Minutes', h: 'Hours', d: 'Days', wk: 'Weeks', mo: 'Months', a: 'Years' };
+ return map[code] ?? code ?? '';
+}
+
diff --git a/print-templates/prescriptions/data-config.json b/print-templates/prescriptions/data-config.json
new file mode 100644
index 000000000..82d934240
--- /dev/null
+++ b/print-templates/prescriptions/data-config.json
@@ -0,0 +1,21 @@
+{
+ "sources": {
+ "patient": {
+ "api": "fhir",
+ "resource": "Patient",
+ "params": {
+ "_id": "{{patientUUID}}"
+ }
+ },
+ "medicationRequests": {
+ "api": "fhir",
+ "resource": "MedicationRequest",
+ "params": {
+ "patient": "{{patientUUID}}",
+ "_include": ["MedicationRequest:encounter", "MedicationRequest:medication"],
+ "_sort": "-_lastUpdated",
+ "_count": "100"
+ }
+ }
+ }
+}
diff --git a/print-templates/prescriptions/styles.css b/print-templates/prescriptions/styles.css
new file mode 100644
index 000000000..ec642dae1
--- /dev/null
+++ b/print-templates/prescriptions/styles.css
@@ -0,0 +1,211 @@
+/* ---------------------------------------------------------------
+ Override base portrait.html section-title (remove border-bottom)
+--------------------------------------------------------------- */
+.section-title {
+ border: 0;
+ border-bottom: none;
+ padding: 0;
+ padding-bottom: 0;
+ margin: 0;
+ font-size: 9.5pt;
+ font-weight: bold;
+ text-decoration: underline;
+ text-transform: uppercase;
+ font-family: Arial, sans-serif;
+ color: #000;
+ background: none;
+}
+
+/* ---------------------------------------------------------------
+ Print header
+--------------------------------------------------------------- */
+.print-header {
+ text-align: center;
+ color: #000;
+ margin-bottom: 20px;
+}
+
+.print-header h1 {
+ font-size: 14pt;
+ font-weight: bold;
+ color: #000;
+ font-family: Arial, sans-serif;
+ margin-bottom: 0;
+}
+
+.print-title-with-logo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.print-title-with-logo > div {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+
+.print-title-with-logo img {
+ flex-shrink: 0;
+}
+
+/* Location name row — bold, 12pt, centered */
+.clinic-info {
+ font-size: 12pt;
+ font-weight: bold;
+ width: 50%;
+ margin: 1% auto 0;
+ text-align: center;
+}
+
+.clinic-info span {
+ display: block;
+}
+
+/* Address row — normal weight, smaller */
+.clinic-info .address {
+ font-weight: normal;
+ font-size: 10pt;
+ margin-top: 4px;
+}
+
+/* ---------------------------------------------------------------
+ Patient info box
+--------------------------------------------------------------- */
+.patient-info {
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ margin-bottom: 10px;
+}
+
+.patient-info table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.patient-info td {
+ font-size: 9pt;
+ font-weight: bold;
+ min-width: 1.75cm;
+ padding: 5px 10px;
+ border: 1px solid #aaa;
+}
+
+.patient-info td .value {
+ font-weight: normal;
+}
+
+/* ---------------------------------------------------------------
+ Treatment section
+--------------------------------------------------------------- */
+.treatment-section {
+ margin-top: 8px;
+}
+
+.encounter-info {
+ font-size: 9.5pt;
+ font-weight: bold;
+ color: #000;
+ font-family: Arial, sans-serif;
+ margin-top: 1%;
+ margin-bottom: 0;
+ border: 0;
+ padding: 0;
+ text-decoration: none;
+}
+
+/* ---------------------------------------------------------------
+ Prescription drug table
+ Header