Files
CTM-Document-Builder/src/components/Instructions.jsx
T
2026-07-17 13:28:28 -04:00

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>
)
}