Files
optima/ui/src/components/InventoryPopover.svelte
T
HoloPanio 6eee7bf0da 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
2026-04-22 00:53:23 +00:00

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>