first commit
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
release
|
||||
.env
|
||||
.DS_Store
|
||||
.cache
|
||||
*.log
|
||||
.wine/
|
||||
.npm/
|
||||
@@ -0,0 +1,95 @@
|
||||
# CTM Document Builder
|
||||
|
||||
Electron + React desktop app for CTM document workflows: instruction PDFs and vaccination records, backed by a **cloud PocketBase** instance for instruction storage.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure PocketBase (internal build)
|
||||
|
||||
Edit `electron/main/pocketbase-config.js` with your instance URL and superuser credentials. Values are bundled into the desktop app — no `.env` file is required.
|
||||
|
||||
### 3. Cloud PocketBase collection
|
||||
|
||||
Your PocketBase instance should have an `instructions` collection with:
|
||||
|
||||
- `vaccine` — Text
|
||||
- `instruction` — Text
|
||||
|
||||
The app authenticates as a `_superusers` account (PocketBase v0.23+).
|
||||
|
||||
### 4. Run the app
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Restart Electron after changes to `electron/main/` (e.g. PDF import).
|
||||
|
||||
## Features
|
||||
|
||||
Three tabs:
|
||||
|
||||
- **Instructions** — Compose instruction documents from library entries; reorder and edit on the canvas; export a portrait PDF with logo and header.
|
||||
- **Instruction Manager** — Create, edit, and delete instruction entries in PocketBase.
|
||||
- **Vaccination Record** — Patient header, up to 15 vaccination rows, and notes; export a landscape PDF; **Import from PDF** to reload a previously exported record.
|
||||
|
||||
## PDF export
|
||||
|
||||
PDFs are generated with Chromium `printToPDF` in a hidden Electron `BrowserWindow`. The app logo is loaded from `assets/logo.png` (bundled as `extraResources` in packaged builds).
|
||||
|
||||
## Vaccination record import (metadata-only)
|
||||
|
||||
Import reads **embedded machine-readable metadata** from PDFs exported by this app. There is no visual/layout fallback.
|
||||
|
||||
- Only PDFs with valid `CTM_VR` metadata can be imported.
|
||||
- **Last update** is always set to **today** on import (not read from the PDF).
|
||||
- Import **replaces** the current builder data (with a confirm dialog if data already exists).
|
||||
- Third-party PDFs, scanned documents, or files with stripped or tampered metadata will show an error.
|
||||
|
||||
## Metadata format (developers)
|
||||
|
||||
Vaccination record PDFs embed a hidden text payload for round-trip import.
|
||||
|
||||
**Wire format:**
|
||||
|
||||
```
|
||||
CTM_VR:<version>:<base64url(JSON)>
|
||||
```
|
||||
|
||||
**Current version:** `1`
|
||||
|
||||
**v1 JSON fields:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|--------|
|
||||
| `patientName` | string | |
|
||||
| `dateOfBirth` | string | ISO `YYYY-MM-DD` |
|
||||
| `notes` | string | |
|
||||
| `rows` | array | Max 15 rows |
|
||||
| `rows[].diseaseKey` | string | Key from `DISEASE_OPTIONS` |
|
||||
| `rows[].date` | string | ISO `YYYY-MM-DD` |
|
||||
| `rows[].productBrand` | string | |
|
||||
| `rows[].lotNumber` | string | |
|
||||
| `rows[].provider` | string | |
|
||||
|
||||
`lastUpdate` is intentionally omitted from metadata; the UI sets it on import.
|
||||
|
||||
**Implementation:** [`src/lib/vaccinationRecordMetadata.js`](src/lib/vaccinationRecordMetadata.js)
|
||||
|
||||
**Adding a new version:** register a parser in `VERSION_PARSERS`, bump `VR_METADATA_LATEST_VERSION` for export, and keep older parsers so previously exported PDFs still import.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` — Start Vite dev server + Electron
|
||||
- `npm run build` — Build and package with electron-builder
|
||||
- `npm run preview` — Vite preview (renderer only; no PDF export)
|
||||
|
||||
## Packaging
|
||||
|
||||
`assets/` (logo and icon) are included as `extraResources` for packaged builds. No local PocketBase binary is bundled.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,176 @@
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import * as pbService from './pocketbase.js'
|
||||
import { parseVaccinationRecordPdf } from './parseVaccinationRecordPdf.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
process.env.APP_ROOT = path.join(__dirname, '../..')
|
||||
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
? path.join(process.env.APP_ROOT, 'public')
|
||||
: RENDERER_DIST
|
||||
|
||||
let win = null
|
||||
const preload = path.join(__dirname, '../preload/index.mjs')
|
||||
const indexHtml = path.join(RENDERER_DIST, 'index.html')
|
||||
|
||||
async function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
title: 'CTM Document Builder',
|
||||
icon: path.join(process.env.APP_ROOT, 'assets', 'icon.ico'),
|
||||
width: 900,
|
||||
height: 700,
|
||||
webPreferences: {
|
||||
preload,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
await win.loadURL(VITE_DEV_SERVER_URL)
|
||||
win.webContents.openDevTools()
|
||||
} else {
|
||||
await win.loadFile(indexHtml)
|
||||
}
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('https:')) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await createWindow()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
win = null
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else {
|
||||
BrowserWindow.getAllWindows()[0].focus()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('pdf:getLogo', () => {
|
||||
const logoPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets', 'logo.png')
|
||||
: path.join(process.env.APP_ROOT, 'assets', 'logo.png')
|
||||
try {
|
||||
if (!fs.existsSync(logoPath)) return { base64: null }
|
||||
const buf = fs.readFileSync(logoPath)
|
||||
return { base64: buf.toString('base64') }
|
||||
} catch {
|
||||
return { base64: null }
|
||||
}
|
||||
})
|
||||
|
||||
const PDF_FOOTER_TEMPLATE = `
|
||||
<div style="width: 100%; font-size: 10px; color: #707070; padding: 0 0.67in; box-sizing: border-box;
|
||||
display: flex; justify-content: space-between; align-items: center; font-family: Helvetica, Arial, sans-serif;">
|
||||
<span class="date"></span>
|
||||
<span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
|
||||
</div>
|
||||
`.trim()
|
||||
|
||||
async function generatePdfFromHtml(html, { landscape = false, footer = true } = {}) {
|
||||
const printWin = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
|
||||
const tempPath = path.join(os.tmpdir(), `ctm-print-${Date.now()}.html`)
|
||||
try {
|
||||
fs.writeFileSync(tempPath, html, 'utf8')
|
||||
await printWin.loadFile(tempPath)
|
||||
await printWin.webContents.executeJavaScript(`
|
||||
(window.__pdfLayoutReady ?? Promise.all([
|
||||
document.fonts.ready,
|
||||
...Array.from(document.images).map((img) =>
|
||||
img.complete
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
img.onload = resolve
|
||||
img.onerror = resolve
|
||||
})
|
||||
),
|
||||
]))
|
||||
`)
|
||||
|
||||
const pdfBuffer = await printWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
landscape,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: footer ? PDF_FOOTER_TEMPLATE : '<div></div>',
|
||||
margins: { marginType: 'none' },
|
||||
})
|
||||
|
||||
return pdfBuffer
|
||||
} finally {
|
||||
printWin.destroy()
|
||||
try {
|
||||
fs.unlinkSync(tempPath)
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('pdf:generate', async (_, { html, landscape = false, footer = true }) => {
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new Error('PDF export requires HTML content')
|
||||
}
|
||||
const pdfBuffer = await generatePdfFromHtml(html, { landscape, footer })
|
||||
return { base64: pdfBuffer.toString('base64') }
|
||||
})
|
||||
|
||||
ipcMain.handle('pdf:save', async (_, { base64, defaultName = 'document.pdf' }) => {
|
||||
const w = BrowserWindow.getFocusedWindow() ?? win
|
||||
const { canceled, filePath } = await dialog.showSaveDialog(w, {
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
})
|
||||
if (canceled || !filePath) return { ok: false }
|
||||
const buf = Buffer.from(base64, 'base64')
|
||||
fs.writeFileSync(filePath, buf)
|
||||
return { ok: true, filePath }
|
||||
})
|
||||
|
||||
ipcMain.handle('vaccinationRecord:importPdf', async () => {
|
||||
const w = BrowserWindow.getFocusedWindow() ?? win
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(w, {
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
if (canceled || !filePaths?.[0]) return { ok: false, canceled: true }
|
||||
try {
|
||||
const data = await parseVaccinationRecordPdf(filePaths[0])
|
||||
return { ok: true, ...data }
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: msg || 'Failed to parse PDF' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('pb:fetchEntries', () => pbService.fetchEntries())
|
||||
ipcMain.handle('pb:createEntry', (_, data) => pbService.createEntry(data))
|
||||
ipcMain.handle('pb:updateEntry', (_, payload) => pbService.updateEntry(payload.id, payload))
|
||||
ipcMain.handle('pb:deleteEntry', (_, { id }) => pbService.deleteEntry(id))
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs'
|
||||
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import { WorkerMessageHandler } from 'pdfjs-dist/legacy/build/pdf.worker.mjs'
|
||||
import { parseEncodedVaccinationRecordMetadata } from '../../src/lib/vaccinationRecordMetadata.js'
|
||||
|
||||
globalThis.pdfjsWorker = { WorkerMessageHandler }
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<{ patientName: string, dateOfBirth: string, notes: string, rows: object[] }>}
|
||||
*/
|
||||
export async function parseVaccinationRecordPdf(filePath) {
|
||||
const data = new Uint8Array(fs.readFileSync(filePath))
|
||||
const doc = await getDocument({ data, useSystemFonts: true }).promise
|
||||
const page = await doc.getPage(1)
|
||||
const content = await page.getTextContent()
|
||||
|
||||
const textItems = content.items.map((item) => item.str)
|
||||
const metadataText =
|
||||
textItems.find((s) => s.startsWith('CTM_VR:')) ??
|
||||
textItems.find((s) => s.includes('CTM_VR:')) ??
|
||||
textItems.join('')
|
||||
return parseEncodedVaccinationRecordMetadata(metadataText)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Internal admin app — PocketBase connection (bundled with the desktop build). */
|
||||
export const POCKETBASE_URL = 'https://ctm-pocketbase.wirediv.dev'
|
||||
export const PB_ADMIN_EMAIL = 'info@wirediv.com'
|
||||
export const PB_ADMIN_PASSWORD = 'Supernova99!!__'
|
||||
@@ -0,0 +1,78 @@
|
||||
import PocketBase from 'pocketbase'
|
||||
import { POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD } from './pocketbase-config.js'
|
||||
|
||||
const COLLECTION = 'instructions'
|
||||
|
||||
let pb = null
|
||||
let authPromise = null
|
||||
|
||||
function getClient() {
|
||||
if (!pb) {
|
||||
pb = new PocketBase(POCKETBASE_URL)
|
||||
// Main process may receive overlapping IPC calls (e.g. React Strict Mode).
|
||||
// Default auto-cancel would abort duplicate in-flight PocketBase requests.
|
||||
pb.autoCancellation(false)
|
||||
}
|
||||
return pb
|
||||
}
|
||||
|
||||
function parseEntry(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
vaccine: r.vaccine ?? '',
|
||||
instruction: r.instruction ?? '',
|
||||
created: r.created,
|
||||
updated: r.updated,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAuth() {
|
||||
const client = getClient()
|
||||
if (client.authStore.isValid) return
|
||||
|
||||
if (authPromise) {
|
||||
await authPromise
|
||||
return
|
||||
}
|
||||
|
||||
// PocketBase v0.23+ removed /api/admins/*; superusers authenticate as a collection.
|
||||
authPromise = client
|
||||
.collection('_superusers')
|
||||
.authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
||||
try {
|
||||
await authPromise
|
||||
} catch (err) {
|
||||
authPromise = null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEntries() {
|
||||
await ensureAuth()
|
||||
const list = await getClient().collection(COLLECTION).getFullList({ sort: '-created' })
|
||||
return list.map(parseEntry)
|
||||
}
|
||||
|
||||
export async function createEntry({ vaccine, instruction }) {
|
||||
await ensureAuth()
|
||||
const record = await getClient().collection(COLLECTION).create({
|
||||
vaccine: vaccine.trim(),
|
||||
instruction: instruction.trim(),
|
||||
})
|
||||
return parseEntry(record)
|
||||
}
|
||||
|
||||
export async function updateEntry(id, { instruction, vaccine, includeVaccine }) {
|
||||
await ensureAuth()
|
||||
const body = { instruction: instruction.trim() }
|
||||
if (includeVaccine && vaccine != null) {
|
||||
body.vaccine = vaccine.trim()
|
||||
}
|
||||
const record = await getClient().collection(COLLECTION).update(id, body)
|
||||
return parseEntry(record)
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
await ensureAuth()
|
||||
await getClient().collection(COLLECTION).delete(id)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on: (channel, listener) => {
|
||||
return ipcRenderer.on(channel, (_event, ...args) => listener(...args))
|
||||
},
|
||||
off: (channel, ...args) => ipcRenderer.off(channel, ...args),
|
||||
send: (channel, ...args) => ipcRenderer.send(channel, ...args),
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
pb: {
|
||||
fetchEntries: () => ipcRenderer.invoke('pb:fetchEntries'),
|
||||
createEntry: (data) => ipcRenderer.invoke('pb:createEntry', data),
|
||||
updateEntry: (id, data) => ipcRenderer.invoke('pb:updateEntry', { id, ...data }),
|
||||
deleteEntry: (id) => ipcRenderer.invoke('pb:deleteEntry', { id }),
|
||||
},
|
||||
})
|
||||
|
||||
function domReady(condition = ['complete', 'interactive']) {
|
||||
return new Promise((resolve) => {
|
||||
if (condition.includes(document.readyState)) {
|
||||
resolve()
|
||||
} else {
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (condition.includes(document.readyState)) resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const safeDOM = {
|
||||
append(parent, child) {
|
||||
if (!Array.from(parent.children).includes(child)) parent.appendChild(child)
|
||||
},
|
||||
remove(parent, child) {
|
||||
if (Array.from(parent.children).includes(child)) parent.removeChild(child)
|
||||
},
|
||||
}
|
||||
|
||||
function useLoading() {
|
||||
const className = 'loaders-css__square-spin'
|
||||
const styleContent = `
|
||||
@keyframes square-spin {
|
||||
25% { transform: perspective(100px) rotateX(180deg) rotateY(0); }
|
||||
50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); }
|
||||
75% { transform: perspective(100px) rotateX(0) rotateY(180deg); }
|
||||
100% { transform: perspective(100px) rotateX(0) rotateY(0); }
|
||||
}
|
||||
.${className} > div {
|
||||
animation-fill-mode: both;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #fff;
|
||||
animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite;
|
||||
}
|
||||
.app-loading-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1b26;
|
||||
z-index: 9;
|
||||
}
|
||||
`
|
||||
const style = document.createElement('style')
|
||||
const div = document.createElement('div')
|
||||
style.id = 'app-loading-style'
|
||||
style.textContent = styleContent
|
||||
div.className = 'app-loading-wrap'
|
||||
div.innerHTML = `<div class="${className}"><div></div></div>`
|
||||
|
||||
return {
|
||||
appendLoading() {
|
||||
safeDOM.append(document.head, style)
|
||||
safeDOM.append(document.body, div)
|
||||
},
|
||||
removeLoading() {
|
||||
safeDOM.remove(document.head, style)
|
||||
safeDOM.remove(document.body, div)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { appendLoading, removeLoading } = useLoading()
|
||||
domReady().then(appendLoading)
|
||||
window.onmessage = (ev) => {
|
||||
if (ev.data?.payload === 'removeLoading') removeLoading()
|
||||
}
|
||||
setTimeout(removeLoading, 4000)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CTM Document Builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6758
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "ctm-document-builder",
|
||||
"version": "1.2.2",
|
||||
"description": "CTM Document Builder — Electron + React with cloud PocketBase",
|
||||
"author": "Wirediv",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist-electron/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && electron-builder",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-placeholder": "^3.24.0",
|
||||
"@tiptap/extension-subscript": "^3.24.0",
|
||||
"@tiptap/extension-superscript": "^3.24.0",
|
||||
"@tiptap/extension-text-style": "^3.24.0",
|
||||
"@tiptap/pm": "^3.24.0",
|
||||
"@tiptap/react": "^3.24.0",
|
||||
"@tiptap/starter-kit": "^3.24.0",
|
||||
"lucide-react": "^1.3.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pocketbase": "^0.21.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"build": {
|
||||
"appId": "dev.ctm.document-builder",
|
||||
"productName": "CTM Document Builder",
|
||||
"files": [
|
||||
"dist-electron",
|
||||
"dist"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "assets",
|
||||
"to": "assets",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "assets/icon.icns",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"identity": "-",
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
},
|
||||
"win": {
|
||||
"icon": "assets/icon.ico",
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"artifactName": "${productName}_${version}.${ext}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Cog } from 'lucide-react'
|
||||
import pkg from '../package.json'
|
||||
import { Instructions } from './components/Instructions'
|
||||
import { InstructionManager } from './components/InstructionManager'
|
||||
import { VaccinationRecordBuilder } from './components/VaccinationRecordBuilder'
|
||||
|
||||
const TAB_STYLE = {
|
||||
padding: '12px 20px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#71717a',
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
borderBottom: '2px solid transparent',
|
||||
}
|
||||
const TAB_ACTIVE = {
|
||||
...TAB_STYLE,
|
||||
color: '#e4e4e7',
|
||||
borderBottom: '2px solid #7c3aed',
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState('instructions')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowSettings(false)
|
||||
}
|
||||
}
|
||||
if (showSettings) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showSettings])
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', background: '#0f0f12' }}>
|
||||
<nav
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: '#18181b',
|
||||
borderBottom: '1px solid #27272a',
|
||||
paddingRight: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('instructions')}
|
||||
style={activeTab === 'instructions' ? TAB_ACTIVE : TAB_STYLE}
|
||||
>
|
||||
Instructions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('vaccination-record')}
|
||||
style={activeTab === 'vaccination-record' ? TAB_ACTIVE : TAB_STYLE}
|
||||
>
|
||||
Vaccination Record
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('paragraph-manager')}
|
||||
style={activeTab === 'paragraph-manager' ? TAB_ACTIVE : TAB_STYLE}
|
||||
>
|
||||
Instruction Manager
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={dropdownRef} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: showSettings ? '#e4e4e7' : '#71717a',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: showSettings ? '#27272a' : 'transparent',
|
||||
transition: 'color 0.2s, background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#e4e4e7'
|
||||
e.currentTarget.style.backgroundColor = '#27272a'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showSettings) {
|
||||
e.currentTarget.style.color = '#71717a'
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Cog size={18} />
|
||||
</button>
|
||||
|
||||
{showSettings && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 'calc(100% + 8px)',
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
borderRadius: '6px',
|
||||
padding: '12px 16px',
|
||||
zIndex: 1000,
|
||||
minWidth: '150px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.75rem', color: '#71717a', marginBottom: '4px' }}>
|
||||
App Version
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: '#e4e4e7' }}>
|
||||
v{pkg.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<main style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{activeTab === 'instructions' && <Instructions />}
|
||||
{activeTab === 'paragraph-manager' && <InstructionManager />}
|
||||
{activeTab === 'vaccination-record' && <VaccinationRecordBuilder />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
fetchEntries as fetchEntriesFromPb,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
} from '@/lib/pocketbase'
|
||||
import { isInstructionEmpty, toEditorHtml } from '@/lib/instructionHtml'
|
||||
import { RichTextEditor } from '@/components/RichTextEditor'
|
||||
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #27272a',
|
||||
background: '#18181b',
|
||||
color: '#e4e4e7',
|
||||
}
|
||||
|
||||
const actionBtnStyle = {
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
export function InstructionManager() {
|
||||
const [vaccine, setVaccine] = useState('')
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [selectedEntry, setSelectedEntry] = useState(null)
|
||||
const [entryToDelete, setEntryToDelete] = useState(null)
|
||||
const [editingEntry, setEditingEntry] = useState(null)
|
||||
const [editInstruction, setEditInstruction] = useState('')
|
||||
const [editInstructionBaseline, setEditInstructionBaseline] = useState('')
|
||||
const [editBaselineSynced, setEditBaselineSynced] = useState(false)
|
||||
const [editVaccine, setEditVaccine] = useState('')
|
||||
const [isVaccineEditing, setIsVaccineEditing] = useState(false)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const vaccineInputRef = useRef(null)
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await fetchEntriesFromPb()
|
||||
setEntries(list)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [fetchEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntry && !entryToDelete) return
|
||||
function onKeyDown(ev) {
|
||||
if (ev.key !== 'Escape' || deleting) return
|
||||
if (entryToDelete) setEntryToDelete(null)
|
||||
else setSelectedEntry(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [selectedEntry, entryToDelete, deleting])
|
||||
|
||||
useEffect(() => {
|
||||
if (isVaccineEditing) vaccineInputRef.current?.focus()
|
||||
}, [isVaccineEditing])
|
||||
|
||||
function startEdit(entry) {
|
||||
setSelectedEntry(null)
|
||||
setEntryToDelete(null)
|
||||
setEditingEntry(entry)
|
||||
setEditInstruction(toEditorHtml(entry.instruction))
|
||||
setEditInstructionBaseline('')
|
||||
setEditBaselineSynced(false)
|
||||
setEditVaccine(entry.vaccine)
|
||||
setIsVaccineEditing(false)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingEntry(null)
|
||||
setEditInstruction('')
|
||||
setEditInstructionBaseline('')
|
||||
setEditBaselineSynced(false)
|
||||
setEditVaccine('')
|
||||
setIsVaccineEditing(false)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!vaccine.trim() || isInstructionEmpty(instruction) || saving) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createEntry({ vaccine: vaccine.trim(), instruction })
|
||||
setVaccine('')
|
||||
setInstruction('')
|
||||
await fetchEntries()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = Boolean(vaccine.trim() && !isInstructionEmpty(instruction)) && !saving
|
||||
|
||||
const instructionChanged =
|
||||
editBaselineSynced && editInstruction !== editInstructionBaseline
|
||||
const vaccineChanged =
|
||||
isVaccineEditing &&
|
||||
editingEntry != null &&
|
||||
editVaccine.trim() !== '' &&
|
||||
editVaccine.trim() !== editingEntry.vaccine
|
||||
const canUpdate =
|
||||
editingEntry != null &&
|
||||
editBaselineSynced &&
|
||||
!isInstructionEmpty(editInstruction) &&
|
||||
(instructionChanged || vaccineChanged) &&
|
||||
!updating
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editingEntry || !canUpdate) return
|
||||
setUpdating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateEntry(editingEntry.id, {
|
||||
instruction: editInstruction,
|
||||
vaccine: editVaccine.trim(),
|
||||
includeVaccine: isVaccineEditing,
|
||||
})
|
||||
cancelEdit()
|
||||
await fetchEntries()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (deleting || !id) return
|
||||
setDeleting(id)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteEntry(id)
|
||||
setEntryToDelete(null)
|
||||
await fetchEntries()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const listBusy = Boolean(editingEntry || updating)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: 24,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
gap: 24,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 600 }}>
|
||||
Instruction Manager
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 16px',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p style={{ margin: 0, color: '#a1a1aa', fontSize: '0.75rem' }}>
|
||||
Vaccine instructions are used to compile the document.
|
||||
Use this screen to add, edit, and delete instructions.
|
||||
</p>
|
||||
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<input
|
||||
type="text"
|
||||
id="vaccine"
|
||||
value={vaccine}
|
||||
onChange={(e) => setVaccine(e.target.value)}
|
||||
placeholder="Vaccine"
|
||||
disabled={saving}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
<RichTextEditor
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
disabled={saving}
|
||||
placeholder="Type an instruction and save…"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: canSave ? '#7c3aed' : '#3f3f46',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
cursor: canSave ? 'pointer' : 'not-allowed',
|
||||
opacity: canSave ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
|
||||
<h2 style={{ margin: '0 0 8px',fontSize: '0.875rem', color: '#a1a1aa', fontWeight: 600 }}>
|
||||
{editingEntry ? 'Edit entry' : 'Saved entries'}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 8px', color: '#a1a1aa', fontSize: '0.75rem' }}>
|
||||
Vaccine names can be clicked to to reveal the instruction.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{editingEntry ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: '#71717a',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Vaccine
|
||||
</label>
|
||||
{isVaccineEditing ? (
|
||||
<input
|
||||
ref={vaccineInputRef}
|
||||
type="text"
|
||||
value={editVaccine}
|
||||
onChange={(e) => setEditVaccine(e.target.value)}
|
||||
disabled={updating}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsVaccineEditing(true)}
|
||||
disabled={updating}
|
||||
title="Click to edit vaccine name"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
font: 'inherit',
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
color: '#e4e4e7',
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
if (!updating) ev.currentTarget.style.color = '#a78bfa'
|
||||
}}
|
||||
onMouseLeave={(ev) => {
|
||||
ev.currentTarget.style.color = '#e4e4e7'
|
||||
}}
|
||||
>
|
||||
{editingEntry.vaccine}
|
||||
</button>
|
||||
)}
|
||||
{!isVaccineEditing && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#71717a' }}>
|
||||
Click to edit vaccine name
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
color: '#71717a',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Instruction
|
||||
</label>
|
||||
<RichTextEditor
|
||||
key={editingEntry.id}
|
||||
value={editInstruction}
|
||||
onChange={setEditInstruction}
|
||||
onReady={(html) => {
|
||||
setEditInstruction(html)
|
||||
setEditInstructionBaseline(html)
|
||||
setEditBaselineSynced(true)
|
||||
}}
|
||||
disabled={updating}
|
||||
placeholder="Edit instruction…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
disabled={updating}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
fontWeight: 500,
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdate}
|
||||
disabled={!canUpdate}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: canUpdate ? '#7c3aed' : '#3f3f46',
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
cursor: canUpdate ? 'pointer' : 'not-allowed',
|
||||
opacity: canUpdate ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
{updating ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<p style={{ color: '#71717a', margin: 0 }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p style={{ color: '#71717a', margin: 0 }}>No entries yet.</p>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{entries.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(e)}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
font: 'inherit',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
ev.currentTarget.style.color = '#a78bfa'
|
||||
}}
|
||||
onMouseLeave={(ev) => {
|
||||
ev.currentTarget.style.color = '#e4e4e7'
|
||||
}}
|
||||
>
|
||||
{e.vaccine}
|
||||
</button>
|
||||
<time
|
||||
style={{ fontSize: '0.75rem', color: '#71717a', marginTop: 4, display: 'block' }}
|
||||
dateTime={e.updated}
|
||||
>
|
||||
Updated: {new Date(e.updated).toLocaleString()}
|
||||
</time>
|
||||
<div
|
||||
className="instruction-html-preview instruction-html-preview--list"
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
color: '#a1a1aa',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: toEditorHtml(e.instruction) }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(e)}
|
||||
disabled={listBusy || deleting === e.id}
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
background: listBusy ? '#3f3f46' : '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
cursor: listBusy ? 'not-allowed' : 'pointer',
|
||||
opacity: listBusy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryToDelete(e)}
|
||||
disabled={listBusy || deleting === e.id}
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
background: deleting === e.id ? '#3f3f46' : '#dc2626',
|
||||
color: '#fff',
|
||||
cursor: listBusy || deleting === e.id ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entryToDelete && (
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
if (deleting !== entryToDelete.id) setEntryToDelete(null)
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 1001,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-modal-title"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
id="delete-modal-title"
|
||||
style={{ margin: '0 0 20px', lineHeight: 1.5, color: '#e4e4e7', fontSize: '0.9375rem' }}
|
||||
>
|
||||
Are you sure you want to delete{' '}
|
||||
<span style={{ fontWeight: 600 }}>{entryToDelete.vaccine}</span>?
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryToDelete(null)}
|
||||
disabled={deleting === entryToDelete.id}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
cursor: deleting === entryToDelete.id ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entryToDelete.id)}
|
||||
disabled={deleting === entryToDelete.id}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: deleting === entryToDelete.id ? '#3f3f46' : '#dc2626',
|
||||
color: '#fff',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 600,
|
||||
cursor: deleting === entryToDelete.id ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{deleting === entryToDelete.id ? 'Deleting…' : 'DELETE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry && (
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="entry-modal-title"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 560,
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
id="entry-modal-title"
|
||||
style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, color: '#e4e4e7' }}
|
||||
>
|
||||
{selectedEntry.vaccine}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
background: '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="instruction-html-preview"
|
||||
style={{ color: '#e4e4e7' }}
|
||||
dangerouslySetInnerHTML={{ __html: toEditorHtml(selectedEntry.instruction) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Lock, Pencil, Trash2 } from 'lucide-react'
|
||||
import { fetchEntries as fetchEntriesFromPb } from '@/lib/pocketbase'
|
||||
import { toEditorHtml } from '@/lib/instructionHtml'
|
||||
import { buildPrintDocumentHtml } from '@/lib/buildPrintDocumentHtml'
|
||||
import { RichTextEditor } from '@/components/RichTextEditor'
|
||||
|
||||
const ADDRESS_PLACEHOLDER = '101- 444 Concession St\nHamilton, ON\nL9A 1C2'
|
||||
|
||||
const cardStyle = {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
}
|
||||
const buttonStyle = {
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
const primaryButtonStyle = { ...buttonStyle, background: '#7c3aed' }
|
||||
const iconButtonStyle = {
|
||||
...buttonStyle,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 6,
|
||||
lineHeight: 0,
|
||||
}
|
||||
const removeButtonStyle = {
|
||||
...iconButtonStyle,
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
}
|
||||
|
||||
function slotId() {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `slot-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export function Instructions() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [canvasItems, setCanvasItems] = useState([])
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
const [pdfError, setPdfError] = useState(null)
|
||||
const [editingSlotId, setEditingSlotId] = useState(null)
|
||||
const [editBaseline, setEditBaseline] = useState(null)
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await fetchEntriesFromPb()
|
||||
setEntries(list)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [fetchEntries])
|
||||
|
||||
function addToCanvas(entry) {
|
||||
setCanvasItems((prev) => [
|
||||
...prev,
|
||||
{ slotId: slotId(), id: entry.id, vaccine: entry.vaccine, instruction: entry.instruction ?? '' },
|
||||
])
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
setCanvasItems((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= canvasItems.length - 1) return
|
||||
setCanvasItems((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function startPreviewEdit(slotId) {
|
||||
const item = canvasItems.find((it) => it.slotId === slotId)
|
||||
if (item) {
|
||||
setEditBaseline({ instruction: item.instruction ?? '' })
|
||||
}
|
||||
setEditingSlotId(slotId)
|
||||
}
|
||||
|
||||
function cancelPreviewEdit() {
|
||||
if (editingSlotId && editBaseline) {
|
||||
updateCanvasInstruction(editingSlotId, editBaseline.instruction)
|
||||
}
|
||||
setEditBaseline(null)
|
||||
setEditingSlotId(null)
|
||||
}
|
||||
|
||||
function lockPreviewEdit() {
|
||||
setEditBaseline(null)
|
||||
setEditingSlotId(null)
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
const removedId = canvasItems[index]?.slotId
|
||||
setCanvasItems((prev) => prev.filter((_, i) => i !== index))
|
||||
if (removedId != null) {
|
||||
setEditingSlotId((current) => (current === removedId ? null : current))
|
||||
if (editingSlotId === removedId) setEditBaseline(null)
|
||||
}
|
||||
}
|
||||
|
||||
function updateCanvasInstruction(slotId, instruction) {
|
||||
setCanvasItems((prev) =>
|
||||
prev.map((it) => (it.slotId === slotId ? { ...it, instruction } : it))
|
||||
)
|
||||
}
|
||||
|
||||
const editingItem = editingSlotId
|
||||
? canvasItems.find((it) => it.slotId === editingSlotId)
|
||||
: null
|
||||
|
||||
async function handleDownloadPdf() {
|
||||
if (canvasItems.length === 0 || pdfLoading) return
|
||||
setPdfLoading(true)
|
||||
setPdfError(null)
|
||||
try {
|
||||
const { base64: logoBase64 } = await window.ipcRenderer.invoke('pdf:getLogo')
|
||||
const html = buildPrintDocumentHtml({
|
||||
items: canvasItems,
|
||||
logoBase64,
|
||||
address: ADDRESS_PLACEHOLDER,
|
||||
})
|
||||
const { base64 } = await window.ipcRenderer.invoke('pdf:generate', { html })
|
||||
const res = await window.ipcRenderer.invoke('pdf:save', {
|
||||
base64,
|
||||
defaultName: 'vaccine-instructions.pdf',
|
||||
})
|
||||
if (res && !res.ok) {
|
||||
setPdfError('Save cancelled')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setPdfError(msg || 'PDF export failed')
|
||||
console.error('PDF export failed:', e)
|
||||
} finally {
|
||||
setPdfLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minHeight: 'calc(100vh - 52px)',
|
||||
background: '#0f0f12',
|
||||
}}
|
||||
>
|
||||
<aside
|
||||
style={{
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #27272a',
|
||||
padding: 16,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '1rem', fontWeight: 600, color: '#e4e4e7' }}>
|
||||
Library
|
||||
</h2>
|
||||
{error && (
|
||||
<p style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, background: 'rgba(239,68,68,0.15)', color: '#fca5a5', fontSize: '0.875rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div style={{
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<p style={{ color: '#71717a', margin: 0 }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p style={{ color: '#71717a', margin: 0 }}>No entries yet. Add some in Paragraph Manager.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{entries.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => addToCanvas(e)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault()
|
||||
addToCanvas(e)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...cardStyle,
|
||||
cursor: 'pointer',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, lineHeight: 1.5, fontSize: '0.9375rem' }}>{e.vaccine}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 24,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: '1rem', fontWeight: 600, color: '#e4e4e7' }}>
|
||||
Document Workspace
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={canvasItems.length === 0 || pdfLoading}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
background: canvasItems.length === 0 || pdfLoading ? '#3f3f46' : '#7c3aed',
|
||||
}}
|
||||
>
|
||||
{pdfLoading ? 'Generating…' : 'Download PDF'}
|
||||
</button>
|
||||
{pdfError && (
|
||||
<span style={{ fontSize: '0.875rem', color: '#fca5a5' }}>{pdfError}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Canvas
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{canvasItems.length === 0 ? (
|
||||
<li style={{ color: '#a1a1aa', fontSize: '0.75rem' }}>
|
||||
Click entries in the Library to add them here.
|
||||
</li>
|
||||
) : (
|
||||
canvasItems.map((it, idx) => (
|
||||
<li
|
||||
key={it.slotId}
|
||||
style={{
|
||||
...cardStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
borderColor: it.slotId === editingSlotId ? '#7c3aed' : '#27272a',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, flex: 1, lineHeight: 1.5, fontSize: '0.9375rem', color: '#e4e4e7' }}>
|
||||
{it.vaccine}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startPreviewEdit(it.slotId)}
|
||||
disabled={editingSlotId != null && editingSlotId !== it.slotId}
|
||||
aria-label={`Edit instruction for ${it.vaccine}`}
|
||||
style={{
|
||||
...iconButtonStyle,
|
||||
opacity: editingSlotId != null && editingSlotId !== it.slotId ? 0.5 : 1,
|
||||
cursor:
|
||||
editingSlotId != null && editingSlotId !== it.slotId
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
aria-label="Move up"
|
||||
style={{ ...iconButtonStyle, opacity: idx === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
<ChevronUp size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDown(idx)}
|
||||
disabled={idx === canvasItems.length - 1}
|
||||
aria-label="Move down"
|
||||
style={{ ...iconButtonStyle, opacity: idx === canvasItems.length - 1 ? 0.5 : 1 }}
|
||||
>
|
||||
<ChevronDown size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
aria-label={`Remove ${it.vaccine}`}
|
||||
style={removeButtonStyle}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Preview
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '0.75rem', color: '#a1a1aa' }}>
|
||||
{editingSlotId
|
||||
? 'Editing one instruction | Lock when done.'
|
||||
: 'Edits here apply to this document and PDF only.'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
...cardStyle,
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
fontSize: '0.9375rem',
|
||||
lineHeight: 1.6,
|
||||
color: '#e4e4e7',
|
||||
display: editingSlotId ? 'flex' : 'block',
|
||||
flexDirection: editingSlotId ? 'column' : undefined,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{canvasItems.length === 0 ? (
|
||||
'Preview will appear here.'
|
||||
) : editingSlotId && editingItem ? (
|
||||
<>
|
||||
<p style={{ margin: 0, fontWeight: 600, lineHeight: 1.5, flexShrink: 0 }}>
|
||||
{editingItem.vaccine}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor
|
||||
key={editingSlotId}
|
||||
value={toEditorHtml(editingItem.instruction)}
|
||||
onChange={(html) => updateCanvasInstruction(editingSlotId, html)}
|
||||
placeholder="Edit instruction…"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8, flexShrink: 0, gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelPreviewEdit}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={lockPreviewEdit}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
background: '#7c3aed',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Lock size={16} strokeWidth={2} aria-hidden />
|
||||
Lock
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
canvasItems.map((it) => (
|
||||
<div key={it.slotId} style={{ marginBottom: 14 }}>
|
||||
<p style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600, lineHeight: 1.5 }}>{it.vaccine}</p>
|
||||
<div
|
||||
className="instruction-html-preview"
|
||||
style={{ marginTop: 4 }}
|
||||
dangerouslySetInnerHTML={{ __html: toEditorHtml(it.instruction) }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
applyDateMask,
|
||||
DATE_INPUT_PLACEHOLDER,
|
||||
DATE_LOCALE,
|
||||
DATE_MASK_MAX_LENGTH,
|
||||
formatDateInputValue,
|
||||
isValidIsoDate,
|
||||
} from '@/lib/dateLocale'
|
||||
|
||||
const NAVIGATION_KEYS = new Set([
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Tab',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Home',
|
||||
'End',
|
||||
])
|
||||
|
||||
export function LocaleDateInput({ value, onChange, style }) {
|
||||
const [draft, setDraft] = useState(() => formatDateInputValue(value))
|
||||
const pickerRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(formatDateInputValue(value))
|
||||
}, [value])
|
||||
|
||||
function commit(next) {
|
||||
const masked = applyDateMask(next)
|
||||
if (masked === '') {
|
||||
onChange({ target: { value: '' } })
|
||||
setDraft('')
|
||||
return
|
||||
}
|
||||
if (!isValidIsoDate(masked)) {
|
||||
setDraft(formatDateInputValue(value))
|
||||
return
|
||||
}
|
||||
onChange({ target: { value: masked } })
|
||||
setDraft(formatDateInputValue(masked))
|
||||
}
|
||||
|
||||
function handleInputChange(e) {
|
||||
const masked = applyDateMask(e.target.value)
|
||||
setDraft(masked)
|
||||
|
||||
if (masked === '') {
|
||||
onChange({ target: { value: '' } })
|
||||
return
|
||||
}
|
||||
if (isValidIsoDate(masked)) {
|
||||
onChange({ target: { value: masked } })
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (NAVIGATION_KEYS.has(e.key) || e.ctrlKey || e.metaKey) return
|
||||
if (!/^\d$/.test(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e) {
|
||||
e.preventDefault()
|
||||
const pasted = e.clipboardData.getData('text')
|
||||
const masked = applyDateMask(pasted)
|
||||
setDraft(masked)
|
||||
|
||||
if (masked === '') {
|
||||
onChange({ target: { value: '' } })
|
||||
return
|
||||
}
|
||||
if (isValidIsoDate(masked)) {
|
||||
onChange({ target: { value: masked } })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.setSelectionRange(masked.length, masked.length)
|
||||
})
|
||||
}
|
||||
|
||||
function handlePickerChange(e) {
|
||||
onChange(e)
|
||||
setDraft(formatDateInputValue(e.target.value))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="locale-date-input">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
lang={DATE_LOCALE}
|
||||
className="locale-date-input__text"
|
||||
style={style}
|
||||
value={draft}
|
||||
placeholder={DATE_INPUT_PLACEHOLDER}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={DATE_MASK_MAX_LENGTH}
|
||||
onChange={handleInputChange}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<input
|
||||
ref={pickerRef}
|
||||
type="date"
|
||||
lang={DATE_LOCALE}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
className="locale-date-input__native date-input"
|
||||
value={value}
|
||||
onChange={handlePickerChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEditor, EditorContent, useEditorState } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import { TextStyleKit } from '@tiptap/extension-text-style'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Subscript as SubscriptIcon,
|
||||
Superscript as SuperscriptIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
} from 'lucide-react'
|
||||
|
||||
const FONT_SIZE_OPTIONS = [
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: '12px', value: '12px' },
|
||||
{ label: '14px', value: '14px' },
|
||||
{ label: '16px', value: '16px' },
|
||||
{ label: '18px', value: '18px' },
|
||||
{ label: '20px', value: '20px' },
|
||||
{ label: '24px', value: '24px' },
|
||||
]
|
||||
|
||||
const toolbarBtnStyle = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #27272a',
|
||||
background: '#18181b',
|
||||
color: '#e4e4e7',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
function ToolbarButton({ active, disabled, onClick, label, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
...toolbarBtnStyle,
|
||||
background: active ? '#3f3f46' : '#18181b',
|
||||
color: active ? '#fff' : '#e4e4e7',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FontSizeSelect({ editor, disabled, fontSize }) {
|
||||
return (
|
||||
<select
|
||||
className="instruction-editor__font-size"
|
||||
aria-label="Font size"
|
||||
disabled={disabled}
|
||||
value={fontSize}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value) {
|
||||
editor.chain().focus().setFontSize(value).run()
|
||||
} else {
|
||||
editor.chain().focus().unsetFontSize().run()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{FONT_SIZE_OPTIONS.map((opt) => (
|
||||
<option key={opt.label} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
{fontSize && !FONT_SIZE_OPTIONS.some((o) => o.value === fontSize) && (
|
||||
<option value={fontSize}>{fontSize}</option>
|
||||
)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorToolbar({ editor, disabled }) {
|
||||
const {
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isStrike,
|
||||
isSubscript,
|
||||
isSuperscript,
|
||||
isBulletList,
|
||||
isOrderedList,
|
||||
fontSize,
|
||||
} = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
isBold: ed.isActive('bold'),
|
||||
isItalic: ed.isActive('italic'),
|
||||
isUnderline: ed.isActive('underline'),
|
||||
isStrike: ed.isActive('strike'),
|
||||
isSubscript: ed.isActive('subscript'),
|
||||
isSuperscript: ed.isActive('superscript'),
|
||||
isBulletList: ed.isActive('bulletList'),
|
||||
isOrderedList: ed.isActive('orderedList'),
|
||||
fontSize: ed.getAttributes('textStyle').fontSize || '',
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
padding: 8,
|
||||
borderBottom: '1px solid #27272a',
|
||||
background: '#0f0f12',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
label="Bold"
|
||||
active={isBold}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Bold size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Italic"
|
||||
active={isItalic}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Italic size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Underline"
|
||||
active={isUnderline}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Strikethrough"
|
||||
active={isStrike}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<Strikethrough size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Subscript"
|
||||
active={isSubscript}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleSubscript().run()}
|
||||
>
|
||||
<SubscriptIcon size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Superscript"
|
||||
active={isSuperscript}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleSuperscript().run()}
|
||||
>
|
||||
<SuperscriptIcon size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<FontSizeSelect editor={editor} disabled={disabled} fontSize={fontSize} />
|
||||
<ToolbarButton
|
||||
label="Bullet list"
|
||||
active={isBulletList}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="Numbered list"
|
||||
active={isOrderedList}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered size={16} strokeWidth={2} aria-hidden />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RichTextEditor({ value, onChange, disabled = false, placeholder, onReady }) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
Subscript,
|
||||
Superscript,
|
||||
TextStyleKit.configure({
|
||||
fontSize: {},
|
||||
fontFamily: false,
|
||||
color: false,
|
||||
backgroundColor: false,
|
||||
lineHeight: false,
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
content: value || '',
|
||||
editable: !disabled,
|
||||
onCreate: ({ editor: ed }) => {
|
||||
const html = ed.getHTML()
|
||||
onChange(html)
|
||||
onReady?.(html)
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
onChange(ed.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.setEditable(!disabled)
|
||||
}, [editor, disabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const current = editor.getHTML()
|
||||
const next = value || ''
|
||||
if (next === '' && current !== '<p></p>' && current !== '') {
|
||||
editor.commands.setContent('')
|
||||
} else if (next !== '' && next !== current) {
|
||||
editor.commands.setContent(next)
|
||||
}
|
||||
}, [editor, value])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="instruction-editor"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #27272a',
|
||||
background: '#18181b',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<EditorToolbar editor={editor} disabled={disabled} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { LocaleDateInput } from '@/components/LocaleDateInput'
|
||||
import { buildVaccinationRecordHtml } from '@/lib/buildVaccinationRecordHtml'
|
||||
import { buildVaccinationLabelHtml } from '@/lib/buildVaccinationLabelHtml'
|
||||
import { formatDisplayDate } from '@/lib/dateLocale'
|
||||
import {
|
||||
DISEASE_OPTIONS,
|
||||
VACCINATION_RECORD_MAX_ROWS,
|
||||
diseaseLabel,
|
||||
} from '@/lib/vaccinationRecordDiseases'
|
||||
|
||||
const cardStyle = {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#18181b',
|
||||
border: '1px solid #27272a',
|
||||
}
|
||||
const buttonStyle = {
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: '#3f3f46',
|
||||
color: '#e4e4e7',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}
|
||||
const primaryButtonStyle = { ...buttonStyle, background: '#7c3aed' }
|
||||
const iconButtonStyle = {
|
||||
...buttonStyle,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 6,
|
||||
lineHeight: 0,
|
||||
}
|
||||
const removeButtonStyle = {
|
||||
...iconButtonStyle,
|
||||
background: '#dc2626',
|
||||
color: '#fff',
|
||||
}
|
||||
const fieldStyle = {
|
||||
width: '100%',
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #27272a',
|
||||
background: '#18181b',
|
||||
color: '#e4e4e7',
|
||||
fontSize: '0.875rem',
|
||||
boxSizing: 'border-box',
|
||||
}
|
||||
const labelStyle = {
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#a1a1aa',
|
||||
}
|
||||
|
||||
function slotId() {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `slot-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function VaccinationRecordBuilder() {
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
const [pdfError, setPdfError] = useState(null)
|
||||
const [importLoading, setImportLoading] = useState(false)
|
||||
const [importError, setImportError] = useState(null)
|
||||
|
||||
const [patientName, setPatientName] = useState('')
|
||||
const [dateOfBirth, setDateOfBirth] = useState('')
|
||||
const [lastUpdate, setLastUpdate] = useState(todayIso)
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
const [rows, setRows] = useState([])
|
||||
const [formDiseaseKey, setFormDiseaseKey] = useState(DISEASE_OPTIONS[0]?.key ?? '')
|
||||
const [formDate, setFormDate] = useState('')
|
||||
const [formProductBrand, setFormProductBrand] = useState('')
|
||||
const [formLotNumber, setFormLotNumber] = useState('')
|
||||
const [formProvider, setFormProvider] = useState('')
|
||||
|
||||
const atRowLimit = rows.length >= VACCINATION_RECORD_MAX_ROWS
|
||||
const isPatientFormComplete = patientName !== '' && dateOfBirth !== '' && lastUpdate !== ''
|
||||
const isRowComplete = formDiseaseKey !== '' && formDate !== '' && formProductBrand !== '' && formLotNumber !== '' && formProvider !== ''
|
||||
const canAdd = isRowComplete && !atRowLimit
|
||||
const canExport = isPatientFormComplete && rows.length > 0 && DISEASE_OPTIONS.length > 0
|
||||
|
||||
function addRow() {
|
||||
if (!canAdd || rows.length >= VACCINATION_RECORD_MAX_ROWS) return
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
slotId: slotId(),
|
||||
diseaseKey: formDiseaseKey,
|
||||
date: formDate,
|
||||
productBrand: formProductBrand,
|
||||
lotNumber: formLotNumber,
|
||||
provider: formProvider,
|
||||
},
|
||||
])
|
||||
setFormDate('')
|
||||
setFormProductBrand('')
|
||||
setFormLotNumber('')
|
||||
setFormProvider('')
|
||||
}
|
||||
|
||||
function moveUp(index) {
|
||||
if (index <= 0) return
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function moveDown(index) {
|
||||
if (index >= rows.length - 1) return
|
||||
setRows((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
setRows((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function hasExistingData() {
|
||||
return (
|
||||
patientName !== '' ||
|
||||
dateOfBirth !== '' ||
|
||||
notes !== '' ||
|
||||
rows.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
async function handleImportPdf() {
|
||||
if (importLoading || pdfLoading) return
|
||||
if (hasExistingData() && !window.confirm('Replace the current record with data from the PDF?')) {
|
||||
return
|
||||
}
|
||||
setImportLoading(true)
|
||||
setImportError(null)
|
||||
setPdfError(null)
|
||||
try {
|
||||
const res = await window.ipcRenderer.invoke('vaccinationRecord:importPdf')
|
||||
if (res?.canceled) return
|
||||
if (!res?.ok) {
|
||||
setImportError(res?.error || 'Failed to import PDF')
|
||||
return
|
||||
}
|
||||
setPatientName(res.patientName ?? '')
|
||||
setDateOfBirth(res.dateOfBirth ?? '')
|
||||
setLastUpdate(todayIso())
|
||||
setNotes(res.notes ?? '')
|
||||
setRows(
|
||||
(res.rows ?? []).map((row) => ({
|
||||
slotId: slotId(),
|
||||
diseaseKey: row.diseaseKey,
|
||||
date: row.date ?? '',
|
||||
productBrand: row.productBrand ?? '',
|
||||
lotNumber: row.lotNumber ?? '',
|
||||
provider: row.provider ?? '',
|
||||
}))
|
||||
)
|
||||
setFormDate('')
|
||||
setFormProductBrand('')
|
||||
setFormLotNumber('')
|
||||
setFormProvider('')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setImportError(msg || 'Failed to import PDF')
|
||||
console.error('PDF import failed:', e)
|
||||
} finally {
|
||||
setImportLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportPdf() {
|
||||
if (!canExport || pdfLoading || importLoading) return
|
||||
setPdfLoading(true)
|
||||
setPdfError(null)
|
||||
try {
|
||||
const { base64: logoBase64 } = await window.ipcRenderer.invoke('pdf:getLogo')
|
||||
const html = buildVaccinationRecordHtml({
|
||||
logoBase64,
|
||||
patientName,
|
||||
dateOfBirth,
|
||||
lastUpdate,
|
||||
notes,
|
||||
diseases: DISEASE_OPTIONS,
|
||||
rows,
|
||||
})
|
||||
|
||||
const { base64 } = await window.ipcRenderer.invoke('pdf:generate', { html, landscape: false, footer: false })
|
||||
|
||||
const sanitizedName = (patientName || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
||||
.replace(/__+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
const sanitizedDob = (dateOfBirth || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
||||
.replace(/__+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
const defaultName = sanitizedName && sanitizedDob
|
||||
? `VR_${sanitizedName}_${sanitizedDob}.pdf`
|
||||
: sanitizedName
|
||||
? `VR_${sanitizedName}.pdf`
|
||||
: sanitizedDob
|
||||
? `VR_${sanitizedDob}.pdf`
|
||||
: 'VR_vaccination-record.pdf'
|
||||
|
||||
const res = await window.ipcRenderer.invoke('pdf:save', {
|
||||
base64,
|
||||
defaultName,
|
||||
})
|
||||
if (res && !res.ok) {
|
||||
setPdfError('Save cancelled')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setPdfError(msg || 'PDF export failed')
|
||||
console.error('PDF export failed:', e)
|
||||
} finally {
|
||||
setPdfLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePrintLabel() {
|
||||
if (!isPatientFormComplete || pdfLoading || importLoading) return
|
||||
setPdfLoading(true)
|
||||
setPdfError(null)
|
||||
try {
|
||||
const html = buildVaccinationLabelHtml({
|
||||
patientName,
|
||||
dateOfBirth,
|
||||
})
|
||||
|
||||
const { base64 } = await window.ipcRenderer.invoke('pdf:generate', { html, landscape: false, footer: false })
|
||||
|
||||
const sanitizedName = (patientName || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
||||
.replace(/__+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
const sanitizedDob = (dateOfBirth || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '_')
|
||||
.replace(/__+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
const defaultName = sanitizedName && sanitizedDob
|
||||
? `Label_${sanitizedName}_${sanitizedDob}.pdf`
|
||||
: sanitizedName
|
||||
? `Label_${sanitizedName}.pdf`
|
||||
: sanitizedDob
|
||||
? `Label_${sanitizedDob}.pdf`
|
||||
: 'Label_vaccination-record.pdf'
|
||||
|
||||
const res = await window.ipcRenderer.invoke('pdf:save', {
|
||||
base64,
|
||||
defaultName,
|
||||
})
|
||||
if (res && !res.ok) {
|
||||
setPdfError('Save cancelled')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setPdfError(msg || 'Label generation failed')
|
||||
console.error('Label generation failed:', e)
|
||||
} finally {
|
||||
setPdfLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
minHeight: 'calc(100vh - 52px)',
|
||||
background: '#0f0f12',
|
||||
padding: 24,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: '1rem', fontWeight: 600, color: '#e4e4e7' }}>
|
||||
Vaccination Record
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
minWidth: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<section style={cardStyle}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Patient
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label>
|
||||
<span style={labelStyle}>Patient Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={patientName}
|
||||
onChange={(e) => setPatientName(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Date of Birth</span>
|
||||
<LocaleDateInput
|
||||
value={dateOfBirth}
|
||||
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Last update</span>
|
||||
<LocaleDateInput
|
||||
value={lastUpdate}
|
||||
onChange={(e) => setLastUpdate(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, width: '100%' }}>
|
||||
<div style={{ ...cardStyle, width: '50%' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Add vaccination row
|
||||
</h3>
|
||||
{DISEASE_OPTIONS.length === 0 ? (
|
||||
<p style={{ margin: 0, color: '#71717a', fontSize: '0.875rem' }}>
|
||||
No diseases configured. Add entries to DISEASE_OPTIONS.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label>
|
||||
<span style={labelStyle}>Disease</span>
|
||||
<select
|
||||
value={formDiseaseKey}
|
||||
onChange={(e) => setFormDiseaseKey(e.target.value)}
|
||||
style={fieldStyle}
|
||||
>
|
||||
{DISEASE_OPTIONS.map((v) => (
|
||||
<option key={v.key} value={v.key}>
|
||||
{v.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Date</span>
|
||||
<LocaleDateInput
|
||||
value={formDate}
|
||||
onChange={(e) => setFormDate(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Product brand</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formProductBrand}
|
||||
onChange={(e) => setFormProductBrand(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Lot Number</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formLotNumber}
|
||||
onChange={(e) => setFormLotNumber(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Provider</span>
|
||||
<input
|
||||
type="text"
|
||||
value={formProvider}
|
||||
onChange={(e) => setFormProvider(e.target.value)}
|
||||
style={fieldStyle}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={!canAdd}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
background: canAdd ? '#7c3aed' : '#3f3f46',
|
||||
cursor: canAdd ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
Add to list
|
||||
</button>
|
||||
{atRowLimit && (
|
||||
<p style={{ margin: 0, fontSize: '0.75rem', color: '#a1a1aa' }}>
|
||||
Maximum of {VACCINATION_RECORD_MAX_ROWS} rows reached.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ ...cardStyle, width: '50%' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Notes
|
||||
</h3>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={4}
|
||||
style={{ ...fieldStyle, resize: 'vertical', minHeight: 300 }}
|
||||
placeholder="Notes for the bottom of the record…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPdf}
|
||||
disabled={importLoading || pdfLoading}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
cursor: importLoading || pdfLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: importLoading || pdfLoading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{importLoading ? 'Importing…' : 'Import PDF'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPdf}
|
||||
disabled={!canExport || pdfLoading || importLoading}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
background: !canExport || pdfLoading || importLoading ? '#3f3f46' : '#7c3aed',
|
||||
cursor: !canExport || pdfLoading || importLoading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{pdfLoading ? 'Generating…' : 'Export PDF'}
|
||||
</button>
|
||||
{importError && (
|
||||
<span style={{ fontSize: '0.875rem', color: '#fca5a5' }}>{importError}</span>
|
||||
)}
|
||||
{pdfError && (
|
||||
<span style={{ fontSize: '0.875rem', color: '#fca5a5' }}>{pdfError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrintLabel}
|
||||
disabled={!isPatientFormComplete || pdfLoading || importLoading}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
background: !isPatientFormComplete || pdfLoading || importLoading ? '#3f3f46' : '#7c3aed',
|
||||
cursor: !isPatientFormComplete || pdfLoading || importLoading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{pdfLoading ? 'Generating…' : 'Print Label'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 50%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px', fontSize: '0.875rem', fontWeight: 600, color: '#a1a1aa' }}>
|
||||
Row list
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '0.75rem', color: '#71717a' }}>
|
||||
Up to {VACCINATION_RECORD_MAX_ROWS} rows; order here matches the PDF table.
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<li style={{ color: '#a1a1aa', fontSize: '0.75rem' }}>
|
||||
Add vaccination rows using the form on the left.
|
||||
</li>
|
||||
) : (
|
||||
rows.map((row, idx) => (
|
||||
<li
|
||||
key={row.slotId}
|
||||
style={{
|
||||
...cardStyle,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: '0.875rem', color: '#e4e4e7' }}>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>{diseaseLabel(row.diseaseKey)}</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#a1a1aa', lineHeight: 1.5 }}>
|
||||
{formatDisplayDate(row.date) || '—'}
|
||||
{row.productBrand ? ` · ${row.productBrand}` : ''}
|
||||
{row.lotNumber ? ` · Lot ${row.lotNumber}` : ''}
|
||||
{row.provider ? ` · ${row.provider}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
aria-label="Move up"
|
||||
style={{ ...iconButtonStyle, opacity: idx === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
<ChevronUp size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDown(idx)}
|
||||
disabled={idx === rows.length - 1}
|
||||
aria-label="Move down"
|
||||
style={{
|
||||
...iconButtonStyle,
|
||||
opacity: idx === rows.length - 1 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
aria-label={`Remove ${diseaseLabel(row.diseaseKey)}`}
|
||||
style={removeButtonStyle}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
background: #0f0f12;
|
||||
color: #e4e4e7;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.locale-date-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.locale-date-input__text {
|
||||
width: 100%;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.locale-date-input__native {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 2.5rem;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap {
|
||||
min-height: 20rem;
|
||||
padding: 12px;
|
||||
color: #e4e4e7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap ul,
|
||||
.instruction-editor .tiptap ol {
|
||||
margin: 0 0 0.5em;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap p.is-editor-empty:first-child::before {
|
||||
color: #71717a;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.instruction-editor .tiptap sub,
|
||||
.instruction-editor .tiptap sup {
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.instruction-editor__font-size {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
color: #e4e4e7;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instruction-editor__font-size:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.instruction-html-preview {
|
||||
line-height: 1.6;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.instruction-html-preview p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.instruction-html-preview p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.instruction-html-preview ul,
|
||||
.instruction-html-preview ol {
|
||||
margin: 0 0 0.5em;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.instruction-html-preview u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.instruction-html-preview s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.instruction-html-preview sub,
|
||||
.instruction-html-preview sup {
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.instruction-html-preview--list {
|
||||
line-height: 1.5;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.instruction-html-preview--list p {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.instruction-html-preview--list p:not(:last-child)::after {
|
||||
content: ' ';
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { toEditorHtml } from '@/lib/instructionHtml'
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** Matches former jsPDF 48pt margins; applied via @page so every printed page is inset. */
|
||||
const PRINT_MARGIN = '0.667in'
|
||||
|
||||
const PRINT_STYLES = `
|
||||
@page {
|
||||
size: letter;
|
||||
margin: ${PRINT_MARGIN};
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.header__logo img {
|
||||
display: block;
|
||||
height: 48px;
|
||||
width: auto;
|
||||
}
|
||||
.header__address {
|
||||
text-align: right;
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
padding-top: 10px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 40px;
|
||||
font-size: 15pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.entry {
|
||||
margin-bottom: 14pt;
|
||||
}
|
||||
.entry h2 {
|
||||
margin: 0;
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
break-after: avoid;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.instruction-body {
|
||||
margin-top: 4px;
|
||||
break-inside: auto;
|
||||
}
|
||||
.instruction-body p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
.instruction-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.instruction-body ul,
|
||||
.instruction-body ol {
|
||||
margin: 0 0 0.5em;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.instruction-body u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.instruction-body s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.instruction-body sub,
|
||||
.instruction-body sup {
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Build a complete HTML document for Chromium printToPDF.
|
||||
* @param {{ items: { vaccine: string, instruction?: string }[], logoBase64?: string | null, address?: string }} options
|
||||
*/
|
||||
export function buildPrintDocumentHtml({ items, logoBase64, address }) {
|
||||
const addressText = address ?? ''
|
||||
const logoBlock =
|
||||
logoBase64 != null && logoBase64 !== ''
|
||||
? `<div class="header__logo"><img src="data:image/png;base64,${logoBase64}" alt="" /></div>`
|
||||
: '<div class="header__logo"></div>'
|
||||
|
||||
const entriesHtml = items
|
||||
.map((it) => {
|
||||
const instructionHtml = toEditorHtml(it.instruction ?? '')
|
||||
const bodyBlock = instructionHtml
|
||||
? `<div class="instruction-body">${instructionHtml}</div>`
|
||||
: ''
|
||||
return `<section class="entry">
|
||||
<h2>${escapeHtml(it.vaccine)}</h2>
|
||||
${bodyBlock}
|
||||
</section>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Instructions</title>
|
||||
<style>${PRINT_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
${logoBlock}
|
||||
<div class="header__address">${escapeHtml(addressText)}</div>
|
||||
</header>
|
||||
<h1>Instructions</h1>
|
||||
${entriesHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { formatDisplayDate } from '@/lib/dateLocale'
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function buildVaccinationLabelHtml({ patientName, dateOfBirth }) {
|
||||
const formattedDob = formatDisplayDate(dateOfBirth)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vaccination Label</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 11in 5.5in;
|
||||
margin: 0;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 11in;
|
||||
height: 5.5in;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.label-container {
|
||||
position: absolute;
|
||||
top: 68%; /* 2/3 down the page */
|
||||
right: 0.3in; /* right aligned */
|
||||
transform: translateY(-50%);
|
||||
text-align: right;
|
||||
}
|
||||
.label-field {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
.label-field + .label-field {
|
||||
margin-left: 32px; /* space between name and DOB */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label-container">
|
||||
<span class="label-field">${escapeHtml(patientName)}</span>
|
||||
<span class="label-field">${escapeHtml(formattedDob)}</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { formatDisplayDate as formatLocaleDisplayDate } from '@/lib/dateLocale'
|
||||
import { FIT_VACCINATION_TABLE_CELLS_INIT } from '@/lib/fitVaccinationTableCellsScript'
|
||||
import { buildEncodedVaccinationRecordMetadata } from '@/lib/vaccinationRecordMetadata'
|
||||
import {
|
||||
DISEASE_GROUP_HEADER,
|
||||
VACCINATION_RECORD_MAX_ROWS,
|
||||
} from '@/lib/vaccinationRecordDiseases'
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** Full label in one vertical column; wraps only when taller than max-height. */
|
||||
function formatDiseaseHeaderLabel(text) {
|
||||
const label = String(text).trim()
|
||||
if (!label) return ''
|
||||
return `<span class="disease-header__label">${escapeHtml(label)}</span>`
|
||||
}
|
||||
|
||||
function formatDisplayDate(value) {
|
||||
if (!value) return ''
|
||||
const formatted = formatLocaleDisplayDate(value)
|
||||
return escapeHtml(formatted || value)
|
||||
}
|
||||
|
||||
export const VACCINATION_RECORD_PAGE_WIDTH = '11in'
|
||||
export const VACCINATION_RECORD_PAGE_HEIGHT = '5.5in'
|
||||
export const VACCINATION_RECORD_PAGE_MARGIN = '0.1in'
|
||||
|
||||
const PRINT_STYLES = `
|
||||
@page {
|
||||
size: ${VACCINATION_RECORD_PAGE_WIDTH} ${VACCINATION_RECORD_PAGE_HEIGHT};
|
||||
margin: ${VACCINATION_RECORD_PAGE_MARGIN};
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 7pt;
|
||||
line-height: 1.25;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 7pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #333;
|
||||
padding: 2px 2px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
th {
|
||||
font-weight: 700;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
col.col-date { width: 9%; }
|
||||
col.col-brand { width: 11%; }
|
||||
col.col-lot { width: 10%; }
|
||||
col.col-provider { width: 11%; }
|
||||
.col-date { width: 9%; }
|
||||
.col-brand { width: 11%; }
|
||||
th.col-patient-block {
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 6pt;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
padding: 2px 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-patient-header {
|
||||
margin: 0;
|
||||
}
|
||||
.table-patient-header dt {
|
||||
display: inline;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.table-patient-header dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 0.25em;
|
||||
font-weight: 400;
|
||||
}
|
||||
.table-patient-header dd::after {
|
||||
content: '';
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.col-disease { width: auto; min-width: 0; }
|
||||
th.col-disease {
|
||||
vertical-align: bottom;
|
||||
padding: 2px 1px;
|
||||
}
|
||||
.col-lot { width: 10%; }
|
||||
.col-provider { width: 11%; }
|
||||
.disease-group-banner {
|
||||
font-size: 6pt;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 3px 2px;
|
||||
line-height: 1.2;
|
||||
background: #e4e4e4;
|
||||
}
|
||||
.disease-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
min-height: 58px;
|
||||
max-height: 60px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.disease-header__label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 5pt;
|
||||
line-height: 1.1;
|
||||
max-height: 58px;
|
||||
}
|
||||
tbody tr {
|
||||
height: 17px;
|
||||
}
|
||||
tbody td {
|
||||
height: 17px;
|
||||
max-height: 17px;
|
||||
overflow: hidden;
|
||||
padding: 2px 2px;
|
||||
line-height: 1.15;
|
||||
vertical-align: middle;
|
||||
}
|
||||
td.cell-fit {
|
||||
padding: 2px 2px;
|
||||
}
|
||||
.cell-fit__text {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.1;
|
||||
font-size: 7pt;
|
||||
}
|
||||
.checkmark {
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.page-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.page-footer__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.page-footer__brand img {
|
||||
display: block;
|
||||
height: 16px;
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-footer__name {
|
||||
font-size: 7pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.page-footer__notes {
|
||||
min-width: 0;
|
||||
}
|
||||
.page-footer__notes h2 {
|
||||
margin: 0 0 3px;
|
||||
font-size: 7pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
.page-footer__notes p {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
font-size: 7pt;
|
||||
min-height: 24px;
|
||||
border: 1px solid #333;
|
||||
padding: 4px;
|
||||
}
|
||||
.vr-metadata {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 1pt;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
`
|
||||
|
||||
function renderDiseaseCells(diseases, row) {
|
||||
return diseases
|
||||
.map((v) => {
|
||||
const mark = row && v.key === row.diseaseKey ? '<span class="checkmark">☑</span>' : ''
|
||||
return `<td class="col-disease">${mark}</td>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderFitCell(className, content) {
|
||||
const inner = content ? `<span class="cell-fit__text">${content}</span>` : ''
|
||||
return `<td class="${className} cell-fit">${inner}</td>`
|
||||
}
|
||||
|
||||
function renderPageFooter(logoBase64, notes) {
|
||||
const logo =
|
||||
logoBase64 != null && logoBase64 !== ''
|
||||
? `<img src="data:image/png;base64,${logoBase64}" alt="" />`
|
||||
: ''
|
||||
return `<div class="page-footer">
|
||||
<div class="page-footer__brand">
|
||||
${logo}<span class="page-footer__name">Canada TravelMED © ${new Date().getFullYear()}</span>
|
||||
</div>
|
||||
<section class="page-footer__notes">
|
||||
<h2>Notes</h2>
|
||||
<p>${escapeHtml(notes ?? '')}</p>
|
||||
</section>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderTablePatientHeader(patientName, dateOfBirth, lastUpdate) {
|
||||
return `<dl class="table-patient-header">
|
||||
<dt>Patient Name:</dt><dd>${escapeHtml(patientName ?? '')}</dd>
|
||||
<dt>Date of Birth:</dt><dd>${formatDisplayDate(dateOfBirth)}</dd>
|
||||
<dt>Last update:</dt><dd>${formatDisplayDate(lastUpdate)}</dd>
|
||||
</dl>`
|
||||
}
|
||||
|
||||
function renderDataRow(row, diseases) {
|
||||
return `<tr>
|
||||
${renderFitCell('col-date', formatDisplayDate(row.date))}
|
||||
${renderFitCell('col-brand', escapeHtml(row.productBrand ?? ''))}
|
||||
${renderDiseaseCells(diseases, row)}
|
||||
${renderFitCell('col-lot', escapeHtml(row.lotNumber ?? ''))}
|
||||
${renderFitCell('col-provider', escapeHtml(row.provider ?? ''))}
|
||||
</tr>`
|
||||
}
|
||||
|
||||
const EMPTY_CELL = ' '
|
||||
|
||||
function renderEmptyRow(diseases) {
|
||||
return `<tr>
|
||||
<td class="col-date">${EMPTY_CELL}</td>
|
||||
<td class="col-brand">${EMPTY_CELL}</td>
|
||||
${diseases.map(() => `<td class="col-disease">${EMPTY_CELL}</td>`).join('')}
|
||||
<td class="col-lot">${EMPTY_CELL}</td>
|
||||
<td class="col-provider">${EMPTY_CELL}</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* logoBase64?: string | null,
|
||||
* patientName?: string,
|
||||
* dateOfBirth?: string,
|
||||
* lastUpdate?: string,
|
||||
* notes?: string,
|
||||
* diseases: { key: string, value: string }[],
|
||||
* rows: { diseaseKey: string, date: string, productBrand?: string, lotNumber?: string, provider?: string }[],
|
||||
* }} options
|
||||
*/
|
||||
export function buildVaccinationRecordHtml({
|
||||
logoBase64,
|
||||
patientName,
|
||||
dateOfBirth,
|
||||
lastUpdate,
|
||||
notes,
|
||||
diseases,
|
||||
rows,
|
||||
}) {
|
||||
const diseaseHeaders = diseases
|
||||
.map(
|
||||
(v) =>
|
||||
`<th class="col-disease"><div class="disease-header">${formatDiseaseHeaderLabel(v.value)}</div></th>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const colGroup = [
|
||||
'<col class="col-date" />',
|
||||
'<col class="col-brand" />',
|
||||
...diseases.map(() => '<col class="col-disease" />'),
|
||||
'<col class="col-lot" />',
|
||||
'<col class="col-provider" />',
|
||||
].join('\n ')
|
||||
|
||||
const dataRows = rows.slice(0, VACCINATION_RECORD_MAX_ROWS)
|
||||
const emptyCount = Math.max(0, VACCINATION_RECORD_MAX_ROWS - dataRows.length)
|
||||
const bodyRows = [
|
||||
...dataRows.map((row) => renderDataRow(row, diseases)),
|
||||
...Array.from({ length: emptyCount }, () => renderEmptyRow(diseases)),
|
||||
].join('\n')
|
||||
|
||||
const encodedMetadata = buildEncodedVaccinationRecordMetadata({
|
||||
patientName,
|
||||
dateOfBirth,
|
||||
notes,
|
||||
rows: dataRows,
|
||||
})
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vaccination Record</title>
|
||||
<style>${PRINT_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<colgroup>
|
||||
${colGroup}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-patient-block" colspan="2">
|
||||
${renderTablePatientHeader(patientName, dateOfBirth, lastUpdate)}
|
||||
</th>
|
||||
<th class="disease-group-banner" colspan="${diseases.length || 1}">
|
||||
${escapeHtml(DISEASE_GROUP_HEADER)}
|
||||
</th>
|
||||
<th class="col-lot" rowspan="2">Lot Number</th>
|
||||
<th class="col-provider" rowspan="2">Provider</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="col-date">Date</th>
|
||||
<th class="col-brand">Product brand</th>
|
||||
${diseaseHeaders}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${bodyRows}
|
||||
</tbody>
|
||||
</table>
|
||||
${renderPageFooter(logoBase64, notes)}
|
||||
<span class="vr-metadata" aria-hidden="true">${encodedMetadata}</span>
|
||||
<script>${FIT_VACCINATION_TABLE_CELLS_INIT}</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export const DATE_LOCALE = 'en-CA'
|
||||
|
||||
/** Format hint for empty date fields (en-CA numeric dates). */
|
||||
export const DATE_INPUT_PLACEHOLDER = 'YYYY-MM-DD'
|
||||
|
||||
export const DATE_MASK_MAX_LENGTH = 10
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/** Restrict input to en-CA numeric date mask: YYYY-MM-DD */
|
||||
export function applyDateMask(value) {
|
||||
const digits = String(value).replace(/\D/g, '').slice(0, 8)
|
||||
if (digits.length <= 4) return digits
|
||||
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`
|
||||
}
|
||||
|
||||
export function isValidIsoDate(value) {
|
||||
if (!ISO_DATE_RE.test(value)) return false
|
||||
const d = new Date(`${value}T00:00:00`)
|
||||
return !Number.isNaN(d.getTime())
|
||||
}
|
||||
|
||||
const INPUT_DATE_OPTIONS = { year: 'numeric', month: '2-digit', day: '2-digit' }
|
||||
const DISPLAY_DATE_OPTIONS = { year: 'numeric', month: 'short', day: '2-digit' }
|
||||
|
||||
/** en-CA field value, e.g. 2024-06-03 */
|
||||
export function formatDateInputValue(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
return d.toLocaleDateString(DATE_LOCALE, INPUT_DATE_OPTIONS)
|
||||
}
|
||||
|
||||
/** en-CA readable date, e.g. Jun 3, 2024 */
|
||||
export function formatDisplayDate(value) {
|
||||
if (!value) return ''
|
||||
const d = new Date(`${value}T00:00:00`)
|
||||
if (Number.isNaN(d.getTime())) return String(value)
|
||||
return d.toLocaleDateString(DATE_LOCALE, DISPLAY_DATE_OPTIONS)
|
||||
}
|
||||
|
||||
/** Parse display or ISO date string to YYYY-MM-DD. */
|
||||
export function parseDisplayDateToIso(value) {
|
||||
if (!value) return ''
|
||||
const trimmed = String(value).trim()
|
||||
if (ISO_DATE_RE.test(trimmed)) return trimmed
|
||||
const isoMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (isoMatch) return trimmed
|
||||
const d = new Date(`${trimmed}T00:00:00`)
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
const parsed = Date.parse(trimmed)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const d2 = new Date(parsed)
|
||||
const y = d2.getFullYear()
|
||||
const m = String(d2.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d2.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Pre-print script: two-tier font sizing for vaccination table body cells. */
|
||||
export const FIT_VACCINATION_TABLE_CELLS_BODY = `
|
||||
const DEFAULT_PT = 7;
|
||||
const SHRUNK_MAX_PT = 6;
|
||||
const MIN_PT = 5;
|
||||
const STEP = 0.25;
|
||||
const MAX_LINES = 2;
|
||||
|
||||
function measure(span, td, pt) {
|
||||
span.style.fontSize = pt + 'pt';
|
||||
const lineHeight = parseFloat(getComputedStyle(span).lineHeight);
|
||||
if (!lineHeight || Number.isNaN(lineHeight)) {
|
||||
return { lineCount: 99, fits: false };
|
||||
}
|
||||
const lineCount = Math.ceil(span.scrollHeight / lineHeight);
|
||||
const maxAllowedHeight = Math.min(td.clientHeight, lineHeight * MAX_LINES);
|
||||
const fits = lineCount <= MAX_LINES && span.scrollHeight <= maxAllowedHeight;
|
||||
return { lineCount, fits };
|
||||
}
|
||||
|
||||
function setFont(span, pt) {
|
||||
span.style.fontSize = pt + 'pt';
|
||||
}
|
||||
|
||||
document.querySelectorAll('tbody td.cell-fit .cell-fit__text').forEach((span) => {
|
||||
const text = span.textContent.trim();
|
||||
if (!text) return;
|
||||
const td = span.closest('td');
|
||||
if (!td) return;
|
||||
|
||||
const probe = measure(span, td, DEFAULT_PT);
|
||||
if (probe.lineCount === 1 && probe.fits) {
|
||||
setFont(span, DEFAULT_PT);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let pt = SHRUNK_MAX_PT; pt >= MIN_PT - STEP / 2; pt -= STEP) {
|
||||
const rounded = Math.round(pt * 100) / 100;
|
||||
const result = measure(span, td, rounded);
|
||||
if (result.fits) {
|
||||
setFont(span, rounded);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFont(span, MIN_PT);
|
||||
});
|
||||
`
|
||||
|
||||
export const FIT_VACCINATION_TABLE_CELLS_INIT = `
|
||||
window.__pdfLayoutReady = (async () => {
|
||||
await document.fonts.ready;
|
||||
${FIT_VACCINATION_TABLE_CELLS_BODY}
|
||||
})();
|
||||
`
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* True when HTML has no meaningful text (empty TipTap doc, whitespace only, etc.).
|
||||
*/
|
||||
export function isInstructionEmpty(html) {
|
||||
if (!html || typeof html !== 'string') return true
|
||||
if (!/<[a-z][\s\S]*>/i.test(html)) return html.trim().length === 0
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const text = (doc.body.textContent ?? '').replace(/\u00a0/g, ' ').trim()
|
||||
return text.length === 0
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize stored instruction for TipTap: pass HTML through, wrap plain text in paragraphs.
|
||||
*/
|
||||
export function toEditorHtml(instruction) {
|
||||
if (!instruction || typeof instruction !== 'string') return ''
|
||||
if (/<[a-z][\s\S]*>/i.test(instruction)) return instruction
|
||||
|
||||
const lines = instruction.split('\n')
|
||||
if (lines.length === 0) return ''
|
||||
|
||||
return lines.map((line) => `<p>${line ? escapeHtml(line) : '<br>'}</p>`).join('')
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
function getPbApi() {
|
||||
if (!window.api?.pb) {
|
||||
throw new Error('PocketBase API is only available in the Electron app')
|
||||
}
|
||||
return window.api.pb
|
||||
}
|
||||
|
||||
export async function fetchEntries() {
|
||||
return getPbApi().fetchEntries()
|
||||
}
|
||||
|
||||
export async function createEntry({ vaccine, instruction }) {
|
||||
return getPbApi().createEntry({ vaccine, instruction })
|
||||
}
|
||||
|
||||
export async function updateEntry(id, data) {
|
||||
return getPbApi().updateEntry(id, data)
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
return getPbApi().deleteEntry(id)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Maximum data rows in the vaccination record PDF table. */
|
||||
export const VACCINATION_RECORD_MAX_ROWS = 20
|
||||
|
||||
export const DISEASE_GROUP_HEADER =
|
||||
'Immunization protects against the following diseases:'
|
||||
|
||||
/** Hardcoded disease columns for the vaccination record table. Edit as needed. */
|
||||
export const DISEASE_OPTIONS = [
|
||||
{ key: 'hep-a', value: 'Hepatitis A' },
|
||||
{ key: 'hep-b', value: 'Hepatitis B' },
|
||||
{ key: 'typhoid', value: 'Typhoid Fever' },
|
||||
{ key: 'japanese-encephalitis', value: 'Japanese Encephalitis' },
|
||||
{ key: 'cholera', value: 'ETEC-Cholera' },
|
||||
{ key: 'tdap', value: 'TdaP' },
|
||||
{ key: 'rabies', value: 'Rabies' },
|
||||
{ key: 'meningococcal', value: 'Meningococcal' },
|
||||
{ key: 'yellow-fever', value: 'Yellow Fever' },
|
||||
{ key: 'hpv', value: 'HPV' },
|
||||
{ key: 'polio', value: 'Polio' },
|
||||
{ key: 'h-influenza', value: 'Haemophilus Influenza' },
|
||||
{ key: 'mmr', value: 'MMR' },
|
||||
{ key: 'chicken-pox', value: 'Chicken Pox' },
|
||||
{ key: 'shingles', value: 'Shingles' },
|
||||
{ key: 'pneumococcal', value: 'Pneumococcal' },
|
||||
{ key: 'bcg', value: 'BCG' },
|
||||
{ key: 'chikungunya', value: 'Chikungunya' },
|
||||
]
|
||||
|
||||
export function diseaseLabel(key) {
|
||||
return DISEASE_OPTIONS.find((o) => o.key === key)?.value ?? key
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { isValidIsoDate } from './dateLocale.js'
|
||||
import {
|
||||
DISEASE_OPTIONS,
|
||||
VACCINATION_RECORD_MAX_ROWS,
|
||||
} from './vaccinationRecordDiseases.js'
|
||||
|
||||
export const VR_METADATA_MARKER = 'CTM_VR'
|
||||
export const VR_METADATA_LATEST_VERSION = 1
|
||||
|
||||
const DISEASE_KEYS = new Set(DISEASE_OPTIONS.map((d) => d.key))
|
||||
const METADATA_RE = /CTM_VR:(\d+):([A-Za-z0-9_-]+)/
|
||||
|
||||
function encodeBase64Url(str) {
|
||||
const bytes = new TextEncoder().encode(str)
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(bytes).toString('base64url')
|
||||
}
|
||||
let binary = ''
|
||||
for (const b of bytes) binary += String.fromCharCode(b)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function decodeBase64Url(encoded) {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8')
|
||||
}
|
||||
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (base64.length % 4) base64 += '='
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function str(value) {
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
function buildV1Payload({ patientName, dateOfBirth, notes, rows }) {
|
||||
const normalizedRows = (rows ?? [])
|
||||
.slice(0, VACCINATION_RECORD_MAX_ROWS)
|
||||
.map((row) => {
|
||||
const diseaseKey = str(row.diseaseKey)
|
||||
if (!DISEASE_KEYS.has(diseaseKey)) {
|
||||
throw new Error(`Invalid disease key in vaccination record metadata: ${diseaseKey}`)
|
||||
}
|
||||
return {
|
||||
diseaseKey,
|
||||
date: str(row.date),
|
||||
productBrand: str(row.productBrand),
|
||||
lotNumber: str(row.lotNumber),
|
||||
provider: str(row.provider),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
patientName: str(patientName),
|
||||
dateOfBirth: str(dateOfBirth),
|
||||
notes: str(notes),
|
||||
rows: normalizedRows,
|
||||
}
|
||||
}
|
||||
|
||||
function parseV1Payload(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid vaccination record metadata payload.')
|
||||
}
|
||||
|
||||
const patientName = str(raw.patientName)
|
||||
const dateOfBirth = str(raw.dateOfBirth)
|
||||
const notes = str(raw.notes)
|
||||
|
||||
if (dateOfBirth && !isValidIsoDate(dateOfBirth)) {
|
||||
throw new Error('Invalid date of birth in vaccination record metadata.')
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw.rows)) {
|
||||
throw new Error('Invalid rows in vaccination record metadata.')
|
||||
}
|
||||
|
||||
const rows = raw.rows.slice(0, VACCINATION_RECORD_MAX_ROWS).map((row, index) => {
|
||||
if (!row || typeof row !== 'object') {
|
||||
throw new Error(`Invalid row at index ${index} in vaccination record metadata.`)
|
||||
}
|
||||
const diseaseKey = str(row.diseaseKey)
|
||||
if (!DISEASE_KEYS.has(diseaseKey)) {
|
||||
throw new Error(`Invalid disease key at row ${index}: ${diseaseKey}`)
|
||||
}
|
||||
const date = str(row.date)
|
||||
if (date && !isValidIsoDate(date)) {
|
||||
throw new Error(`Invalid date at row ${index} in vaccination record metadata.`)
|
||||
}
|
||||
return {
|
||||
diseaseKey,
|
||||
date,
|
||||
productBrand: str(row.productBrand),
|
||||
lotNumber: str(row.lotNumber),
|
||||
provider: str(row.provider),
|
||||
}
|
||||
})
|
||||
|
||||
return { patientName, dateOfBirth, notes, rows }
|
||||
}
|
||||
|
||||
const VERSION_PARSERS = {
|
||||
1: parseV1Payload,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ patientName?: string, dateOfBirth?: string, notes?: string, rows?: object[] }} data
|
||||
*/
|
||||
export function buildEncodedVaccinationRecordMetadata(data) {
|
||||
const payload = buildV1Payload(data)
|
||||
const encoded = encodeBase64Url(JSON.stringify(payload))
|
||||
return `${VR_METADATA_MARKER}:${VR_METADATA_LATEST_VERSION}:${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fullText
|
||||
* @returns {{ patientName: string, dateOfBirth: string, notes: string, rows: object[] }}
|
||||
*/
|
||||
export function parseEncodedVaccinationRecordMetadata(fullText) {
|
||||
const match = String(fullText).match(METADATA_RE)
|
||||
if (!match) {
|
||||
throw new Error('This PDF does not contain CTM vaccination record import data.')
|
||||
}
|
||||
|
||||
const version = Number(match[1])
|
||||
const parser = VERSION_PARSERS[version]
|
||||
if (!parser) {
|
||||
throw new Error(`Unsupported vaccination record metadata version: ${version}.`)
|
||||
}
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(decodeBase64Url(match[2]))
|
||||
} catch {
|
||||
throw new Error('Failed to parse vaccination record metadata.')
|
||||
}
|
||||
|
||||
return parser(json)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (rootEl) {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
postMessage({ payload: 'removeLoading' }, '*')
|
||||
@@ -0,0 +1,60 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import electron from 'vite-plugin-electron/simple'
|
||||
import pkg from './package.json'
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
rmSync('dist-electron', { recursive: true, force: true })
|
||||
|
||||
const isServe = command === 'serve'
|
||||
const isBuild = command === 'build'
|
||||
const sourcemap = isServe || !!process.env.VSCODE_DEBUG
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: { '@': path.join(__dirname, 'src') },
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
electron({
|
||||
main: {
|
||||
entry: 'electron/main/index.js',
|
||||
onstart(args) {
|
||||
if (process.env.VSCODE_DEBUG) {
|
||||
console.log('[startup] Electron App')
|
||||
} else {
|
||||
args.startup()
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
sourcemap,
|
||||
minify: isBuild,
|
||||
outDir: 'dist-electron/main',
|
||||
rollupOptions: {
|
||||
external: Object.keys(pkg.dependencies ?? {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
input: 'electron/preload/index.js',
|
||||
vite: {
|
||||
build: {
|
||||
sourcemap: sourcemap ? 'inline' : undefined,
|
||||
minify: isBuild,
|
||||
outDir: 'dist-electron/preload',
|
||||
rollupOptions: {
|
||||
external: Object.keys(pkg.dependencies ?? {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {},
|
||||
}),
|
||||
],
|
||||
clearScreen: false,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user