1310 lines
44 KiB
Svelte
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>
|