feat: opportunity detail overhaul, catalog improvements, cleanup

- api: remove stray console.log debug lines across fetch routes, controllers, and workflow dispatch
- api: refactor OpportunityController and cwProbabilityCache
- api: add workflow history endpoint updates
- ui: overhaul opportunity detail page styles and layout
- ui: remove DiscountsTab (consolidated elsewhere), update ProductsTab
- ui: improve catalog page with inventory popover
- ui: update sales API module and server-side page loader
- ui: add sleepy-cat asset, simplify NoResultsMonkey component
This commit is contained in:
2026-04-22 00:53:23 +00:00
parent 5194d0e21e
commit 6eee7bf0da
19 changed files with 406 additions and 507 deletions
-2
View File
@@ -20,8 +20,6 @@ export default createRoute(
const includeAllContacts = c.req.query("includeAllContacts") === "true"; const includeAllContacts = c.req.query("includeAllContacts") === "true";
const includeAllAddresses = c.req.query("includeAllAddresses") === "true"; const includeAllAddresses = c.req.query("includeAllAddresses") === "true";
console.log(company.toJson({ includeAddress, includePrimaryContact, includeAllContacts }));
// Check for address-specific permission if includeAddress is requested // Check for address-specific permission if includeAddress is requested
if (includeAddress) { if (includeAddress) {
const user = c.get("user"); const user = c.get("user");
-2
View File
@@ -35,8 +35,6 @@ export default createRoute(
const data = schema.parse(body); const data = schema.parse(body);
console.log("Creating Credential Type with data:", data);
const credentialType = await credentialTypes.create(data as any); const credentialType = await credentialTypes.create(data as any);
const response = apiResponse.created( const response = apiResponse.created(
+4 -1
View File
@@ -36,7 +36,10 @@ export default createRoute(
if (includes.has("products")) { if (includes.has("products")) {
subResourcePromises.products = item subResourcePromises.products = item
.fetchProducts() .fetchProducts()
.then((products) => products.map((p) => p.toJson())); .then((products) => {
const json = products.map((p) => p.toJson());
return json;
});
} }
if (includes.has("quotes")) { if (includes.has("quotes")) {
subResourcePromises.quotes = generatedQuotes subResourcePromises.quotes = generatedQuotes
@@ -39,7 +39,11 @@ export default createRoute(
if (includes.has("products")) { if (includes.has("products")) {
subResourcePromises.products = item subResourcePromises.products = item
.fetchProducts() .fetchProducts()
.then((products) => products.map((p) => p.toJson())); .then((products) => {
const json = products.map((p) => p.toJson());
console.log(`[PRODUCTS_DEBUG] cwOpportunityId=${item.cwOpportunityId} count=${json.length}`, JSON.stringify(json, null, 2));
return json;
});
} }
if (includes.has("quotes")) { if (includes.has("quotes")) {
const includeRegenData = c.req.query("includeRegenData") === "true"; const includeRegenData = c.req.query("includeRegenData") === "true";
@@ -116,15 +116,7 @@ export default createRoute(
try { try {
const identifier = c.req.param("identifier"); const identifier = c.req.param("identifier");
const body = await c.req.json(); const body = await c.req.json();
console.log(
"[Workflow Dispatch] Raw request body:",
JSON.stringify(body, null, 2),
);
const parsed = dispatchSchema.parse(body); const parsed = dispatchSchema.parse(body);
console.log(
"[Workflow Dispatch] Parsed payload:",
JSON.stringify(parsed.payload, null, 2),
);
const user = c.get("user"); const user = c.get("user");
// ── Resolve opportunity ──────────────────────────────────────────── // ── Resolve opportunity ────────────────────────────────────────────
+2 -4
View File
@@ -222,8 +222,6 @@ export class OpportunityController {
activities?: ActivityController[]; activities?: ActivityController[];
} }
) { ) {
console.log(data.primarySalesRep);
// New schema: uid is the internal PK (string), id is the CW opportunity ID (Int) // New schema: uid is the internal PK (string), id is the CW opportunity ID (Int)
this.id = data.uid; this.id = data.uid;
this.cwOpportunityId = data.id; this.cwOpportunityId = data.id;
@@ -827,6 +825,8 @@ export class OpportunityController {
}, },
}); });
console.log("[ROWS_DEBUG]", rows)
let ordered = rows; let ordered = rows;
if (this.productSequence.length > 0) { if (this.productSequence.length > 0) {
const byId = new Map(rows.map((row) => [row.id, row])); const byId = new Map(rows.map((row) => [row.id, row]));
@@ -1068,8 +1068,6 @@ export class OpportunityController {
quoNarrativeProduct?.customerDescription ?? quoNarrativeProduct?.customerDescription ??
undefined; undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company"; const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name // Only show attention if it differs from the customer name
+3 -2
View File
@@ -6,7 +6,8 @@
export let onHand: number | undefined; export let onHand: number | undefined;
// Warehouse IDs for the fixed locations // Warehouse IDs for the fixed locations
const MURRAY_ID = 1; // Murray = Andrus 301 (1) + Andrus-205C (26) + Andrus 205D (27)
const MURRAY_IDS = new Set([1, 26, 27]);
const UNION_CITY_ID = 6; const UNION_CITY_ID = 6;
const LONDON_ID = 30; const LONDON_ID = 30;
@@ -56,7 +57,7 @@
for (const row of rows) { for (const row of rows) {
const wId = row.warehouseId; const wId = row.warehouseId;
const wName = row.warehouse?.name ?? ""; const wName = row.warehouse?.name ?? "";
if (wId === MURRAY_ID) { if (wId != null && MURRAY_IDS.has(wId)) {
result.murray += row.qtyOnHand; result.murray += row.qtyOnHand;
} else if (wId === UNION_CITY_ID) { } else if (wId === UNION_CITY_ID) {
result.unionCity += row.qtyOnHand; result.unionCity += row.qtyOnHand;
+3 -52
View File
@@ -3,64 +3,15 @@
export let size: number = 160; export let size: number = 160;
</script> </script>
<div class="monkey" style="width: {size}px"> <div class="empty-state">
<svg viewBox="0 0 120 120" width="100%" height="100%" aria-hidden="true"> <p class="msg">{message}</p>
<!-- head -->
<circle cx="60" cy="60" r="44" fill="#8B5E3C" />
<!-- face -->
<ellipse cx="60" cy="70" rx="30" ry="24" fill="#E8C9A1" />
<!-- ears -->
<circle cx="26" cy="56" r="12" fill="#8B5E3C" />
<circle cx="94" cy="56" r="12" fill="#8B5E3C" />
<circle cx="26" cy="56" r="6" fill="#E8C9A1" />
<circle cx="94" cy="56" r="6" fill="#E8C9A1" />
<!-- eyes -->
<circle cx="48" cy="64" r="6" fill="#2b2b2b" />
<circle cx="72" cy="64" r="6" fill="#2b2b2b" />
<!-- smile -->
<path
d="M48 78 Q60 88 72 78"
stroke="#2b2b2b"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
<!-- eyebrow accents -->
<path
d="M42 58 Q48 54 54 58"
stroke="#6b4a33"
stroke-width="2"
fill="none"
stroke-linecap="round"
opacity="0.6"
/>
<path
d="M66 58 Q72 54 78 58"
stroke="#6b4a33"
stroke-width="2"
fill="none"
stroke-linecap="round"
opacity="0.6"
/>
<!-- tiny tuft -->
<path
d="M60 28 Q58 34 62 36"
stroke="#6b4a33"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
<div class="msg">{message}</div>
</div> </div>
<style> <style>
.monkey { .empty-state {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem;
margin: 1.5rem auto; margin: 1.5rem auto;
} }
.msg { .msg {
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/svelte";
import NoResultsMonkey from "./NoResultsMonkey.svelte";
describe("NoResultsMonkey", () => {
it("renders with default message", () => {
render(NoResultsMonkey);
expect(screen.getByText("No results found")).toBeInTheDocument();
});
it("renders with custom message", () => {
render(NoResultsMonkey, { props: { message: "Nothing here" } });
expect(screen.getByText("Nothing here")).toBeInTheDocument();
});
it("renders SVG illustration", () => {
const { container } = render(NoResultsMonkey);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
it("applies custom size", () => {
const { container } = render(NoResultsMonkey, {
props: { size: 200 },
});
const wrapper = container.querySelector(".monkey");
expect(wrapper).toHaveStyle("width: 200px");
});
});
+6
View File
@@ -654,6 +654,9 @@ export const sales = {
}, },
async fetchProducts(accessToken: string, identifier: string) { async fetchProducts(accessToken: string, identifier: string) {
console.log("fetch prod exec check")
const response = await api.get( const response = await api.get(
`/v1/sales/opportunities/opportunity/${encodeURIComponent( `/v1/sales/opportunities/opportunity/${encodeURIComponent(
identifier identifier
@@ -664,6 +667,9 @@ export const sales = {
}, },
} }
); );
console.log("[fetchProducts] API response:", JSON.stringify(response.data, null, 2));
return response.data; return response.data;
}, },
+44 -5
View File
@@ -6,7 +6,6 @@
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
import AccessDenied from "../../../components/AccessDenied.svelte"; import AccessDenied from "../../../components/AccessDenied.svelte";
import Pagination from "../../../components/Pagination.svelte"; import Pagination from "../../../components/Pagination.svelte";
import InventoryPopover from "../../../components/InventoryPopover.svelte";
import { formatDate } from "$lib/utils"; import { formatDate } from "$lib/utils";
import "../../../styles/procurement/catalog.css"; import "../../../styles/procurement/catalog.css";
import { clientFetch } from "$lib/client-fetch"; import { clientFetch } from "$lib/client-fetch";
@@ -599,7 +598,16 @@
<td class="col-price">{formatCurrency(item.price)}</td> <td class="col-price">{formatCurrency(item.price)}</td>
<td class="col-cost">{formatCurrency(item.cost)}</td> <td class="col-cost">{formatCurrency(item.cost)}</td>
<td class="col-onhand"> <td class="col-onhand">
<InventoryPopover identifier={item.id} onHand={item.onHand} /> <span
class="onhand-badge"
class:onhand-zero={item.onHand === 0}
class:onhand-low={item.onHand != null &&
item.onHand > 0 &&
item.onHand <= 3}
class:onhand-ok={item.onHand != null && item.onHand > 3}
>
{item.onHand ?? "—"}
</span>
</td> </td>
<td class="col-status"> <td class="col-status">
<span <span
@@ -713,7 +721,17 @@
<div class="detail-field"> <div class="detail-field">
<span class="detail-label">On Hand</span> <span class="detail-label">On Hand</span>
<span class="detail-value"> <span class="detail-value">
<InventoryPopover identifier={selectedItem.id} onHand={selectedItem.onHand} /> <span
class="onhand-badge"
class:onhand-zero={selectedItem.onHand === 0}
class:onhand-low={selectedItem.onHand != null &&
selectedItem.onHand > 0 &&
selectedItem.onHand <= 3}
class:onhand-ok={selectedItem.onHand != null &&
selectedItem.onHand > 3}
>
{selectedItem.onHand ?? "—"}
</span>
</span> </span>
</div> </div>
</div> </div>
@@ -948,7 +966,18 @@
<div class="linked-detail-field"> <div class="linked-detail-field">
<span class="detail-label">On Hand</span> <span class="detail-label">On Hand</span>
<span class="detail-value"> <span class="detail-value">
<InventoryPopover identifier={li.id} onHand={li.onHand} /> <span
class="onhand-badge"
class:onhand-zero={li.onHand === 0}
class:onhand-low={li.onHand != null &&
li.onHand > 0 &&
li.onHand <= 3}
class:onhand-ok={li.onHand != null &&
li.onHand > 3}
>
{li.onHand ?? "—"}
</span>
</span>
</div> </div>
<div class="linked-detail-field"> <div class="linked-detail-field">
<span class="detail-label">Status</span> <span class="detail-label">Status</span>
@@ -1303,7 +1332,17 @@
<div class="link-preview-field"> <div class="link-preview-field">
<span class="detail-label">On Hand</span> <span class="detail-label">On Hand</span>
<span class="detail-value"> <span class="detail-value">
<InventoryPopover identifier={linkPreviewItem.id} onHand={linkPreviewItem.onHand} /> <span
class="onhand-badge"
class:onhand-zero={linkPreviewItem.onHand === 0}
class:onhand-low={linkPreviewItem.onHand != null &&
linkPreviewItem.onHand > 0 &&
linkPreviewItem.onHand <= 3}
class:onhand-ok={linkPreviewItem.onHand != null &&
linkPreviewItem.onHand > 3}
>
{linkPreviewItem.onHand ?? "—"}
</span>
</span> </span>
</div> </div>
{#if linkPreviewItem.manufacturer} {#if linkPreviewItem.manufacturer}
@@ -68,6 +68,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
JSON.stringify(result, null, 2), JSON.stringify(result, null, 2),
); );
console.log("[page.server] Full API response:", JSON.stringify(result, null, 2));
const opportunity = result?.data ?? null; const opportunity = result?.data ?? null;
const notes = result?.data?.notes ?? []; const notes = result?.data?.notes ?? [];
const contacts = result?.data?.contacts ?? []; const contacts = result?.data?.contacts ?? [];
@@ -30,6 +30,7 @@
$: notes = data.notes; $: notes = data.notes;
$: contacts = data.contacts; $: contacts = data.contacts;
$: products = data.products; $: products = data.products;
$: console.log("[OpportunityPage] full API response data:", data);
$: quotes = data.quotes ?? []; $: quotes = data.quotes ?? [];
$: permissions = data.permissions; $: permissions = data.permissions;
@@ -1,247 +0,0 @@
<script lang="ts">
import type { OpportunityProduct } from "../types";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
export let products: OpportunityProduct[] = [];
$: discountedProducts = products.filter(
(p) => (p.discount ?? 0) !== 0 && !p.cancelled
);
$: totalDiscount = discountedProducts.reduce(
(sum, p) => sum + (p.discount ?? 0) * (p.quantity ?? 1),
0
);
$: totalListPrice = discountedProducts.reduce(
(sum, p) => sum + (p.listPrice ?? 0) * (p.quantity ?? 1),
0
);
$: totalUnitPrice = discountedProducts.reduce(
(sum, p) => sum + (p.unitPrice ?? 0) * (p.quantity ?? 1),
0
);
function fmtMoney(value: number): string {
return value.toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
}
function fmtPercent(list: number, discount: number): string {
if (list === 0) return "—";
const pct = (discount / list) * 100;
return `${pct.toFixed(1)}%`;
}
</script>
<div class="discounts-tab">
<!-- Summary cards -->
{#if discountedProducts.length > 0}
<div class="discounts-summary">
<div class="discount-summary-card">
<span class="discount-summary-label">Total List Price</span>
<span class="discount-summary-value">{fmtMoney(totalListPrice)}</span>
</div>
<div class="discount-summary-card">
<span class="discount-summary-label">Total Discounts</span>
<span class="discount-summary-value discount-negative"
>{fmtMoney(totalDiscount)}</span
>
</div>
<div class="discount-summary-card">
<span class="discount-summary-label">Net Price</span>
<span class="discount-summary-value">{fmtMoney(totalUnitPrice)}</span>
</div>
</div>
{/if}
<!-- Discounts table -->
{#if discountedProducts.length === 0}
<div class="tab-empty">
<NoResultsMonkey message="No discounts applied" />
</div>
{:else}
<div class="discounts-table-wrap">
<table class="discounts-table">
<thead>
<tr>
<th class="col-product">Product</th>
<th class="col-num">Qty</th>
<th class="col-num">List Price</th>
<th class="col-num">Discount</th>
<th class="col-num">% Off</th>
<th class="col-num">Unit Price</th>
<th class="col-num">Ext. Discount</th>
</tr>
</thead>
<tbody>
{#each discountedProducts as product (product.id)}
<tr>
<td class="col-product">
<div class="discount-product-name">
{product.forecastDescription || product.productDescription || "—"}
</div>
{#if product.catalogItem?.identifier}
<div class="discount-product-sku">
{product.catalogItem.identifier}
</div>
{/if}
</td>
<td class="col-num">{product.quantity ?? 0}</td>
<td class="col-num">{fmtMoney(product.listPrice ?? 0)}</td>
<td class="col-num discount-negative">
{fmtMoney(product.discount ?? 0)}
</td>
<td class="col-num">
{fmtPercent(product.listPrice ?? 0, product.discount ?? 0)}
</td>
<td class="col-num">{fmtMoney(product.unitPrice ?? 0)}</td>
<td class="col-num discount-negative">
{fmtMoney((product.discount ?? 0) * (product.quantity ?? 1))}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td class="col-product"><strong>Total</strong></td>
<td class="col-num"></td>
<td class="col-num"><strong>{fmtMoney(totalListPrice)}</strong></td>
<td class="col-num discount-negative">
<strong>{fmtMoney(totalDiscount)}</strong>
</td>
<td class="col-num">
{fmtPercent(totalListPrice, totalDiscount)}
</td>
<td class="col-num"><strong>{fmtMoney(totalUnitPrice)}</strong></td>
<td class="col-num discount-negative">
<strong>{fmtMoney(totalDiscount)}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
{/if}
</div>
<style>
.discounts-tab {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
}
.discounts-summary {
display: flex;
gap: 12px;
padding: 0 16px;
}
.discount-summary-card {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border-radius: 8px;
background: var(--bg-raised, #f7f8fa);
border: 1px solid var(--border-subtle, #e2e5ea);
}
.discount-summary-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.discount-summary-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a2e);
font-variant-numeric: tabular-nums;
}
.discount-negative {
color: var(--color-danger, #dc2626);
}
.discounts-table-wrap {
overflow-x: auto;
padding: 0 16px;
}
.discounts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.discounts-table th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #6b7280);
border-bottom: 2px solid var(--border-subtle, #e2e5ea);
white-space: nowrap;
}
.discounts-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle, #e2e5ea);
vertical-align: middle;
}
.discounts-table tbody tr:last-child td {
border-bottom: none;
}
.discounts-table tbody tr:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.02));
}
.discounts-table tfoot td {
padding: 10px 12px;
border-top: 2px solid var(--border-subtle, #e2e5ea);
font-weight: 600;
}
.col-product {
min-width: 200px;
}
.col-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.discounts-table th.col-num {
text-align: right;
}
.discount-product-name {
font-weight: 500;
color: var(--text-primary, #1a1a2e);
}
.discount-product-sku {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
margin-top: 2px;
}
@media (max-width: 768px) {
.discounts-summary {
flex-direction: column;
}
}
</style>
@@ -8,7 +8,6 @@
import { optima } from "$lib"; import { optima } from "$lib";
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte"; import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
import AddProductModal from "../../../../../components/AddProductModal.svelte"; import AddProductModal from "../../../../../components/AddProductModal.svelte";
import InventoryPopover from "../../../../../components/InventoryPopover.svelte";
import type { CatalogItem } from "$lib/optima-api/modules/procurement"; import type { CatalogItem } from "$lib/optima-api/modules/procurement";
import type { import type {
AddProductBody, AddProductBody,
@@ -2557,12 +2556,7 @@
<div class="detail-field"> <div class="detail-field">
<span class="detail-field-label">On Hand</span> <span class="detail-field-label">On Hand</span>
<span class="detail-field-value"> <span class="detail-field-value">
{#if selectedProduct.catalogItem} {#if selectedProduct.onHand != null}
<InventoryPopover
identifier={String(selectedProduct.catalogItem.id)}
onHand={selectedProduct.onHand ?? undefined}
/>
{:else if selectedProduct.onHand != null}
<span <span
class="stock-badge" class="stock-badge"
class:stock-none={selectedProduct.onHand === 0} class:stock-none={selectedProduct.onHand === 0}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB