848 lines
51 KiB
XML
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'">🔗 </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">●</span> In Stock</span>
|
|
<span><span class="badge bg-warning text-dark">●</span> Booked</span>
|
|
<span><span class="badge bg-info">●</span> Incoming (PO)</span>
|
|
<span><span class="badge bg-danger">●</span> Out of Stock</span>
|
|
<span t-if="has_sync"><span class="badge bg-purple" style="background:#6f42c1!important;">●</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'] <= 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) <= 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'] <= 0 and p.get('remote_qty', 0) <= 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'] <= 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 && 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 <= 1) { nav.innerHTML = ''; return; }
|
|
|
|
var html = '<ul class="pagination pagination-sm justify-content-center">';
|
|
html += '<li class="page-item' + (page <= 1 ? ' disabled' : '') + '">'
|
|
+ '<a class="page-link" href="#" data-page="' + (page - 1) + '">&lt;</a></li>';
|
|
|
|
for (var pg = 1; pg <= totalPages; pg++) {
|
|
if (pg <= 3 || Math.abs(pg - page) <= 1 || pg === totalPages) {
|
|
html += '<li class="page-item' + (pg === page ? ' active' : '') + '">'
|
|
+ '<a class="page-link" href="#" data-page="' + pg + '">' + pg + '</a></li>';
|
|
} else if (pg === 4 && page > 5) {
|
|
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
|
} else if (pg === totalPages - 1 && page < totalPages - 3) {
|
|
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
|
}
|
|
}
|
|
|
|
html += '<li class="page-item' + (page >= totalPages ? ' disabled' : '') + '">'
|
|
+ '<a class="page-link" href="#" data-page="' + (page + 1) + '">&gt;</a></li>';
|
|
html += '</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 && pg <= 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 <= 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 += '<button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book</button>';
|
|
}
|
|
if (hasSync && remoteQty > 0 && p.id > 0) {
|
|
actionHtml += '<button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '"><i class="fa fa-exchange"></i></button>';
|
|
}
|
|
if (!actionHtml) actionHtml = '<span class="text-muted small">--</span>';
|
|
actionHtml = '<div class="btn-group btn-group-sm">' + actionHtml + '</div>';
|
|
} else {
|
|
actionHtml = '<span class="text-muted small">' + escHtml(p.config_name || '') + '</span>';
|
|
}
|
|
|
|
var remoteCol = '';
|
|
var totalCol = '';
|
|
if (hasSync) {
|
|
var whTooltip = (p.remote_warehouses || []).map(function(w) { return w.warehouse + ': ' + w.qty; }).join(', ');
|
|
remoteCol = remoteQty > 0
|
|
? '<span class="badge text-white fi-remote-badge" style="background:#6f42c1; cursor:pointer;" title="' + escHtml(whTooltip) + '">' + remoteQty + '</span>'
|
|
: '<span class="text-muted">0</span>';
|
|
totalCol = '<strong>' + totalQty + '</strong>';
|
|
}
|
|
|
|
var trClass = isRemoteOnly ? ' class="table-light fst-italic"' : '';
|
|
var nameCell = isRemoteOnly
|
|
? '<span class="badge bg-secondary me-1">Remote</span>' + escHtml(p.name)
|
|
: '<strong>' + escHtml(p.name) + '</strong>';
|
|
|
|
html += '<tr' + trClass + ' data-id="' + p.id + '">'
|
|
+ '<td class="fi-col-product">' + nameCell + '</td>'
|
|
+ '<td class="fi-col-sku text-muted">' + escHtml(p.default_code) + '</td>'
|
|
+ '<td class="fi-col-cat">' + escHtml(p.category) + '</td>'
|
|
+ '<td class="fi-col-num">' + p.qty_on_hand + '</td>'
|
|
+ '<td class="fi-col-num"><span class="fw-bold ' + availClass + '">' + p.available_qty + '</span></td>'
|
|
+ (hasSync ? '<td class="fi-col-num">' + remoteCol + '</td>' : '')
|
|
+ (hasSync ? '<td class="fi-col-num">' + totalCol + '</td>' : '')
|
|
+ '<td class="fi-col-num">' + (p.booked_qty > 0 ? '<span class="badge bg-warning text-dark">' + p.booked_qty + '</span>' : '') + '</td>'
|
|
+ '<td class="fi-col-num">' + (p.shadow_qty > 0 ? '<span class="badge bg-info">' + p.shadow_qty + '</span>' : '') + '</td>'
|
|
+ '<td class="fi-col-price">$' + p.sale_price.toFixed(2) + '</td>'
|
|
+ '<td class="fi-col-num"><span class="badge ' + marginClass + '">' + p.margin_pct.toFixed(1) + '%</span></td>'
|
|
+ '<td class="fi-col-action">' + actionHtml + '</td>'
|
|
+ '</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 <= 0 ? 'text-danger' : '';
|
|
|
|
var remoteMobile = '';
|
|
if (hasSync) {
|
|
remoteMobile = '<div class="col">'
|
|
+ '<div class="fw-bold" style="color:#6f42c1;">' + remoteQty + '</div>'
|
|
+ '<div class="text-muted" style="font-size:.7rem;">Remote</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
var btns = '';
|
|
if (p.available_qty > 0) {
|
|
btns += '<button class="btn btn-sm btn-outline-primary fi-book-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '">Book</button>';
|
|
}
|
|
if (hasSync && remoteQty > 0 && p.id > 0) {
|
|
btns += '<button class="btn btn-sm btn-outline-secondary fi-transfer-btn" data-id="' + p.id + '" data-name="' + escHtml(p.name) + '" data-remote-qty="' + remoteQty + '"><i class="fa fa-exchange"></i> Transfer</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 += '<div class="card mb-2 fi-card" data-id="' + p.id + '" style="border-left: 4px solid ' + borderColor + ';">'
|
|
+ '<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">' + escHtml(p.name) + '</div>'
|
|
+ '<small class="text-muted">' + escHtml(p.default_code) + (p.category ? ' | ' + escHtml(p.category) : '') + '</small>'
|
|
+ '</div>'
|
|
+ '<div class="text-end">'
|
|
+ '<div class="fw-bold">$' + p.sale_price.toFixed(2) + '</div>'
|
|
+ '<span class="badge ' + marginClass + ' small">' + p.margin_pct.toFixed(1) + '%</span>'
|
|
+ '</div>'
|
|
+ '</div>'
|
|
+ '<div class="' + rowClass + '" style="font-size:.85rem;">'
|
|
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.qty_on_hand + '</div><div class="text-muted" style="font-size:.7rem;">Local</div></div>'
|
|
+ '<div class="' + colClass + '"><div class="fw-bold ' + availClass + '">' + p.available_qty + '</div><div class="text-muted" style="font-size:.7rem;">Avail</div></div>'
|
|
+ remoteMobile
|
|
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.booked_qty + '</div><div class="text-muted" style="font-size:.7rem;">Booked</div></div>'
|
|
+ '<div class="' + colClass + '"><div class="fw-bold">' + p.shadow_qty + '</div><div class="text-muted" style="font-size:.7rem;">Incoming</div></div>'
|
|
+ '</div>'
|
|
+ (btns ? '<div class="mt-2 text-end"><div class="btn-group btn-group-sm">' + btns + '</div></div>' : '')
|
|
+ '</div></div>';
|
|
});
|
|
container.innerHTML = html;
|
|
bindBookButtons();
|
|
bindTransferButtons();
|
|
}
|
|
|
|
function escHtml(s) {
|
|
if (!s) return '';
|
|
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&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 = '<div class="px-3 py-2 text-muted small">No categories found</div>';
|
|
}
|
|
cats.forEach(function(c) {
|
|
html += '<div class="px-3 py-2 fi-cat-option" style="cursor:pointer;" data-id="' + c.id + '" data-name="' + escHtml(c.name) + '">'
|
|
+ escHtml(c.name) + '</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) && !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>
|