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