Files
Odoo-Modules/fusion_inventory/views/portal_inventory_templates.xml
gsinghpal e9cf75ee48 changes
2026-03-14 12:04:20 -04:00

848 lines
51 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ═══════════════════════════════════════════════════════════
Portal "My Inventory" link on portal home
═══════════════════════════════════════════════════════════ -->
<template id="portal_my_home_inventory" name="Show Inventory"
inherit_id="portal.portal_my_home"
customize_show="True" priority="60">
<xpath expr="//div[hasclass('o_portal_docs')]" position="before">
<t t-call="portal.portal_docs_entry">
<t t-set="icon" t-value="'/fusion_inventory/static/description/icon.png'"/>
<t t-set="title">Inventory Sheet</t>
<t t-set="url" t-value="'/my/inventory'"/>
<t t-set="text">View live inventory, search products, and book items</t>
<t t-set="placeholder_count" t-value="'fi_inventory'"/>
</t>
</xpath>
</template>
<!-- ═══════════════════════════════════════════════════════════
Portal Inventory Sheet - Main Page
═══════════════════════════════════════════════════════════ -->
<template id="portal_inventory_sheet" name="Inventory Sheet">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container-fluid px-2 px-md-4 py-3" id="fi_inventory_app">
<!-- ── Header ── -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h3 class="mb-0">Inventory Sheet</h3>
<small class="text-muted">
<span id="fi_last_update">Just now</span>
<span class="spinner-border spinner-border-sm ms-1 d-none"
id="fi_loading" role="status"/>
</small>
</div>
<!-- ── Search + Filters ── -->
<div class="row g-2 mb-3">
<div class="col-12 col-md-5">
<div class="input-group">
<input type="text" id="fi_search" class="form-control"
placeholder="Search by name, SKU, or serial..."
t-att-value="search"/>
<button class="btn btn-primary" id="fi_search_btn"
type="button">
<i class="fa fa-search"/>
</button>
</div>
</div>
<div class="col-12 col-md-4 position-relative">
<input type="text" id="fi_category_search" class="form-control"
placeholder="Search categories..." autocomplete="off"/>
<input type="hidden" id="fi_category_filter" value=""/>
<div id="fi_category_dropdown"
class="position-absolute w-100 bg-white border rounded-bottom shadow-sm d-none"
style="z-index:1050; max-height:250px; overflow-y:auto; top:100%;">
</div>
</div>
<div class="col-12 col-md-3" t-if="has_sync">
<select id="fi_warehouse_filter" class="form-select">
<option value="all"
t-att-selected="warehouse_filter == 'all'">All Locations</option>
<option value="local"
t-att-selected="warehouse_filter == 'local'">Local Only</option>
<option value="remote"
t-att-selected="warehouse_filter == 'remote'">Remote Only</option>
<t t-foreach="all_warehouses" t-as="wh">
<option t-att-value="wh['id']">
<t t-if="wh['type'] == 'remote'">&#x1F517; </t>
<t t-esc="wh['name']"/>
<t t-if="wh['company']"> (<t t-esc="wh['company']"/>)</t>
</option>
</t>
</select>
</div>
</div>
<!-- ── Compact table styles ── -->
<style>
#fi_inventory_table {
font-size: 0.8rem;
table-layout: fixed;
width: 100%;
}
#fi_inventory_table th,
#fi_inventory_table td {
padding: 0.3rem 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
#fi_inventory_table .fi-col-product {
width: auto;
white-space: normal;
word-wrap: break-word;
}
#fi_inventory_table .fi-col-sku {
width: 80px;
white-space: nowrap;
}
#fi_inventory_table .fi-col-cat {
width: 100px;
white-space: normal;
word-wrap: break-word;
}
#fi_inventory_table .fi-col-num {
width: 50px;
white-space: nowrap;
text-align: right;
}
#fi_inventory_table .fi-col-price {
width: 65px;
white-space: nowrap;
text-align: right;
}
#fi_inventory_table .fi-col-action {
width: 60px;
text-align: center;
white-space: nowrap;
}
#fi_inventory_table thead th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.02em;
border-bottom: 2px solid #adb5bd;
}
#fi_inventory_table .badge {
font-size: 0.7rem;
padding: 0.15em 0.4em;
}
#fi_inventory_table .btn-sm {
font-size: 0.68rem;
padding: 0.1rem 0.35rem;
}
</style>
<!-- ── Legend ── -->
<div class="d-flex flex-wrap gap-3 mb-3 small">
<span><span class="badge bg-success">&#9679;</span> In Stock</span>
<span><span class="badge bg-warning text-dark">&#9679;</span> Booked</span>
<span><span class="badge bg-info">&#9679;</span> Incoming (PO)</span>
<span><span class="badge bg-danger">&#9679;</span> Out of Stock</span>
<span t-if="has_sync"><span class="badge bg-purple" style="background:#6f42c1!important;">&#9679;</span> Remote</span>
</div>
<!-- ══════════ Desktop Table ══════════ -->
<div class="table-responsive d-none d-md-block">
<table class="table table-hover table-sm table-bordered align-middle"
id="fi_inventory_table">
<thead class="table-light">
<tr>
<th class="fi-col-product">Product</th>
<th class="fi-col-sku">SKU</th>
<th class="fi-col-cat">Category</th>
<th class="fi-col-num">Local</th>
<th class="fi-col-num">Avail</th>
<th class="fi-col-num" t-if="has_sync">Remote</th>
<th class="fi-col-num" t-if="has_sync">Total</th>
<th class="fi-col-num">Booked</th>
<th class="fi-col-num">Incom.</th>
<th class="fi-col-price">Price</th>
<th class="fi-col-num">Margin</th>
<th class="fi-col-action">Action</th>
</tr>
</thead>
<tbody id="fi_table_body">
<t t-foreach="products" t-as="p">
<tr t-att-data-id="p['id']" t-att-data-tmpl="p.get('tmpl_id', 0)">
<td class="fi-col-product">
<strong t-esc="p['name']"/>
</td>
<td class="fi-col-sku text-muted">
<t t-esc="p['default_code']"/>
</td>
<td class="fi-col-cat"><t t-esc="p['category']"/></td>
<td class="fi-col-num"><t t-esc="p['qty_on_hand']"/></td>
<td class="fi-col-num">
<span t-attf-class="fw-bold #{'text-danger' if p['available_qty'] &lt;= 0 else 'text-success'}">
<t t-esc="p['available_qty']"/>
</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<span t-if="p.get('remote_qty', 0) > 0"
class="badge text-white fi-remote-badge"
style="background:#6f42c1; cursor:pointer;"
t-att-title="', '.join([w['warehouse'] + ': ' + str(w['qty']) for w in p.get('remote_warehouses', [])])">
<t t-esc="p['remote_qty']"/>
</span>
<span t-if="p.get('remote_qty', 0) &lt;= 0"
class="text-muted">0</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<strong><t t-esc="p.get('total_qty', p['qty_on_hand'])"/></strong>
</td>
<td class="fi-col-num">
<span t-if="p['booked_qty'] > 0"
class="badge bg-warning text-dark">
<t t-esc="p['booked_qty']"/>
</span>
</td>
<td class="fi-col-num">
<span t-if="p['shadow_qty'] > 0"
class="badge bg-info">
<t t-esc="p['shadow_qty']"/>
</span>
</td>
<td class="fi-col-price">
$<t t-esc="'%.2f' % p['sale_price']"/>
</td>
<td class="fi-col-num">
<span t-attf-class="badge #{'bg-success' if p['margin_pct'] > 0 else 'bg-secondary'}">
<t t-esc="'%.1f' % p['margin_pct']"/>%
</span>
</td>
<td class="fi-col-action">
<div class="btn-group btn-group-sm">
<button t-if="p['available_qty'] > 0"
class="btn btn-sm btn-outline-primary fi-book-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']">
Book
</button>
<button t-if="has_sync and p.get('remote_qty', 0) > 0 and p['id'] > 0"
class="btn btn-sm btn-outline-secondary fi-transfer-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']"
t-att-data-remote-qty="p['remote_qty']"
title="Transfer from remote">
<i class="fa fa-exchange"/>
</button>
</div>
<span t-if="p['available_qty'] &lt;= 0 and p.get('remote_qty', 0) &lt;= 0"
class="text-muted small">--</span>
</td>
</tr>
</t>
<!-- Remote-only products -->
<t t-foreach="remote_only_products" t-as="rp">
<tr class="table-light fst-italic" t-att-data-id="0">
<td class="fi-col-product">
<span class="badge bg-secondary me-1">Remote</span>
<t t-esc="rp['name']"/>
</td>
<td class="fi-col-sku text-muted"><t t-esc="rp['default_code']"/></td>
<td class="fi-col-cat"><t t-esc="rp['category']"/></td>
<td class="fi-col-num text-muted">0</td>
<td class="fi-col-num text-muted">0</td>
<td class="fi-col-num" t-if="has_sync">
<span class="badge text-white" style="background:#6f42c1;">
<t t-esc="rp['remote_qty']"/>
</span>
</td>
<td class="fi-col-num" t-if="has_sync">
<strong><t t-esc="rp['total_qty']"/></strong>
</td>
<td class="fi-col-num">-</td>
<td class="fi-col-num">-</td>
<td class="fi-col-price">$<t t-esc="'%.2f' % rp['sale_price']"/></td>
<td class="fi-col-num">-</td>
<td class="fi-col-action text-muted small">
<t t-esc="rp.get('config_name', '')"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<!-- ══════════ Mobile Cards ══════════ -->
<div class="d-block d-md-none" id="fi_mobile_cards">
<t t-foreach="products" t-as="p">
<div class="card mb-2 fi-card" t-att-data-id="p['id']"
t-attf-style="border-left: 4px solid #{'#ffc107' if p['booked_qty'] > 0 else '#198754' if p['available_qty'] > 0 else '#dc3545'};">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start">
<div style="max-width:65%;">
<div class="fw-bold text-truncate">
<t t-esc="p['name']"/>
</div>
<small class="text-muted">
<t t-esc="p['default_code']"/>
<t t-if="p['category']"> | <t t-esc="p['category']"/></t>
</small>
</div>
<div class="text-end">
<div class="fw-bold">$<t t-esc="'%.2f' % p['sale_price']"/></div>
<span t-attf-class="badge #{'bg-success' if p['margin_pct'] > 0 else 'bg-secondary'} small">
<t t-esc="'%.1f' % p['margin_pct']"/>%
</span>
</div>
</div>
<div t-attf-class="row g-1 mt-2 text-center #{'row-cols-5' if has_sync else ''}" style="font-size:.85rem;">
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['qty_on_hand']"/></div>
<div class="text-muted" style="font-size:.7rem;">Local</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div t-attf-class="fw-bold #{'text-danger' if p['available_qty'] &lt;= 0 else ''}">
<t t-esc="p['available_qty']"/>
</div>
<div class="text-muted" style="font-size:.7rem;">Avail</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}" t-if="has_sync">
<div class="fw-bold" style="color:#6f42c1;">
<t t-esc="p.get('remote_qty', 0)"/>
</div>
<div class="text-muted" style="font-size:.7rem;">Remote</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['booked_qty']"/></div>
<div class="text-muted" style="font-size:.7rem;">Booked</div>
</div>
<div t-attf-class="#{'col' if has_sync else 'col-3'}">
<div class="fw-bold"><t t-esc="p['shadow_qty']"/></div>
<div class="text-muted" style="font-size:.7rem;">Incoming</div>
</div>
</div>
<div class="mt-2 text-end">
<div class="btn-group btn-group-sm">
<button t-if="p['available_qty'] > 0"
class="btn btn-sm btn-outline-primary fi-book-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']">
Book
</button>
<button t-if="has_sync and p.get('remote_qty', 0) > 0 and p['id'] > 0"
class="btn btn-sm btn-outline-secondary fi-transfer-btn"
t-att-data-id="p['id']"
t-att-data-name="p['name']"
t-att-data-remote-qty="p['remote_qty']">
<i class="fa fa-exchange"/> Transfer
</button>
</div>
</div>
</div>
</div>
</t>
</div>
<!-- ══════════ Pagination ══════════ -->
<nav class="mt-3" id="fi_pagination">
</nav>
<div class="text-muted small text-center mt-2">
<span id="fi_count_text">Showing <t t-esc="len(products)"/> of <t t-esc="total_products"/> products</span>
| Auto-refreshes every 30 seconds
</div>
</div>
<!-- ══════════ Transfer Modal ══════════ -->
<div class="modal fade" id="fi_transfer_modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Transfer from Remote</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"/>
</div>
<div class="modal-body">
<p>
Transfer <strong id="fi_transfer_product_name"></strong>
from remote inventory.
</p>
<div class="mb-3">
<label class="form-label">Available at remote:</label>
<span id="fi_transfer_remote_qty" class="fw-bold"></span>
</div>
<div class="mb-3">
<label for="fi_transfer_qty" class="form-label">Quantity to transfer:</label>
<input type="number" id="fi_transfer_qty" class="form-control"
min="1" value="1"/>
</div>
<div id="fi_transfer_result" class="d-none">
<div id="fi_transfer_success" class="alert alert-success d-none"></div>
<div id="fi_transfer_error" class="alert alert-danger d-none"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="fi_transfer_confirm_btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="fi_transfer_spinner"/>
Confirm Transfer
</button>
</div>
</div>
</div>
</div>
<!-- ══════════ JavaScript: Polling + Booking + Transfer ══════════ -->
<script type="text/javascript">
(function () {
'use strict';
var refreshInterval = 30000;
var timer = null;
var searchTimer = null;
var currentPage = <t t-esc="page"/>;
var hasSync = <t t-esc="'true' if has_sync else 'false'"/>;
function updateTimestamp() {
var el = document.getElementById('fi_last_update');
if (el) el.textContent = new Date().toLocaleTimeString();
}
function showLoading(show) {
var el = document.getElementById('fi_loading');
if (el) el.classList.toggle('d-none', !show);
}
function refreshData(resetPage) {
if (resetPage) currentPage = 1;
var searchEl = document.getElementById('fi_search');
var catEl = document.getElementById('fi_category_filter');
var whEl = document.getElementById('fi_warehouse_filter');
var search = searchEl ? searchEl.value.trim() : '';
var catId = catEl ? catEl.value.trim() : '';
var warehouse = whEl ? whEl.value : 'all';
showLoading(true);
fetch('/my/inventory/data', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
search: search,
category_ids: catId ? [parseInt(catId)] : null,
page: currentPage,
warehouse: warehouse
}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
showLoading(false);
updateTimestamp();
if (data.result &amp;&amp; data.result.products) {
rebuildTable(data.result.products);
rebuildMobileCards(data.result.products);
updateCount(data.result.products.length, data.result.total);
rebuildPagination(data.result.page, data.result.total_pages);
}
})
.catch(function() { showLoading(false); });
}
function debouncedSearch() {
if (searchTimer) clearTimeout(searchTimer);
currentPage = 1;
searchTimer = setTimeout(refreshData, 300);
}
function goToPage(pg) {
currentPage = pg;
refreshData();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function rebuildPagination(page, totalPages) {
var nav = document.getElementById('fi_pagination');
if (!nav) return;
if (totalPages &lt;= 1) { nav.innerHTML = ''; return; }
var html = '&lt;ul class="pagination pagination-sm justify-content-center">';
html += '&lt;li class="page-item' + (page &lt;= 1 ? ' disabled' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + (page - 1) + '">&amp;lt;&lt;/a>&lt;/li>';
for (var pg = 1; pg &lt;= totalPages; pg++) {
if (pg &lt;= 3 || Math.abs(pg - page) &lt;= 1 || pg === totalPages) {
html += '&lt;li class="page-item' + (pg === page ? ' active' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + pg + '">' + pg + '&lt;/a>&lt;/li>';
} else if (pg === 4 &amp;&amp; page > 5) {
html += '&lt;li class="page-item disabled">&lt;span class="page-link">...&lt;/span>&lt;/li>';
} else if (pg === totalPages - 1 &amp;&amp; page &lt; totalPages - 3) {
html += '&lt;li class="page-item disabled">&lt;span class="page-link">...&lt;/span>&lt;/li>';
}
}
html += '&lt;li class="page-item' + (page >= totalPages ? ' disabled' : '') + '">'
+ '&lt;a class="page-link" href="#" data-page="' + (page + 1) + '">&amp;gt;&lt;/a>&lt;/li>';
html += '&lt;/ul>';
nav.innerHTML = html;
nav.querySelectorAll('a.page-link[data-page]').forEach(function(a) {
a.addEventListener('click', function(e) {
e.preventDefault();
var pg = parseInt(this.getAttribute('data-page'));
if (pg >= 1 &amp;&amp; pg &lt;= totalPages) goToPage(pg);
});
});
}
function updateCount(shown, total) {
var el = document.getElementById('fi_count_text');
if (el) el.textContent = 'Showing ' + shown + ' of ' + total + ' products';
}
function rebuildTable(products) {
var tbody = document.getElementById('fi_table_body');
if (!tbody) return;
var html = '';
products.forEach(function(p) {
var isRemoteOnly = p.remote_only || false;
var availClass = p.available_qty &lt;= 0 ? 'text-danger' : 'text-success';
var marginClass = p.margin_pct > 0 ? 'bg-success' : 'bg-secondary';
var remoteQty = p.remote_qty || 0;
var totalQty = p.total_qty || p.qty_on_hand;
var actionHtml = '';
if (!isRemoteOnly) {
if (p.available_qty > 0) {
actionHtml += '&lt;button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book&lt;/button>';
}
if (hasSync &amp;&amp; remoteQty > 0 &amp;&amp; p.id > 0) {
actionHtml += '&lt;button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '">&lt;i class="fa fa-exchange">&lt;/i>&lt;/button>';
}
if (!actionHtml) actionHtml = '&lt;span class="text-muted small">--&lt;/span>';
actionHtml = '&lt;div class="btn-group btn-group-sm">' + actionHtml + '&lt;/div>';
} else {
actionHtml = '&lt;span class="text-muted small">' + escHtml(p.config_name || '') + '&lt;/span>';
}
var remoteCol = '';
var totalCol = '';
if (hasSync) {
var whTooltip = (p.remote_warehouses || []).map(function(w) { return w.warehouse + ': ' + w.qty; }).join(', ');
remoteCol = remoteQty > 0
? '&lt;span class="badge text-white fi-remote-badge" style="background:#6f42c1; cursor:pointer;" title="' + escHtml(whTooltip) + '">' + remoteQty + '&lt;/span>'
: '&lt;span class="text-muted">0&lt;/span>';
totalCol = '&lt;strong>' + totalQty + '&lt;/strong>';
}
var trClass = isRemoteOnly ? ' class="table-light fst-italic"' : '';
var nameCell = isRemoteOnly
? '&lt;span class="badge bg-secondary me-1">Remote&lt;/span>' + escHtml(p.name)
: '&lt;strong>' + escHtml(p.name) + '&lt;/strong>';
html += '&lt;tr' + trClass + ' data-id="' + p.id + '">'
+ '&lt;td class="fi-col-product">' + nameCell + '&lt;/td>'
+ '&lt;td class="fi-col-sku text-muted">' + escHtml(p.default_code) + '&lt;/td>'
+ '&lt;td class="fi-col-cat">' + escHtml(p.category) + '&lt;/td>'
+ '&lt;td class="fi-col-num">' + p.qty_on_hand + '&lt;/td>'
+ '&lt;td class="fi-col-num">&lt;span class="fw-bold ' + availClass + '">' + p.available_qty + '&lt;/span>&lt;/td>'
+ (hasSync ? '&lt;td class="fi-col-num">' + remoteCol + '&lt;/td>' : '')
+ (hasSync ? '&lt;td class="fi-col-num">' + totalCol + '&lt;/td>' : '')
+ '&lt;td class="fi-col-num">' + (p.booked_qty > 0 ? '&lt;span class="badge bg-warning text-dark">' + p.booked_qty + '&lt;/span>' : '') + '&lt;/td>'
+ '&lt;td class="fi-col-num">' + (p.shadow_qty > 0 ? '&lt;span class="badge bg-info">' + p.shadow_qty + '&lt;/span>' : '') + '&lt;/td>'
+ '&lt;td class="fi-col-price">$' + p.sale_price.toFixed(2) + '&lt;/td>'
+ '&lt;td class="fi-col-num">&lt;span class="badge ' + marginClass + '">' + p.margin_pct.toFixed(1) + '%&lt;/span>&lt;/td>'
+ '&lt;td class="fi-col-action">' + actionHtml + '&lt;/td>'
+ '&lt;/tr>';
});
tbody.innerHTML = html;
bindBookButtons();
bindTransferButtons();
}
function rebuildMobileCards(products) {
var container = document.getElementById('fi_mobile_cards');
if (!container) return;
var html = '';
products.forEach(function(p) {
if (p.remote_only) return;
var borderColor = p.booked_qty > 0 ? '#ffc107' : (p.available_qty > 0 ? '#198754' : '#dc3545');
var marginClass = p.margin_pct > 0 ? 'bg-success' : 'bg-secondary';
var remoteQty = p.remote_qty || 0;
var availClass = p.available_qty &lt;= 0 ? 'text-danger' : '';
var remoteMobile = '';
if (hasSync) {
remoteMobile = '&lt;div class="col">'
+ '&lt;div class="fw-bold" style="color:#6f42c1;">' + remoteQty + '&lt;/div>'
+ '&lt;div class="text-muted" style="font-size:.7rem;">Remote&lt;/div>'
+ '&lt;/div>';
}
var btns = '';
if (p.available_qty > 0) {
btns += '&lt;button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book&lt;/button>';
}
if (hasSync &amp;&amp; remoteQty > 0 &amp;&amp; p.id > 0) {
btns += '&lt;button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '">&lt;i class="fa fa-exchange">&lt;/i> Transfer&lt;/button>';
}
var colClass = hasSync ? 'col' : 'col-3';
var rowClass = hasSync ? 'row g-1 mt-2 text-center row-cols-5' : 'row g-1 mt-2 text-center';
html += '&lt;div class="card mb-2 fi-card" data-id="' + p.id + '" style="border-left: 4px solid ' + borderColor + ';">'
+ '&lt;div class="card-body py-2 px-3">'
+ '&lt;div class="d-flex justify-content-between align-items-start">'
+ '&lt;div style="max-width:65%;">'
+ '&lt;div class="fw-bold text-truncate">' + escHtml(p.name) + '&lt;/div>'
+ '&lt;small class="text-muted">' + escHtml(p.default_code) + (p.category ? ' | ' + escHtml(p.category) : '') + '&lt;/small>'
+ '&lt;/div>'
+ '&lt;div class="text-end">'
+ '&lt;div class="fw-bold">$' + p.sale_price.toFixed(2) + '&lt;/div>'
+ '&lt;span class="badge ' + marginClass + ' small">' + p.margin_pct.toFixed(1) + '%&lt;/span>'
+ '&lt;/div>'
+ '&lt;/div>'
+ '&lt;div class="' + rowClass + '" style="font-size:.85rem;">'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.qty_on_hand + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Local&lt;/div>&lt;/div>'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold ' + availClass + '">' + p.available_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Avail&lt;/div>&lt;/div>'
+ remoteMobile
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.booked_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Booked&lt;/div>&lt;/div>'
+ '&lt;div class="' + colClass + '">&lt;div class="fw-bold">' + p.shadow_qty + '&lt;/div>&lt;div class="text-muted" style="font-size:.7rem;">Incoming&lt;/div>&lt;/div>'
+ '&lt;/div>'
+ (btns ? '&lt;div class="mt-2 text-end">&lt;div class="btn-group btn-group-sm">' + btns + '&lt;/div>&lt;/div>' : '')
+ '&lt;/div>&lt;/div>';
});
container.innerHTML = html;
bindBookButtons();
bindTransferButtons();
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&amp;/g, '&amp;amp;').replace(/&lt;/g, '&amp;lt;').replace(/>/g, '&amp;gt;').replace(/"/g, '&amp;quot;');
}
/* ── Booking ── */
function bookProduct(productId, productName) {
if (!confirm('Book "' + productName + '" for 24 hours?')) return;
fetch('/my/inventory/book', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {product_id: productId, quantity: 1}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var res = data.result || {};
if (res.success) {
alert('Product booked! Expires at ' + res.expires_at);
refreshData();
} else {
alert(res.error || 'Booking failed');
}
})
.catch(function() { alert('Network error'); });
}
function bindBookButtons() {
document.querySelectorAll('.fi-book-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
bookProduct(
parseInt(this.getAttribute('data-id')),
this.getAttribute('data-name') || ''
);
});
});
}
/* ── Transfer ── */
var currentTransferProductId = null;
function bindTransferButtons() {
document.querySelectorAll('.fi-transfer-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var pid = parseInt(this.getAttribute('data-id'));
var pname = this.getAttribute('data-name') || '';
var remoteQty = parseFloat(this.getAttribute('data-remote-qty') || '0');
openTransferModal(pid, pname, remoteQty);
});
});
}
function openTransferModal(productId, productName, remoteQty) {
currentTransferProductId = productId;
document.getElementById('fi_transfer_product_name').textContent = productName;
document.getElementById('fi_transfer_remote_qty').textContent = remoteQty;
document.getElementById('fi_transfer_qty').value = 1;
document.getElementById('fi_transfer_qty').max = remoteQty;
document.getElementById('fi_transfer_result').classList.add('d-none');
document.getElementById('fi_transfer_success').classList.add('d-none');
document.getElementById('fi_transfer_error').classList.add('d-none');
document.getElementById('fi_transfer_confirm_btn').disabled = false;
var modal = new bootstrap.Modal(document.getElementById('fi_transfer_modal'));
modal.show();
}
function executeTransfer() {
if (!currentTransferProductId) return;
var qty = parseFloat(document.getElementById('fi_transfer_qty').value) || 1;
var btn = document.getElementById('fi_transfer_confirm_btn');
var spinner = document.getElementById('fi_transfer_spinner');
btn.disabled = true;
spinner.classList.remove('d-none');
fetch('/my/inventory/transfer', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
product_id: currentTransferProductId,
quantity: qty
}
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
spinner.classList.add('d-none');
document.getElementById('fi_transfer_result').classList.remove('d-none');
var res = data.result || {};
if (res.success) {
var msg = 'Transfer initiated!';
if (res.local_po) msg += ' Local PO: ' + res.local_po;
if (res.remote_so) msg += ' | Remote SO: ' + res.remote_so;
var successEl = document.getElementById('fi_transfer_success');
successEl.textContent = msg;
successEl.classList.remove('d-none');
refreshData();
} else {
var errorEl = document.getElementById('fi_transfer_error');
errorEl.textContent = res.error || 'Transfer failed';
errorEl.classList.remove('d-none');
btn.disabled = false;
}
})
.catch(function() {
spinner.classList.add('d-none');
btn.disabled = false;
alert('Network error');
});
}
/* ── Category Search ── */
var catSearchTimer = null;
function setupCategorySearch() {
var input = document.getElementById('fi_category_search');
var hidden = document.getElementById('fi_category_filter');
var dropdown = document.getElementById('fi_category_dropdown');
if (!input || !dropdown) return;
input.addEventListener('input', function() {
if (catSearchTimer) clearTimeout(catSearchTimer);
var q = input.value.trim();
if (q.length === 0) {
hidden.value = '';
dropdown.classList.add('d-none');
refreshData(true);
return;
}
catSearchTimer = setTimeout(function() {
fetch('/my/inventory/categories', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0', method: 'call',
params: { search: q }
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.result || [];
var html = '';
if (cats.length === 0) {
html = '&lt;div class="px-3 py-2 text-muted small">No categories found&lt;/div>';
}
cats.forEach(function(c) {
html += '&lt;div class="px-3 py-2 fi-cat-option" style="cursor:pointer;" data-id="' + c.id + '" data-name="' + escHtml(c.name) + '">'
+ escHtml(c.name) + '&lt;/div>';
});
dropdown.innerHTML = html;
dropdown.classList.remove('d-none');
dropdown.querySelectorAll('.fi-cat-option').forEach(function(opt) {
opt.addEventListener('click', function() {
hidden.value = this.getAttribute('data-id');
input.value = this.getAttribute('data-name');
dropdown.classList.add('d-none');
refreshData(true);
});
opt.addEventListener('mouseenter', function() {
this.style.background = '#e9ecef';
});
opt.addEventListener('mouseleave', function() {
this.style.background = '';
});
});
});
}, 250);
});
input.addEventListener('focus', function() {
if (input.value.trim()) input.dispatchEvent(new Event('input'));
});
document.addEventListener('click', function(e) {
if (!input.contains(e.target) &amp;&amp; !dropdown.contains(e.target)) {
dropdown.classList.add('d-none');
}
});
}
/* ── Init ── */
document.addEventListener('DOMContentLoaded', function() {
bindBookButtons();
bindTransferButtons();
setupCategorySearch();
var searchBtn = document.getElementById('fi_search_btn');
var searchInput = document.getElementById('fi_search');
var whFilter = document.getElementById('fi_warehouse_filter');
var transferBtn = document.getElementById('fi_transfer_confirm_btn');
if (searchBtn) searchBtn.addEventListener('click', function() { refreshData(true); });
if (searchInput) {
searchInput.addEventListener('input', debouncedSearch);
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); refreshData(true); }
});
}
if (whFilter) whFilter.addEventListener('change', function() { refreshData(true); });
if (transferBtn) transferBtn.addEventListener('click', executeTransfer);
rebuildPagination(<t t-esc="page"/>, <t t-esc="total_pages"/>);
timer = setInterval(refreshData, refreshInterval);
updateTimestamp();
});
})();
</script>
</t>
</template>
</odoo>