Files
optima/src/routes/sales/opportunity/[id]/components/QuotesTab.svelte
T

1310 lines
44 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from "svelte";
import { PUBLIC_API_URL } from "$env/static/public";
import { io, type Socket } from "socket.io-client";
import {
sales,
type CommittedQuote,
type QuoteDownloadLog,
} from "$lib/optima-api/modules/sales";
import type { PermissionMap } from "$lib/permissions";
import PdfViewer from "../../../../../components/PdfViewer.svelte";
export let accessToken: string | null = null;
export let opportunityId: string = "";
export let quotePreviewPdfUrl: string | null = null;
export let initialQuotes: CommittedQuote[] = [];
export let initialQuoteId: string | null = null;
export let permissions: PermissionMap = {} as PermissionMap;
export let isClosedOpportunity: boolean = false;
const dispatch = createEventDispatcher<{ quotesChanged: CommittedQuote[] }>();
// ── Permission helpers ──
$: canFetchQuotes = permissions["sales.opportunity.quote.fetch"] !== false;
$: canCommitQuote =
!isClosedOpportunity &&
permissions["sales.opportunity.quote.commit"] === true;
$: canPreviewQuote = permissions["sales.opportunity.quote.preview"] === true;
$: canDownloadQuote =
permissions["sales.opportunity.quote.download"] === true;
$: canFetchDownloads =
permissions["sales.opportunity.quote.fetch_downloads"] === true;
// ── Quotes list state (basic data from server) ──
let quotes: CommittedQuote[] = initialQuotes;
let quotesLoading = false;
let quotesError = "";
// Determine initial selection: prefer initialQuoteId match, fall back to first quote
const initialMatch = initialQuoteId
? initialQuotes.find(
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId,
)
: null;
console.log(
"[QuotesTab] initialQuoteId:",
initialQuoteId,
"quotes:",
initialQuotes.map((q) => ({ id: q.id, fileName: q.quoteFileName })),
"match:",
initialMatch?.id,
);
let selectedQuote: CommittedQuote | null =
initialMatch ?? (initialQuotes.length > 0 ? initialQuotes[0] : null);
// Auto-select quote by ID when navigating from activity tab (for post-mount updates)
$: if (initialQuoteId && quotes.length > 0) {
const match = quotes.find(
(q) => q.id === initialQuoteId || q.quoteFileName === initialQuoteId,
);
if (match) {
selectedQuote = match;
viewMode = "list";
loadQuotePreview(match.id);
}
}
// ── Detail data (lazy-loaded with regen data & params) ──
let detailQuotes: Map<string, CommittedQuote> = new Map();
let detailLoading = false;
let detailLoaded = false;
// ── Quote PDF preview (per-quote, from /quote/:id/preview) ──
let quotePreviewLoading = false;
let quotePreviewError = "";
let quotePreviewObjectUrl: string | null = null;
let quotePreviewQuoteId: string | null = null;
// ── View mode ──
type ViewMode = "list" | "preview" | "logs";
let viewMode: ViewMode = initialQuotes.length > 0 ? "list" : "preview";
// Show Quotes sub-tab only when there are quotes
$: showQuotesSubTab = quotes.length > 0;
// ── Live preview state ──
let quotePreviewOptions = {
lineItemPricing: false,
includeQuoteNarrative: true,
includeItemNarratives: true,
};
let isCommitting = false;
let commitError = "";
let commitSuccess = "";
let socket: Socket | null = null;
let liveEventName = "";
let previewEventName = "";
let isConnected = false;
let connectionError = "";
let livePreviewPdfUrl: string | null = null;
let livePreviewObjectUrl: string | null = null;
$: effectivePdfUrl = livePreviewPdfUrl ?? quotePreviewPdfUrl;
// ── Selected quote with detail data merged ──
$: selectedQuoteDetail = selectedQuote
? (detailQuotes.get(selectedQuote.id) ?? selectedQuote)
: null;
// ── Product splits & totals (from quote regen data) ──
$: allProducts = selectedQuoteDetail?.quoteRegenData?.products ?? [];
$: activeProducts = allProducts.filter((p) => p.cancellationType !== "full");
$: cancelledProducts = allProducts.filter(
(p) => p.cancellationType === "full",
);
$: totalRevenue = activeProducts.reduce(
(sum, p) => sum + (p.revenue ?? 0),
0,
);
$: totalCost = activeProducts.reduce((sum, p) => sum + (p.cost ?? 0), 0);
$: totalMargin = totalRevenue - totalCost;
$: totalItems = activeProducts.length;
$: totalQty = activeProducts.reduce(
(sum, p) => sum + (p.effectiveQuantity ?? p.quantity ?? 0),
0,
);
let cancelledExpanded = false;
// ── Download / Print state ──
let quoteDownloading = false;
let quotePrinting = false;
// ── Logs state ──
let downloadLogs: QuoteDownloadLog[] = [];
let logsLoading = false;
let logsLoaded = false;
let logsError = "";
// ── Helpers ──
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return iso;
}
}
function shortFileName(name: string): string {
return name.replace(/\.pdf$/i, "");
}
function formatCurrency(value: unknown): string {
const num = Number(value);
if (isNaN(num)) return "—";
return num.toLocaleString("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
});
}
/** Fetch basic quote list (lightweight, no regen data) */
async function loadQuotes() {
if (!accessToken || !opportunityId) return;
quotesLoading = true;
quotesError = "";
try {
const result = await sales.fetchQuotes(accessToken, opportunityId);
quotes = result.data ?? [];
dispatch("quotesChanged", quotes);
if (quotes.length > 0 && !selectedQuote) {
selectedQuote = quotes[0];
}
} catch (err: unknown) {
quotesError =
err instanceof Error ? err.message : "Failed to load quotes";
} finally {
quotesLoading = false;
}
}
/** Fetch full detail (regen data + params) — called once when tab is opened */
async function loadQuoteDetails() {
if (!accessToken || !opportunityId || detailLoaded || detailLoading) return;
detailLoading = true;
try {
const result = await sales.fetchQuotes(accessToken, opportunityId, {
includeRegenData: true,
includeRegenParams: true,
});
const map = new Map<string, CommittedQuote>();
for (const q of result.data ?? []) {
map.set(q.id, q);
}
detailQuotes = map;
detailLoaded = true;
} catch {
// Silently fail — detail is optional enhancement
} finally {
detailLoading = false;
}
}
function selectQuote(q: CommittedQuote) {
selectedQuote = q;
loadQuotePreview(q.id);
}
function cleanupQuotePreview() {
if (quotePreviewObjectUrl) {
URL.revokeObjectURL(quotePreviewObjectUrl);
quotePreviewObjectUrl = null;
}
quotePreviewQuoteId = null;
}
async function loadQuotePreview(quoteId: string) {
if (!accessToken || !opportunityId || !canPreviewQuote) return;
if (quotePreviewQuoteId === quoteId && quotePreviewObjectUrl) return;
cleanupQuotePreview();
quotePreviewLoading = true;
quotePreviewError = "";
try {
const result = await sales.previewQuote(
accessToken,
opportunityId,
quoteId,
);
const base64 = result.data?.contentBase64;
if (!base64) {
quotePreviewError = "No preview data returned";
return;
}
const cleaned = base64.replace(/^data:application\/pdf;base64,/, "");
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: "application/pdf" });
quotePreviewObjectUrl = URL.createObjectURL(blob);
quotePreviewQuoteId = quoteId;
} catch (err: unknown) {
quotePreviewError =
err instanceof Error ? err.message : "Failed to load preview";
} finally {
quotePreviewLoading = false;
}
}
/** Download quote PDF as a file */
async function handleDownloadQuote() {
if (
!accessToken ||
!opportunityId ||
!selectedQuoteDetail ||
!canDownloadQuote
)
return;
quoteDownloading = true;
try {
const result = await sales.downloadQuote(
accessToken,
opportunityId,
selectedQuoteDetail.id,
"download",
);
const { contentBase64, quoteFileName, mimeType } = result.data;
const cleaned = contentBase64.replace(
/^data:application\/pdf;base64,/,
"",
);
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: mimeType || "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = quoteFileName || "quote.pdf";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err: unknown) {
console.error("Download failed:", err);
} finally {
quoteDownloading = false;
}
}
/** Print quote PDF via hidden iframe */
async function handlePrintQuote() {
if (
!accessToken ||
!opportunityId ||
!selectedQuoteDetail ||
!canDownloadQuote
)
return;
quotePrinting = true;
try {
const result = await sales.downloadQuote(
accessToken,
opportunityId,
selectedQuoteDetail.id,
"print",
);
const { contentBase64 } = result.data;
const cleaned = contentBase64.replace(
/^data:application\/pdf;base64,/,
"",
);
const binary = atob(cleaned);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const printFrame = document.createElement("iframe");
printFrame.style.position = "fixed";
printFrame.style.left = "-9999px";
printFrame.style.top = "-9999px";
printFrame.style.width = "0";
printFrame.style.height = "0";
printFrame.src = url;
document.body.appendChild(printFrame);
printFrame.onload = () => {
try {
printFrame.contentWindow?.print();
} catch {
window.open(url, "_blank");
}
setTimeout(() => {
document.body.removeChild(printFrame);
URL.revokeObjectURL(url);
}, 1000);
};
} catch (err: unknown) {
console.error("Print failed:", err);
} finally {
quotePrinting = false;
}
}
function switchToPreview() {
viewMode = "preview";
}
function switchToList() {
viewMode = "list";
}
function switchToLogs() {
viewMode = "logs";
loadDownloadLogs();
}
async function loadDownloadLogs() {
if (!accessToken || !opportunityId || !canFetchDownloads) return;
logsLoading = true;
logsError = "";
try {
const result = await sales.fetchQuoteDownloads(
accessToken,
opportunityId,
);
downloadLogs = result.data ?? [];
logsLoaded = true;
} catch (err: unknown) {
logsError =
err instanceof Error ? err.message : "Failed to load download logs";
} finally {
logsLoading = false;
}
}
// ── Live preview helpers ──
function cleanupObjectUrl() {
if (livePreviewObjectUrl) {
URL.revokeObjectURL(livePreviewObjectUrl);
livePreviewObjectUrl = null;
}
}
function buildPayload() {
return {
lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives,
};
}
function decodeBase64PdfToObjectUrl(base64Value: string): string | null {
try {
const cleaned = base64Value.replace(/^data:application\/pdf;base64,/, "");
const binary = atob(cleaned);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: "application/pdf" });
return URL.createObjectURL(blob);
} catch {
return null;
}
}
function applyIncomingPayload(payload: unknown) {
const data = payload as Record<string, unknown> | null;
if (!data || typeof data !== "object") return;
const options =
(data.options as Record<string, unknown> | undefined) ??
(data as Record<string, unknown>);
if (options) {
if (typeof options.lineItemPricing === "boolean") {
quotePreviewOptions.lineItemPricing = options.lineItemPricing;
}
if (typeof options.includeQuoteNarrative === "boolean") {
quotePreviewOptions.includeQuoteNarrative =
options.includeQuoteNarrative;
}
if (typeof options.includeItemNarratives === "boolean") {
quotePreviewOptions.includeItemNarratives =
options.includeItemNarratives;
}
}
const nestedData = data.data as Record<string, unknown> | undefined;
const pdfUrlCandidate =
(typeof data.pdfUrl === "string" && data.pdfUrl) ||
(typeof data.url === "string" && data.url) ||
(typeof nestedData?.pdfUrl === "string" && nestedData.pdfUrl) ||
(typeof nestedData?.url === "string" && nestedData.url) ||
null;
if (pdfUrlCandidate) {
cleanupObjectUrl();
livePreviewPdfUrl = pdfUrlCandidate;
return;
}
const pdfBase64Candidate =
(typeof data.contentBase64 === "string" && data.contentBase64) ||
(typeof data.pdfBase64 === "string" && data.pdfBase64) ||
(typeof data.quoteFile === "string" && data.quoteFile) ||
(typeof nestedData?.contentBase64 === "string" &&
nestedData.contentBase64) ||
(typeof nestedData?.pdfBase64 === "string" && nestedData.pdfBase64) ||
(typeof nestedData?.quoteFile === "string" && nestedData.quoteFile) ||
null;
if (pdfBase64Candidate) {
const objectUrl = decodeBase64PdfToObjectUrl(pdfBase64Candidate);
if (objectUrl) {
cleanupObjectUrl();
livePreviewObjectUrl = objectUrl;
livePreviewPdfUrl = objectUrl;
}
}
}
function publishPreviewPayload() {
if (!socket || !liveEventName) return;
socket.emit(liveEventName, buildPayload());
}
function teardownSocket() {
if (socket) {
try {
socket.disconnect();
} catch {}
}
socket = null;
liveEventName = "";
previewEventName = "";
isConnected = false;
}
async function handleCommitQuote() {
if (!accessToken || !opportunityId || isCommitting) return;
isCommitting = true;
commitError = "";
commitSuccess = "";
try {
const result = await sales.commitQuote(accessToken, opportunityId, {
lineItemPricing: quotePreviewOptions.lineItemPricing,
includeQuoteNarrative: quotePreviewOptions.includeQuoteNarrative,
includeItemNarratives: quotePreviewOptions.includeItemNarratives,
});
commitSuccess = result.message || "Quote created successfully!";
// Reload quotes (basic + detail) and switch to list view
detailLoaded = false;
await loadQuotes();
await loadQuoteDetails();
if (result.data) {
selectedQuote =
quotes.find((q) => q.id === result.data.id) ?? quotes[0] ?? null;
}
viewMode = "list";
setTimeout(() => (commitSuccess = ""), 5000);
} catch (err: unknown) {
commitError =
err instanceof Error ? err.message : "Failed to create quote";
setTimeout(() => (commitError = ""), 5000);
} finally {
isCommitting = false;
}
}
onMount(() => {
// Lazy-load detailed quote data (regen data & params) now that the tab is active
loadQuoteDetails();
// Set up live preview socket (only if user has preview permission)
if (!accessToken || !opportunityId || !canPreviewQuote) return;
const base = PUBLIC_API_URL || "";
connectionError = "";
socket = io(`${base}/secure`, {
transports: ["websocket"],
auth: {
authorization: `Bearer ${accessToken}`,
},
rejectUnauthorized: false,
});
socket.on("connect", () => {
isConnected = true;
connectionError = "";
socket?.emit(
"opp:live_quote_preview",
{ id: opportunityId },
(ack: { ok: boolean; event?: string; error?: string }) => {
if (!ack?.ok || !ack?.event) {
connectionError =
ack?.error || "Failed to register live preview channel";
return;
}
liveEventName = ack.event;
previewEventName = `opp:live_quote_preview:${opportunityId}:preview`;
socket?.off(liveEventName);
socket?.on(liveEventName, (payload: unknown) => {
applyIncomingPayload(payload);
});
socket?.off(previewEventName);
socket?.on(previewEventName, (payload: unknown) => {
applyIncomingPayload(payload);
});
publishPreviewPayload();
},
);
});
socket.on("connect_error", (err: unknown) => {
isConnected = false;
connectionError =
err instanceof Error ? err.message : "Socket connection error";
});
socket.on("disconnect", () => {
isConnected = false;
});
socket.on(
"opp:live_quote_preview:error",
(payload: { message?: string }) => {
connectionError = payload?.message || "Live quote preview error";
},
);
// Load preview for initially selected quote
if (selectedQuote) {
loadQuotePreview(selectedQuote.id);
}
return () => {
teardownSocket();
cleanupObjectUrl();
cleanupQuotePreview();
};
});
onDestroy(() => {
teardownSocket();
cleanupObjectUrl();
cleanupQuotePreview();
});
$: if (isConnected && liveEventName) {
publishPreviewPayload();
}
</script>
<div class="quotes-tab">
<!-- ── View toggle bar ── -->
<div class="quotes-view-bar">
<div class="quotes-view-tabs">
{#if showQuotesSubTab}
<button
class="quotes-view-tab"
class:active={viewMode === "list"}
type="button"
on:click={switchToList}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Quotes
<span class="quotes-view-badge">{quotes.length}</span>
</button>
{/if}
<button
class="quotes-view-tab"
class:active={viewMode === "preview"}
type="button"
on:click={switchToPreview}
disabled={!canPreviewQuote}
title={canPreviewQuote
? ""
: "You do not have permission to preview quotes"}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
Live Preview
</button>
{#if canFetchDownloads}
<button
class="quotes-view-tab"
class:active={viewMode === "logs"}
type="button"
on:click={switchToLogs}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
Logs
</button>
{/if}
</div>
</div>
<!-- ═══ LIST VIEW ═══ -->
{#if viewMode === "list"}
<div class="quotes-list-layout">
<!-- Sidebar: quote list -->
<div class="quotes-list-sidebar">
<div class="quotes-list-header">
<span class="quotes-field-label">Committed Quotes</span>
{#if canCommitQuote}
<button
class="quotes-new-btn"
type="button"
on:click={switchToPreview}
title="Create new quote"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
{/if}
</div>
{#if quotesLoading}
<div class="quotes-list-empty">Loading quotes...</div>
{:else if quotesError}
<div class="quotes-list-empty quotes-list-error">{quotesError}</div>
{:else if quotes.length === 0}
<div class="quotes-list-empty">
No quotes yet.
{#if canCommitQuote}
<button
class="quotes-list-empty-link"
type="button"
on:click={switchToPreview}
>
Create one
</button>
{/if}
</div>
{:else}
<div class="quotes-list-items">
{#each quotes as quote (quote.id)}
<button
class="quotes-list-item"
class:active={selectedQuote?.id === quote.id}
type="button"
on:click={() => selectQuote(quote)}
>
<div class="quotes-list-item-name">
{shortFileName(quote.quoteFileName)}
</div>
<div class="quotes-list-item-date">
{formatDate(quote.createdAt)}
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Main area: quote detail + PDF preview -->
<div class="quotes-detail-area">
{#if selectedQuoteDetail}
<div class="quotes-detail-content">
<!-- Left: info + line items -->
<div class="quotes-detail-info">
<!-- Compact header -->
<div class="quotes-detail-header">
<div class="quotes-detail-header-top">
<h3 class="quotes-detail-title">
{shortFileName(selectedQuoteDetail.quoteFileName)}
</h3>
<div class="quotes-detail-header-right">
<span class="quotes-detail-date">
{formatDate(selectedQuoteDetail.createdAt)}
</span>
<div class="quotes-detail-actions">
<button
class="quotes-action-btn"
title="Print quote"
disabled={quotePrinting || !canDownloadQuote}
on:click={handlePrintQuote}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="6 9 6 2 18 2 18 9" />
<path
d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"
/>
<rect x="6" y="14" width="12" height="8" />
</svg>
</button>
<button
class="quotes-action-btn"
title="Download quote"
disabled={quoteDownloading || !canDownloadQuote}
on:click={handleDownloadQuote}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
</div>
</div>
{#if detailLoading}
<div class="quotes-detail-meta">
<span class="quotes-detail-meta-loading"
>Loading details...</span
>
</div>
{:else if selectedQuoteDetail.quoteRegenData?.options}
<div class="quotes-detail-meta">
<span
class="quotes-detail-badge"
class:active={selectedQuoteDetail.quoteRegenData.options
.lineItemPricing}
>
Line pricing
</span>
<span
class="quotes-detail-badge"
class:active={selectedQuoteDetail.quoteRegenData.options
.includeQuoteNarrative}
>
Quote narrative
</span>
<span
class="quotes-detail-badge"
class:active={selectedQuoteDetail.quoteRegenData.options
.includeItemNarratives}
>
Item narratives
</span>
</div>
{/if}
</div>
<!-- Summary numbers -->
{#if !detailLoading && allProducts.length > 0}
<div class="quotes-summary-bar">
<div class="quotes-summary-stat">
<span class="quotes-summary-label">Items</span>
<span class="quotes-summary-value">{totalItems}</span>
</div>
<div class="quotes-summary-stat">
<span class="quotes-summary-label">Revenue</span>
<span class="quotes-summary-value"
>{formatCurrency(totalRevenue)}</span
>
</div>
<div class="quotes-summary-stat">
<span class="quotes-summary-label">Cost</span>
<span class="quotes-summary-value"
>{formatCurrency(totalCost)}</span
>
</div>
<div class="quotes-summary-stat">
<span class="quotes-summary-label">Margin</span>
<span
class="quotes-summary-value"
class:positive={totalMargin >= 0}
class:negative={totalMargin < 0}
>
{formatCurrency(totalMargin)}
</span>
</div>
</div>
{/if}
<!-- Line items table -->
<div class="quotes-line-items">
<div class="quotes-line-items-header">
<span class="quotes-li-col quotes-li-num">#</span>
<span class="quotes-li-col quotes-li-desc">Description</span>
<span class="quotes-li-col quotes-li-qty">Qty</span>
<span class="quotes-li-col quotes-li-price">Revenue</span>
</div>
<div class="quotes-line-items-body">
{#if detailLoading}
<div class="quotes-line-items-empty">
Loading line items...
</div>
{:else if activeProducts.length > 0}
{#each activeProducts as product, i}
<div
class="quotes-line-item"
class:partial-cancelled={product.cancellationType ===
"partial"}
>
<span class="quotes-li-col quotes-li-num">{i + 1}</span>
<span
class="quotes-li-col quotes-li-desc"
title={product.productDescription ?? ""}
>
{product.productDescription ?? "—"}
{#if product.productClass}
<span class="quotes-li-class"
>{product.productClass}</span
>
{/if}
{#if product.cancellationType === "partial"}
<span class="quotes-li-partial-tag">
{product.quantityCancelled ?? 0} cancelled
</span>
{/if}
</span>
<span class="quotes-li-col quotes-li-qty"
>{product.effectiveQuantity ??
product.quantity ??
0}</span
>
<span class="quotes-li-col quotes-li-price"
>{formatCurrency(product.revenue)}</span
>
</div>
{/each}
{:else if allProducts.length === 0}
<div class="quotes-line-items-empty">No line items</div>
{:else}
<div class="quotes-line-items-empty">
All items cancelled
</div>
{/if}
<!-- Cancelled items accordion -->
{#if cancelledProducts.length > 0}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="quotes-cancelled-accordion"
on:click={() => (cancelledExpanded = !cancelledExpanded)}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ")
cancelledExpanded = !cancelledExpanded;
}}
role="button"
tabindex="0"
>
<svg
class="quotes-accordion-chevron"
class:expanded={cancelledExpanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="12"
height="12"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span>Cancelled ({cancelledProducts.length})</span>
</div>
{#if cancelledExpanded}
{#each cancelledProducts as product, i}
<div class="quotes-line-item cancelled">
<span class="quotes-li-col quotes-li-num"
>{activeProducts.length + i + 1}</span
>
<span
class="quotes-li-col quotes-li-desc"
title={product.productDescription ?? ""}
>
{product.productDescription ?? "—"}
{#if product.productClass}
<span class="quotes-li-class"
>{product.productClass}</span
>
{/if}
</span>
<span class="quotes-li-col quotes-li-qty"
>{product.quantity ?? 0}</span
>
<span class="quotes-li-col quotes-li-price"
>{formatCurrency(product.revenue)}</span
>
</div>
{/each}
{/if}
{/if}
</div>
</div>
<!-- Subtle quote ID at bottom right -->
<div class="quotes-detail-footer">
<span class="quotes-detail-quote-id"
>{selectedQuoteDetail.id}</span
>
</div>
</div>
<!-- Right: PDF preview -->
<div class="quotes-detail-preview">
{#if quotePreviewLoading}
<div class="quotes-detail-preview-frame quotes-preview-loading">
<div class="quotes-pdf-page quotes-pdf-page-mini">
<p style="color: var(--text-muted); font-style: italic;">
Loading preview...
</p>
</div>
</div>
{:else if quotePreviewError}
<div
class="quotes-detail-preview-frame quotes-preview-error-frame"
>
<div class="quotes-pdf-page quotes-pdf-page-mini">
<p style="color: var(--text-muted);">{quotePreviewError}</p>
</div>
</div>
{:else if quotePreviewObjectUrl}
<div class="quotes-detail-preview-frame">
<PdfViewer src={quotePreviewObjectUrl} />
</div>
{:else}
<div class="quotes-detail-preview-frame">
<div class="quotes-pdf-page quotes-pdf-page-mini">
<p class="quotes-pdf-page-subtitle">No preview available</p>
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="quotes-detail-empty">
<p>Select a quote from the list to view details.</p>
</div>
{/if}
</div>
</div>
<!-- ═══ LIVE PREVIEW VIEW ═══ -->
{:else if viewMode === "preview"}
<div class="quotes-preview-layout">
<div class="quotes-preview-sidebar">
<div class="quotes-sidebar-header">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>Live Preview</span>
</div>
<div class="quotes-sidebar-divider"></div>
<div
class="quotes-preview-options"
aria-label="Quote generation options"
>
<span class="quotes-field-label">Generation options</span>
<label class="quotes-flag-row">
<input
type="checkbox"
checked={quotePreviewOptions.lineItemPricing}
on:change={() => {
quotePreviewOptions.lineItemPricing =
!quotePreviewOptions.lineItemPricing;
publishPreviewPayload();
}}
/>
<span>Show line item pricing</span>
</label>
<label class="quotes-flag-row">
<input
type="checkbox"
checked={quotePreviewOptions.includeQuoteNarrative}
on:change={() => {
quotePreviewOptions.includeQuoteNarrative =
!quotePreviewOptions.includeQuoteNarrative;
publishPreviewPayload();
}}
/>
<span>Include quote narrative</span>
</label>
<label class="quotes-flag-row">
<input
type="checkbox"
checked={quotePreviewOptions.includeItemNarratives}
on:change={() => {
quotePreviewOptions.includeItemNarratives =
!quotePreviewOptions.includeItemNarratives;
publishPreviewPayload();
}}
/>
<span>Include item narratives</span>
</label>
</div>
<button
class="quotes-create-btn"
type="button"
disabled={isCommitting ||
!accessToken ||
!opportunityId ||
!canCommitQuote}
on:click={handleCommitQuote}
>
{#if isCommitting}
Creating...
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Create Quote
{/if}
</button>
{#if commitError}
<div class="quotes-commit-msg quotes-commit-error">{commitError}</div>
{/if}
{#if commitSuccess}
<div class="quotes-commit-msg quotes-commit-success">
{commitSuccess}
</div>
{/if}
{#if quotes.length > 0}
<div class="quotes-sidebar-divider"></div>
<button
class="quotes-back-to-list"
type="button"
on:click={switchToList}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back to quotes ({quotes.length})
</button>
{/if}
</div>
<div class="quotes-preview-canvas" aria-label="Quote PDF preview area">
{#if effectivePdfUrl}
<PdfViewer src={effectivePdfUrl} />
{:else}
<div class="quotes-pdf-frame quotes-pdf-placeholder">
<div class="quotes-pdf-page">
<h4>Quote PDF Preview</h4>
<p>
{#if connectionError}
Live preview error: {connectionError}
{:else if !accessToken || !opportunityId}
Missing authentication or opportunity id for live preview.
{:else if !isConnected}
Connecting to live preview channel...
{:else}
Connected. Waiting for live quote preview PDF payload.
{/if}
</p>
</div>
</div>
{/if}
</div>
</div>
{:else if viewMode === "logs"}
<!-- ═══ LOGS VIEW ═══ -->
<div class="quotes-logs-view">
<div class="quotes-logs-header">
<span class="quotes-field-label">Download & Print History</span>
<button
class="quotes-logs-refresh"
type="button"
title="Refresh logs"
disabled={logsLoading}
on:click={loadDownloadLogs}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"
/>
</svg>
</button>
</div>
{#if logsLoading}
<div class="quotes-logs-empty">
<p>Loading logs...</p>
</div>
{:else if logsError}
<div class="quotes-logs-empty">
<p style="color: var(--error, #ef4444);">{logsError}</p>
</div>
{:else if downloadLogs.length === 0}
<div class="quotes-logs-empty">
<p>No download or print activity yet.</p>
</div>
{:else}
<div class="quotes-logs-list">
{#each downloadLogs as log}
<div class="quotes-log-group">
<div class="quotes-log-group-header">
<span class="quotes-log-filename"
>{log.quoteFileName.replace(/\.pdf$/i, "")}</span
>
{#if log.downloads.length > 0}
<span class="quotes-log-count"
>{log.downloads.length}
{log.downloads.length === 1 ? "entry" : "entries"}</span
>
{:else}
<span class="quotes-log-count"
>Created {formatDate(log.createdAt)}</span
>
{/if}
</div>
{#if log.downloads.length > 0}
<div class="quotes-log-entries">
{#each log.downloads as dl}
<div class="quotes-log-entry">
<span
class="quotes-log-action"
class:download={dl.fetchAction === "download"}
class:print={dl.fetchAction === "print"}
>
{#if dl.fetchAction === "download"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"
/>
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="11"
height="11"
>
<polyline points="6 9 6 2 18 2 18 9" />
<path
d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"
/>
<rect x="6" y="14" width="12" height="8" />
</svg>
{/if}
{dl.fetchAction}
</span>
<span class="quotes-log-user">{dl.userName}</span>
<span class="quotes-log-date"
>{formatDate(dl.downloadedAt)}</span
>
</div>
{/each}
</div>
{:else}
<div class="quotes-log-entries">
<div class="quotes-log-entry">
<span
class="quotes-log-action"
style="opacity: 0.5; width: auto; white-space: nowrap;"
>No downloads yet</span
>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>