From 70832a099e206b6b53cd6cfff45fb840ceeeee3b Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Thu, 13 Jun 2024 18:12:18 +0530 Subject: [PATCH] feat: print --- electron.vite.config.ts | 10 +- package.json | 2 +- pnpm-lock.yaml | 8 +- resources/templates/surgery-opnote.hbs | 98 +++++++ src/main/index.ts | 21 +- src/preload/index.d.ts | 3 + src/preload/index.ts | 12 +- src/preload/interfaces.ts | 4 + src/renderer/print.html | 17 +- src/renderer/src/lib/print.ts | 30 ++ src/renderer/src/lib/utils.ts | 12 + src/renderer/src/main.tsx | 4 - src/renderer/src/print.tsx | 60 ++++ .../src/routes/surgeries/edit-surgery.tsx | 5 +- .../src/routes/surgeries/view-surgery.tsx | 26 +- src/renderer/src/styles/print.css | 35 +++ src/renderer/src/styles/reset.css | 138 +++++++++ src/renderer/src/styles/screen.css | 262 ++++++++++++++++++ 18 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 resources/templates/surgery-opnote.hbs create mode 100644 src/preload/interfaces.ts create mode 100644 src/renderer/src/lib/print.ts create mode 100644 src/renderer/src/print.tsx create mode 100644 src/renderer/src/styles/print.css create mode 100644 src/renderer/src/styles/reset.css create mode 100644 src/renderer/src/styles/screen.css diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 29293e9..d7f0cda 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -17,6 +17,14 @@ export default defineConfig({ '@': resolve(__dirname, 'src/renderer/src') } }, - plugins: [react()] + plugins: [react()], + build: { + rollupOptions: { + input: { + browser: resolve(__dirname, 'src/renderer/index.html'), + print: resolve(__dirname, 'src/renderer/print.html') + } + } + } } }) diff --git a/package.json b/package.json index 3580d8f..7c8f51e 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "autoprefixer": "^10.4.19", "electron": "^28.2.0", "electron-builder": "^24.9.1", - "electron-vite": "^2.0.0", + "electron-vite": "^2.2.0", "eslint": "^8.56.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1aea2d..af1c57b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,8 +188,8 @@ devDependencies: specifier: ^24.9.1 version: 24.13.3(electron-builder-squirrel-windows@24.13.3) electron-vite: - specifier: ^2.0.0 - version: 2.1.0(vite@5.2.8) + specifier: ^2.2.0 + version: 2.2.0(vite@5.2.8) eslint: specifier: ^8.56.0 version: 8.57.0 @@ -4186,8 +4186,8 @@ packages: - supports-color dev: false - /electron-vite@2.1.0(vite@5.2.8): - resolution: {integrity: sha512-DjToUW6q3ILoW79b1yBywC6LubnOw5Axr2zo9cHMlYf00zAO8oVzrCcqinJQTTbJLvqCuVcBzuICMl5MYshUnQ==} + /electron-vite@2.2.0(vite@5.2.8): + resolution: {integrity: sha512-WvE8KlZTiay9uWqBRvnYuxg2JqIicaNqaz9qFvsZkIae2/FmqZC5jctziyduCuuwVxqJG0Sjh8RlTwSn8xcCoQ==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: diff --git a/resources/templates/surgery-opnote.hbs b/resources/templates/surgery-opnote.hbs new file mode 100644 index 0000000..95e2802 --- /dev/null +++ b/resources/templates/surgery-opnote.hbs @@ -0,0 +1,98 @@ +

+ {{settings.hospital}} +

+

+ {{settings.unit}} +

+ +
+

+ + Name: + + {{patient.name}} +

+

+ + Age: + + {{patient.age}} +

+

+ + BHT: + + {{surgery.bht}} +

+

+ + Ward: + + {{surgery.ward}} +

+

+ + Date: + + {{surgery.date}} +

+
+ +
+ +

+ {{surgery.title}} +

