476 lines
16 KiB
React
476 lines
16 KiB
React
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>
|
|
)
|
|
}
|