feat(sales): add quotes tab, PDF viewer, and opportunity sidebar enhancements

This commit is contained in:
2026-03-06 23:49:27 -06:00
parent 762edd8eb7
commit b735981b6b
17 changed files with 4222 additions and 129 deletions
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { browser } from "$app/environment";
export let src: string | null = null;
let container: HTMLDivElement;
let pageCount = 0;
let loading = true;
let error = "";
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
let currentPdf: any = null;
let currentSrc: string | null = null;
let renderGeneration = 0;
// Max canvas dimension to prevent browser OOM
const MAX_CANVAS_DIM = 2048;
async function initPdfJs() {
if (!browser || pdfjsLib) return;
const lib = await import("pdfjs-dist");
// Use fake worker to avoid worker-loading issues with Vite/SvelteKit
lib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
pdfjsLib = lib;
}
async function renderPdf(pdfSrc: string) {
if (!pdfSrc || !container || !browser) return;
// Set currentSrc immediately to prevent re-entry
currentSrc = pdfSrc;
const generation = ++renderGeneration;
loading = true;
error = "";
// Clean up previous PDF
if (currentPdf) {
currentPdf.destroy();
currentPdf = null;
}
container.innerHTML = "";
try {
await initPdfJs();
if (!pdfjsLib || generation !== renderGeneration) return;
const pdf = await pdfjsLib.getDocument({ url: pdfSrc, isEvalSupported: false }).promise;
if (generation !== renderGeneration) {
pdf.destroy();
return;
}
currentPdf = pdf;
pageCount = pdf.numPages;
for (let i = 1; i <= pageCount; i++) {
if (generation !== renderGeneration) return;
const page = await pdf.getPage(i);
// Calculate a safe scale that won't exceed MAX_CANVAS_DIM
const baseViewport = page.getViewport({ scale: 1.0 });
const maxDim = Math.max(baseViewport.width, baseViewport.height);
const safeScale = maxDim > MAX_CANVAS_DIM ? MAX_CANVAS_DIM / maxDim : 1.0;
const viewport = page.getViewport({ scale: safeScale });
const canvas = document.createElement("canvas");
canvas.className = "pdf-viewer-page";
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
container.appendChild(canvas);
await page.render({ canvas, viewport } as any).promise;
// Release page resources after rendering
page.cleanup();
}
} catch (err: unknown) {
if (generation === renderGeneration) {
error = err instanceof Error ? err.message : "Failed to render PDF";
console.error("PDF render error:", err);
}
} finally {
if (generation === renderGeneration) {
loading = false;
}
}
}
function cleanup() {
renderGeneration++;
if (currentPdf) {
currentPdf.destroy();
currentPdf = null;
}
currentSrc = null;
}
// React to src changes (also handles initial render once container is bound)
$: if (browser && src && src !== currentSrc && container) {
renderPdf(src);
}
// Handle no-src case
$: if (browser && !src) {
loading = false;
}
onDestroy(() => {
cleanup();
});
</script>
<div
class="pdf-viewer"
on:contextmenu|preventDefault
role="document"
>
{#if loading}
<div class="pdf-viewer-loading">
<p>Rendering PDF...</p>
</div>
{/if}
{#if error}
<div class="pdf-viewer-error">
<p>{error}</p>
</div>
{/if}
<div class="pdf-viewer-pages" bind:this={container}></div>
</div>
<style>
.pdf-viewer {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: var(--bg-surface-raised, #2a2a2a);
-webkit-user-select: none;
user-select: none;
}
.pdf-viewer-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px;
}
.pdf-viewer-pages :global(.pdf-viewer-page) {
max-width: 100%;
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.pdf-viewer-loading,
.pdf-viewer-error {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-size: 12px;
color: var(--text-muted, #888);
font-style: italic;
}
</style>