first commit

This commit is contained in:
2026-07-17 13:28:28 -04:00
commit fe0288c284
31 changed files with 10756 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dist-electron
release
.env
.DS_Store
.cache
*.log
.wine/
.npm/
+95
View File
@@ -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.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+176
View File
@@ -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)
}
+4
View File
@@ -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!!__'
+78
View File
@@ -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)
}
+92
View File
@@ -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
View File
@@ -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>
+6758
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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
View File
@@ -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>
)
}
+679
View File
@@ -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>
)
}
+475
View File
@@ -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>
)
}
+125
View File
@@ -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>
)
}
+263
View File
@@ -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>
)
}
+592
View File
@@ -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
View File
@@ -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: ' ';
}
+136
View File
@@ -0,0 +1,136 @@
import { toEditorHtml } from '@/lib/instructionHtml'
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** 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>`
}
+63
View File
@@ -0,0 +1,63 @@
import { formatDisplayDate } from '@/lib/dateLocale'
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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>`
}
+367
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** 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">&#9745;</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 &copy; ${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 = '&nbsp;'
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>`
}
+66
View File
@@ -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 ''
}
+55
View File
@@ -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}
})();
`
+31
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/**
* 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('')
}
+22
View File
@@ -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)
}
+31
View File
@@ -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
}
+142
View File
@@ -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)
}
+14
View File
@@ -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' }, '*')
+60
View File
@@ -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,
}
})