first commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user