6eee7bf0da
- 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
275 lines
6.9 KiB
Svelte
275 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { clientFetch } from "$lib/client-fetch";
|
|
import { onDestroy } from "svelte";
|
|
|
|
export let identifier: string;
|
|
export let onHand: number | undefined;
|
|
|
|
// Warehouse IDs for the fixed locations
|
|
// Murray = Andrus 301 (1) + Andrus-205C (26) + Andrus 205D (27)
|
|
const MURRAY_IDS = new Set([1, 26, 27]);
|
|
const UNION_CITY_ID = 6;
|
|
const LONDON_ID = 30;
|
|
|
|
type InventoryRow = {
|
|
id: number;
|
|
qtyOnHand: number;
|
|
warehouseId: number | null;
|
|
warehouseBinId: number;
|
|
warehouse: { id: number; name: string } | null;
|
|
};
|
|
|
|
type Breakdown = {
|
|
murray: number;
|
|
unionCity: number;
|
|
london: number;
|
|
vehicles: number;
|
|
};
|
|
|
|
let visible = false;
|
|
let loading = false;
|
|
let breakdown: Breakdown | null = null;
|
|
let fetchedFor: string | null = null;
|
|
|
|
let anchorEl: HTMLElement;
|
|
let popoverX = 0;
|
|
let popoverY = 0;
|
|
|
|
// Portal action — moves the node to document.body so it is never
|
|
// clipped by a parent overflow or trapped inside a CSS transform context.
|
|
function portal(node: HTMLElement) {
|
|
document.body.appendChild(node);
|
|
return {
|
|
destroy() {
|
|
node.parentNode?.removeChild(node);
|
|
},
|
|
};
|
|
}
|
|
|
|
function onhandClass(qty: number) {
|
|
if (qty === 0) return "onhand-zero";
|
|
if (qty <= 3) return "onhand-low";
|
|
return "onhand-ok";
|
|
}
|
|
|
|
function computeBreakdown(rows: InventoryRow[]): Breakdown {
|
|
const result: Breakdown = { murray: 0, unionCity: 0, london: 0, vehicles: 0 };
|
|
for (const row of rows) {
|
|
const wId = row.warehouseId;
|
|
const wName = row.warehouse?.name ?? "";
|
|
if (wId != null && MURRAY_IDS.has(wId)) {
|
|
result.murray += row.qtyOnHand;
|
|
} else if (wId === UNION_CITY_ID) {
|
|
result.unionCity += row.qtyOnHand;
|
|
} else if (wId === LONDON_ID) {
|
|
result.london += row.qtyOnHand;
|
|
} else if (/^\d+$/.test(wName)) {
|
|
result.vehicles += row.qtyOnHand;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function load() {
|
|
if (fetchedFor === identifier) return;
|
|
loading = true;
|
|
breakdown = null;
|
|
try {
|
|
const result = await clientFetch<{ data: InventoryRow[] }>(
|
|
`/procurement/catalog/inventory?id=${encodeURIComponent(identifier)}`,
|
|
);
|
|
breakdown = computeBreakdown(result?.data ?? []);
|
|
fetchedFor = identifier;
|
|
} catch {
|
|
breakdown = { murray: 0, unionCity: 0, london: 0, vehicles: 0 };
|
|
fetchedFor = identifier;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function updatePosition() {
|
|
if (!anchorEl) return;
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
popoverX = rect.left + rect.width / 2;
|
|
popoverY = rect.top - 6;
|
|
}
|
|
|
|
function handleMouseEnter() {
|
|
updatePosition();
|
|
visible = true;
|
|
load();
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
visible = false;
|
|
}
|
|
|
|
onDestroy(() => {
|
|
visible = false;
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="inv-popover-anchor"
|
|
role="button"
|
|
tabindex="0"
|
|
bind:this={anchorEl}
|
|
on:mouseenter={handleMouseEnter}
|
|
on:mouseleave={handleMouseLeave}
|
|
on:focusin={handleMouseEnter}
|
|
on:focusout={handleMouseLeave}
|
|
>
|
|
<span
|
|
class="onhand-badge"
|
|
class:onhand-zero={onHand === 0}
|
|
class:onhand-low={onHand != null && onHand > 0 && onHand <= 3}
|
|
class:onhand-ok={onHand != null && onHand > 3}
|
|
>
|
|
{onHand ?? "—"}
|
|
</span>
|
|
</div>
|
|
|
|
{#if visible}
|
|
<div
|
|
use:portal
|
|
class="inv-popover"
|
|
role="tooltip"
|
|
style="left:{popoverX}px; top:{popoverY}px;"
|
|
>
|
|
<div class="inv-popover-title">Inventory Breakdown</div>
|
|
{#if loading}
|
|
<div class="inv-popover-loading">
|
|
<span class="inv-spinner" />
|
|
Loading…
|
|
</div>
|
|
{:else if breakdown}
|
|
<div class="inv-popover-rows">
|
|
<div class="inv-popover-row">
|
|
<span class="inv-location">Murray, KY</span>
|
|
<span class="onhand-badge {onhandClass(breakdown.murray)}">{breakdown.murray}</span>
|
|
</div>
|
|
<div class="inv-popover-row">
|
|
<span class="inv-location">Union City, TN</span>
|
|
<span class="onhand-badge {onhandClass(breakdown.unionCity)}">{breakdown.unionCity}</span>
|
|
</div>
|
|
<div class="inv-popover-row">
|
|
<span class="inv-location">London, KY</span>
|
|
<span class="onhand-badge {onhandClass(breakdown.london)}">{breakdown.london}</span>
|
|
</div>
|
|
<div class="inv-popover-row">
|
|
<span class="inv-location">Vehicles</span>
|
|
<span class="onhand-badge {onhandClass(breakdown.vehicles)}">{breakdown.vehicles}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.inv-popover-anchor {
|
|
position: relative;
|
|
display: inline-block;
|
|
cursor: default;
|
|
}
|
|
|
|
/* Badge styles — self-contained so component works outside catalog.css context */
|
|
.onhand-badge {
|
|
display: inline-block;
|
|
min-width: 28px;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
|
|
.onhand-zero {
|
|
background: var(--status-inactive-bg, #fee2e2);
|
|
color: var(--status-inactive-color, #dc2626);
|
|
}
|
|
|
|
.onhand-low {
|
|
background: var(--status-pending-bg, #fef3c7);
|
|
color: var(--status-pending-color, #d97706);
|
|
}
|
|
|
|
.onhand-ok {
|
|
background: var(--status-active-bg, #dcfce7);
|
|
color: var(--status-active-color, #16a34a);
|
|
}
|
|
|
|
/*
|
|
* Portalled to document.body — fixed to viewport, above everything.
|
|
* Note: Svelte scoped styles won't apply to portalled nodes, so the
|
|
* popover styles below use :global() to reach across the portal boundary.
|
|
*/
|
|
|
|
:global(.inv-popover) {
|
|
position: fixed;
|
|
transform: translateX(-50%) translateY(-100%);
|
|
z-index: 9999;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 8px;
|
|
box-shadow: var(--card-hover-shadow, 0 8px 24px rgba(0, 0, 0, 0.15));
|
|
padding: 10px 14px;
|
|
min-width: 210px;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
font-family: inherit;
|
|
}
|
|
|
|
:global(.inv-popover-title) {
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-muted);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
:global(.inv-popover-loading) {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
padding: 4px 0;
|
|
}
|
|
|
|
:global(.inv-spinner) {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid var(--border-subtle);
|
|
border-top-color: var(--text-secondary);
|
|
border-radius: 50%;
|
|
animation: inv-spin 0.6s linear infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes inv-spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
:global(.inv-popover-rows) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
:global(.inv-popover-row) {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
:global(.inv-location) {
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
font-weight: 500;
|
|
}
|
|
</style>
|