+ +
+
+ {{#if surgery.doneBy.length}} +
+
+ Done By: +
+
    + {{#each surgery.doneBy as |doctor|}} +
  • + {{doctor}} +
  • + {{/each}} +
+
+ {{/if}} + {{#if surgery.assistedBy.length}} +
+
+ Assisted By: +
+
    + {{#each surgery.assistedBy as |doctor|}} +
  • + {{doctor}} +
  • + {{/each}} +
+
+ {{/if}} +
+
+ +{{#if surgery.notes}} +
+
+ {{{surgery.notes}}} +
+
+{{/if}} + +{{#if surgery.post_op_notes}} +
+
+

+ Post-Op Notes +

+
+ {{{surgery.post_op_notes}}} +
+
+{{/if}} diff --git a/src/main/index.ts b/src/main/index.ts index d4d1e2a..07a53aa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { db, migrateToLatest } from './db' import { registerApi } from './api' import electronUpdater, { type AppUpdater } from 'electron-updater' +import { PrintDialogArgs } from '../preload/interfaces' export function getAutoUpdater(): AppUpdater { // Using destructuring to access autoUpdater due to the CommonJS module of 'electron-updater'. @@ -105,11 +106,9 @@ function createPrinterWindow() { if (is.dev && process.env['ELECTRON_RENDERER_URL']) { workerWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/print.html`) } else { - workerWindow.loadFile(join(__dirname, '../renderer/print.html'), { hash: '/print' }) + workerWindow.loadFile(join(__dirname, '../renderer/print.html')) } - workerWindow.hide() - return workerWindow } @@ -147,6 +146,22 @@ app.whenReady().then(() => { return true }) + + ipcMain.handle('printDialog', async (event, options: PrintDialogArgs) => { + const workerWindow = createPrinterWindow() + workerWindow.once('ready-to-show', () => { + const windowTitle = options.title || 'Print Preview' + workerWindow.setTitle(windowTitle) + + workerWindow.webContents.send('printData', options) + workerWindow.show() + // open devtools + + if (is.dev) { + workerWindow.webContents.openDevTools() + } + }) + }) }) // Quit when all windows are closed, except on macOS. There, it's common diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 116f636..7babf02 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,9 +1,12 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { ApiType } from '../main/api' +import { PrintDialogArgs } from './interfaces' type ElectronApi = ElectronAPI & { getAppVersion: () => Promise boot: () => Promise + openPrintDialog: (options: PrintDialogArgs) => Promise + onPrintData: (callback: (options: PrintDialogArgs) => void) => void } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index ef4da35..f99a669 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import { PrintDialogArgs } from './interfaces' // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise @@ -21,10 +22,19 @@ const boot = async () => { return await ipcRenderer.invoke('boot') } +const openPrintDialog = async (options: PrintDialogArgs) => { + return await ipcRenderer.invoke('printDialog', options) +} + +const onPrintData = (callback: (options: PrintDialogArgs) => void) => + ipcRenderer.on('printData', (_, options) => callback(options)) + const electronApi = { ...electronAPI, getAppVersion, - boot + boot, + openPrintDialog, + onPrintData } if (process.contextIsolated) { diff --git a/src/preload/interfaces.ts b/src/preload/interfaces.ts new file mode 100644 index 0000000..dfa1697 --- /dev/null +++ b/src/preload/interfaces.ts @@ -0,0 +1,4 @@ +export interface PrintDialogArgs { + title?: string + data?: object +} diff --git a/src/renderer/print.html b/src/renderer/print.html index 1b24e25..435c2e7 100644 --- a/src/renderer/print.html +++ b/src/renderer/print.html @@ -1,17 +1,20 @@ - + - OpNotes - - + + Print Preview + + + + + - +
- + diff --git a/src/renderer/src/lib/print.ts b/src/renderer/src/lib/print.ts new file mode 100644 index 0000000..a55f4b9 --- /dev/null +++ b/src/renderer/src/lib/print.ts @@ -0,0 +1,30 @@ +import { PatientModel } from '@shared/models/PatientModel' +import { SurgeryModel } from '@shared/models/SurgeryModel' +import { formatDate, isEmptyHtml } from './utils' + +export const surgeryPrintData = ( + patient?: PatientModel, + surgery?: SurgeryModel, + settings?: Record +) => ({ + patient: { + ...patient, + age: patient?.age ? `${patient?.age} yrs` : `<1 yrs` + }, + surgery: { + ...surgery, + doneBy: surgery?.doneBy + ? surgery?.doneBy.map((doctor) => `Dr. ${doctor.name} (${doctor.designation})`) + : [], + assistedBy: surgery?.assistedBy + ? surgery?.assistedBy.map((doctor) => `Dr. ${doctor.name} (${doctor.designation})`) + : [], + date: surgery?.date ? formatDate(surgery?.date) : null, + notes: isEmptyHtml(surgery?.notes) ? null : surgery?.notes, + post_op_notes: isEmptyHtml(surgery?.post_op_notes) ? null : surgery?.post_op_notes + }, + settings: { + hospital: settings?.hospital || '', + unit: settings?.unit || '' + } +}) diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils.ts index 9a6bb66..19a7d7b 100644 --- a/src/renderer/src/lib/utils.ts +++ b/src/renderer/src/lib/utils.ts @@ -26,3 +26,15 @@ export const formatDate = (date?: Date) => dayjs(date).format('DD/MM/YYYY') export const formatDateTime = (date?: Date) => dayjs(date).format('DD/MM/YYYY HH:mm') export const formatTime = (date?: Date) => dayjs(date).format('HH:mm') + +export const isEmptyString = (str?: string | null) => str && str.trim().length === 0 + +export const isEmptyHtml = (html?: string | null) => { + if (!html) { + return true + } + + const tempEl = document.createElement('div') + tempEl.innerHTML = html + return !tempEl.textContent?.trim() +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 1d75e1c..229c0ee 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -98,10 +98,6 @@ const router = createHashRouter([ element: } ] - }, - { - path: '/print', - element:
Printing
} ]) diff --git a/src/renderer/src/print.tsx b/src/renderer/src/print.tsx new file mode 100644 index 0000000..fee4b39 --- /dev/null +++ b/src/renderer/src/print.tsx @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import React, { useEffect, useMemo } from 'react' +import ReactDOM from 'react-dom/client' +import handlebars from 'handlebars' +import surgeryOpNoteTemplate from '../../../resources/templates/surgery-opnote.hbs?raw' +import { Button } from './components/ui/button' + +const printTemplate = handlebars.compile(surgeryOpNoteTemplate) + +const Print = () => { + const [printData, setPrintData] = React.useState(null) + useEffect(() => { + const handlePrintData = (data) => { + setPrintData(data) + } + + window.electronApi.onPrintData(handlePrintData) + }, []) + + const printHtml = useMemo(() => { + if (!printData) { + return '' + } + return printTemplate((printData as any).data) + }, [printData]) + + const handlePrint = () => { + window.print() + } + + useEffect(() => { + const keyboardListener = (e: KeyboardEvent) => { + if (e.key === 'p' && e.ctrlKey) { + handlePrint() + } + } + + window.addEventListener('keydown', keyboardListener) + + return () => { + window.removeEventListener('keydown', keyboardListener) + } + }, []) + + return ( +
+
+ +
+ {printData &&
} +
+ ) +} + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/src/renderer/src/routes/surgeries/edit-surgery.tsx b/src/renderer/src/routes/surgeries/edit-surgery.tsx index 74a2574..3b2ba17 100644 --- a/src/renderer/src/routes/surgeries/edit-surgery.tsx +++ b/src/renderer/src/routes/surgeries/edit-surgery.tsx @@ -2,7 +2,7 @@ import { Button } from '@renderer/components/ui/button' import { useBreadcrumbs } from '@renderer/contexts/BreadcrumbContext' import { AppLayout } from '@renderer/layouts/AppLayout' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Printer, Save } from 'lucide-react' +import { Save } from 'lucide-react' import { useEffect, useMemo, useRef } from 'react' import { useParams } from 'react-router-dom' import { getPatientByIdQuery } from '../patients/edit-patient' @@ -58,9 +58,6 @@ export const EditSurgery = () => { > Save - ) diff --git a/src/renderer/src/routes/surgeries/view-surgery.tsx b/src/renderer/src/routes/surgeries/view-surgery.tsx index b2dfb85..bd616d7 100644 --- a/src/renderer/src/routes/surgeries/view-surgery.tsx +++ b/src/renderer/src/routes/surgeries/view-surgery.tsx @@ -12,7 +12,7 @@ import { PatientModel } from 'src/shared/models/PatientModel' import { Badge } from '@renderer/components/ui/badge' import { AddOrEditFollowup } from '@renderer/components/surgery/AddOrEditFollowup' import { Card, CardContent, CardHeader } from '@renderer/components/ui/card' -import { cn, formatDate, formatDateTime, unwrapResult } from '@renderer/lib/utils' +import { cn, formatDate, formatDateTime, isEmptyHtml, unwrapResult } from '@renderer/lib/utils' import { Skeleton } from '@renderer/components/ui/skeleton' import { FollowupModel } from 'src/shared/models/FollowupModel' import { @@ -27,6 +27,8 @@ import { AlertDialogTrigger } from '@renderer/components/ui/alert-dialog' import { DoctorModel } from '@shared/models/DoctorModel' +import { useSettings } from '@renderer/contexts/SettingsContext' +import { surgeryPrintData } from '@renderer/lib/print' const getSurgeryByIdQuery = (id: number) => queries.surgeries.get(id) const getSurgeryFollowupsQuery = (surgeryId: number) => queries.surgeries.getFollowups(surgeryId) @@ -202,9 +204,9 @@ export const SurgeryCard = ({ surgery, patient }: SurgeryCardProps) => {
Notes - {surgery.notes ? ( + {!isEmptyHtml(surgery.notes) ? ( ) : ( @@ -217,9 +219,9 @@ export const SurgeryCard = ({ surgery, patient }: SurgeryCardProps) => {
Post Op Notes - {surgery.post_op_notes ? ( + {!isEmptyHtml(surgery.post_op_notes) ? ( ) : ( @@ -262,6 +264,7 @@ export const ViewSurgery = () => { const navigate = useNavigate() const { patientId, surgeryId } = useParams() const { setBreadcrumbs } = useBreadcrumbs() + const { settings } = useSettings() const { data: patient } = useQuery({ ...getPatientByIdQuery(parseInt(patientId!)), enabled: !!patientId @@ -292,10 +295,19 @@ export const ViewSurgery = () => { } }, [setBreadcrumbs, patient, ptName, surgeryName, surgeryId, surgery?.bht]) + const handlePrint = async () => { + if (!patient || !surgery) return + + const printData = surgeryPrintData(patient, surgery, settings) + await window.electronApi.openPrintDialog({ + title: `${ptName} - ${surgeryName}`, + data: printData + }) + } + const actions = ( <> - diff --git a/src/renderer/src/styles/print.css b/src/renderer/src/styles/print.css new file mode 100644 index 0000000..4e10c5c --- /dev/null +++ b/src/renderer/src/styles/print.css @@ -0,0 +1,35 @@ +.prose h1, +.prose h2, +.prose h3, +.prose h4, +.prose h5, +.prose h6, +.prose p, +.prose blockquote { + page-break-inside: avoid; +} + +.prose h2 { + font-size: 24pt; +} + +.prose h3 { + font-size: 18pt; +} + +.page-break { + display: block; + page-break-before: always; +} + +@page { + size: A4; + margin-top: 0.75in; + margin-bottom: 0.75in; + margin-left: 0.75in; + margin-right: 0.75in; +} + +.print-hidden { + display: none; +} diff --git a/src/renderer/src/styles/reset.css b/src/renderer/src/styles/reset.css new file mode 100644 index 0000000..f56c4a0 --- /dev/null +++ b/src/renderer/src/styles/reset.css @@ -0,0 +1,138 @@ +/* Reset and general styles */ +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* HTML5 display-role reset for older browsers */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} + +body { + line-height: 1; +} + +ol, +ul { + list-style: none; +} + +blockquote, +q { + quotes: none; +} + +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ''; + content: none; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +/* Print-friendly body styles */ +body { + height: 100%; + box-sizing: border-box; + /* Ensure padding is included in height */ +} diff --git a/src/renderer/src/styles/screen.css b/src/renderer/src/styles/screen.css new file mode 100644 index 0000000..0e9c84e --- /dev/null +++ b/src/renderer/src/styles/screen.css @@ -0,0 +1,262 @@ +body { + font-family: 'Times New Roman', Times, serif; +} + +/* Text size classes */ +.text-xs { + font-size: 12px; +} + +.text-sm { + font-size: 16px; +} + +.text-md { + font-size: 20px; +} + +.text-lg { + font-size: 24px; +} + +.text-xl { + font-size: 32px; +} + +.text-2xl { + font-size: 38px; +} + +.text-3xl { + font-size: 48px; +} + +.text-4xl { + font-size: 56px; +} + +.text-5xl { + font-size: 64px; +} + +/* Text style classes */ +.bold { + font-weight: bold; +} + +.italic { + font-style: italic; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-bold { + font-weight: bold; +} + +.text-underline { + text-decoration: underline; +} + +.text-italic { + font-style: italic; +} + +.text-uppercase { + text-transform: uppercase; +} + +.text-lowercase { + text-transform: lowercase; +} + +/* Flexbox utilities */ +.flex { + display: flex; +} + +.flex-1 { + flex: 1; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.p-1 { + padding: 4px; +} + +.p-2 { + padding: 8px; +} + +.p-3 { + padding: 12px; +} + +.pt-1 { + padding-top: 4px; +} + +.pt-2 { + padding-top: 8px; +} + +.pt-3 { + padding-top: 12px; +} + +.mt-1 { + margin-top: 4px; +} + +.mt-2 { + margin-top: 8px; +} + +.mt-3 { + margin-top: 12px; +} + +.gap-1 { + gap: 4px; +} + +.gap-2 { + gap: 8px; +} + +.justify-between { + justify-content: space-between; +} + +.justify-end { + justify-content: flex-end; +} + +.items-center { + align-items: center; +} + +.items-end { + align-items: flex-end; +} + +.flex-basis-33 { + flex-basis: 33%; + flex-grow: 1; +} + +/* Grid utilities */ +.grid { + display: grid; +} + +.grid-cols-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, 1fr); +} + +.disc { + list-style-type: disc; +} + +/* Prose styles */ +.prose { + font-family: 'Times New Roman', Times, serif; + color: #000; + margin: 0; + /* Remove default margin */ +} + +.prose h1, +.prose h2, +.prose h3, +.prose h4, +.prose h5, +.prose h6 { + margin-top: 1em; + margin-bottom: 0.5em; +} + +.prose h1 { + font-size: 40px; +} + +.prose h2 { + font-size: 36px; + /* 40px - 4px */ +} + +.prose h3 { + font-size: 32px; + /* 36px - 4px */ +} + +.prose h4 { + font-size: 28px; + /* 32px - 4px */ +} + +.prose h5 { + font-size: 24px; + /* 28px - 4px */ +} + +.prose h6 { + font-size: 20px; + /* 20px as specified */ +} + +.prose ul { + list-style-type: disc; + margin-left: 20px; + margin-bottom: 1em; +} + +.prose ol { + list-style-type: decimal; + margin-left: 20px; + margin-bottom: 1em; +} + +.prose ul li, +.prose ol li { + margin-bottom: 0.5em; +} + +.prose blockquote { + margin: 1em 20px; + padding-left: 10px; + border-left: 3px solid #ccc; + color: #666; + font-style: italic; +} + +.prose p { + padding-top: 0.5em; +} + +.prose b, +.prose strong { + font-weight: bold; +} + +.prose i, +.prose em { + font-style: italic; +}