Dashboard update

This commit is contained in:
Raman Marikanti 2026-01-02 09:59:48 +05:30
parent 3052d3e254
commit 13fa5d8eb6
3 changed files with 1442 additions and 347 deletions

View File

@ -141,9 +141,18 @@ class SamashtiDashboard(models.AbstractModel):
return []
@api.model
def get_products_data(self):
def get_dashboard_cards_data(self):
all_prod = self.env['product.product'].search([('type', '=', 'consu')])
all_category = all_prod.category_id
all_category = all_prod.categ_id
out_stock_prods = all_prod.filtered(lambda x:x.qty_available <= 0)
low_stock_prods = self.env['stock.warehouse.orderpoint'].search([('qty_to_order', '>', 0)]).product_id
return {
'products_count': [len(all_prod),all_prod.ids],
'category_count':len(all_category),
'out_stock_prods': [len(out_stock_prods),out_stock_prods.ids],
'low_stock_prods': [len(low_stock_prods),low_stock_prods.ids],
}
@api.model

View File

@ -1,61 +1,157 @@
/** @odoo-module **/
import { Component, onMounted, useRef, useState, onWillStart } from "@odoo/owl";
import { Component, onMounted, useState, onWillStart, useRef } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
function formatFloat(value) {
if (value === null || value === undefined || isNaN(value)) {
return "0.00";
}
return parseFloat(value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
export class StockDashboard extends Component {
static props = {
static props = {
...standardActionServiceProps,
};
static template = "StockDashboard";
setup() {
this.orm = useService("orm");
this.gridRef = useRef("gridContainer");
this.action = useService("action");
this.notification = useService("notification");
// Refs for debouncing
this.filterTimeout = null;
this.state = useState({
rows: [],
category:[],
cards_data:[],
filteredRows: [],
fromDate: "",
toDate: ""
toDate: "",
filters: {
productCode: '',
productName: '',
minStock: '',
maxStock: ''
},
sortField: '',
sortDirection: 'asc',
activeCategory: 'Finished Products'
});
onWillStart(async () => {
await this.loadDependencies();
this.initializeDates();
await this.loadGridData();
});
onMounted(() => {
this.initDatePickers();
if (this.gridRef.el) {
this.renderGrid();
}
});
}
async loadDependencies() {
try {
window.$ = window.jQuery = window.$ || window.jQuery;
if (!window.pq) throw new Error("pqGrid failed to load");
} catch (error) {
console.error("Failed to load dependencies:", error);
this.notification.add("Failed to load required components", { type: "danger" });
throw error;
// Get category groups (Finished Products vs Raw Materials & Packaging)
getCategoryGroups() {
const finishedProducts = this.getFilteredFinishedProducts();
const rawMaterials = this.getFilteredRawMaterials();
return [
{
name: 'Finished Products',
count: finishedProducts.length,
icon: 'fas fa-box',
color: 'primary',
badgeClass: 'bg-primary bg-opacity-10 text-primary',
slug: 'finished-products'
},
{
name: 'Raw Materials & Packaging',
count: rawMaterials.length,
icon: 'fas fa-industry',
color: 'success',
badgeClass: 'bg-success bg-opacity-10 text-success',
slug: 'raw-materials-packaging'
}
];
}
// Get ALL finished products (unfiltered)
getFinishedProducts() {
return this.state.rows.filter(row =>
(row.category || '').toLowerCase() === 'finished products'
);
}
OpenAllProducts() {
let ids = this.state.cards_data['products_count'][1] || []
this.action.doAction({
type: "ir.actions.act_window",
name: ("Products"),
res_model: "product.product",
views: [[false, "list"]],
domain: [["id", "in", ids]],
target: 'current'
});
}
OpenOutofProducts() {
let ids = this.state.cards_data['out_stock_prods'][1] || []
this.action.doAction({
type: "ir.actions.act_window",
name: ("Products"),
res_model: "product.product",
views: [[false, "list"]],
domain: [["id", "in", ids]],
target: 'current'
});
}
OpenLowProducts() {
let ids = this.state.cards_data['low_stock_prods'][1] || []
this.action.doAction({
type: "ir.actions.act_window",
name: ("Products"),
res_model: "stock.warehouse.orderpoint",
views: [[false, "list"]],
domain: [["product_id", "in", ids]],
target: 'current'
});
}
// Get FILTERED finished products
getFilteredFinishedProducts() {
let filtered = this.getFinishedProducts();
filtered = this.applyCustomFilters(filtered);
return filtered;
}
// Get ALL raw materials & packaging (unfiltered)
getRawMaterials() {
return this.state.rows.filter(row => {
const category = (row.category || '').toLowerCase();
return category !== 'finished products' && category !== '';
});
}
// Get FILTERED raw materials
getFilteredRawMaterials() {
let filtered = this.getRawMaterials();
filtered = this.applyCustomFilters(filtered);
return filtered;
}
// Switch active category
switchCategory(categoryName) {
this.state.activeCategory = categoryName;
// Trigger filter update to show correct filtered data
this.updateFilter();
}
// Format float values for display
formatFloat(value) {
if (value === null || value === undefined || isNaN(value)) {
return "0.00";
}
return parseFloat(value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
initializeDates() {
@ -99,229 +195,506 @@ export class StockDashboard extends Component {
this.state.toDate,
]);
this.state.rows = records || [];
this.renderGrid();
// Initialize filteredRows with filtered data
this.updateFilter();
this.state.cards_data = await this.orm.call("samashti.board", "get_dashboard_cards_data",[])
} catch (error) {
console.error("Error loading data:", error);
this.notification.add("Error loading stock data", { type: "danger" });
}
}
getTotalStockValue() {
return formatFloat(this.state.rows.reduce((sum, row) => sum + (row.value || 0), 0).toFixed(2)) + " ₹";
getTotalStockValue() {
return this.formatFloat(this.state.rows.reduce((sum, row) => sum + (row.value || 0), 0).toFixed(2)) + " ₹";
}
parseFloatValue(value) {
if (value === null || value === undefined || value === "") {
parseFloatValue(value) {
if (value === null || value === undefined || value === "") {
return 0;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
let cleaned = value.replace(/[^\d.-]/g, '');
let result = parseFloat(cleaned);
return isNaN(result) ? 0 : result;
}
return 0;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
let cleaned = value.replace(/[^\d.-]/g, '');
let result = parseFloat(cleaned);
return isNaN(result) ? 0 : result;
}
return 0;
}
// Stock dashboard calculation methods
getTotalProductsCount() {
// Count unique products based on product_code
const uniqueProducts = new Set(
this.state.rows
.filter(row => row.product_code)
.map(row => row.product_code)
);
return uniqueProducts.size;
}
getCategoryCount() {
// Count unique categories
const uniqueCategories = new Set(
this.state.rows
.filter(row => row.category)
.map(row => row.category)
);
return uniqueCategories.size;
}
getOutOfStockCount() {
return this.state.rows.filter(row => {
const currentStock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
return currentStock <= 0;
}).length;
}
getLowStockCount() {
return this.state.rows.filter(row => {
const currentStock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
const minStock = this.parseFloatValue(row.min_stock) || 50; // Default minimum stock
return currentStock > 0 && currentStock < minStock;
}).length;
}
getLowStockPercentage() {
const totalProducts = this.getTotalProductsCount();
const lowStockCount = this.getLowStockCount();
if (totalProducts === 0) return "0.00";
const percentage = (lowStockCount / totalProducts) * 100;
return formatFloat(percentage);
}
async getColumns() {
return [
{ title: "Product Code", dataIndx: "product_code", width: 100, filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Product Name", dataIndx: "product_name", width: 280, filter: { type: 'textbox', condition: 'contain', listeners: ['keyup'] } },
{ title: "Category", dataIndx: "category", width: 150 },
{ title: "Opening Stock", dataIndx: "opening_stock", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Receipts", dataIndx: "receipts", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Production", dataIndx: "production", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Consumption", dataIndx: "consumption", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Dispatch", dataIndx: "dispatch", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Closing Stock", dataIndx: "closing_stock", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
{ title: "Uom", dataIndx: "uom", width: 90, dataType: "text" },
{ title: "Value", dataIndx: "value", width: 120, dataType: "float", format: "#,###.00", summary: { type: "sum" }, align: "right" },
];
getTotalProductsCount() {
const uniqueProducts = new Set(
this.state.rows
.filter(row => row.product_code)
.map(row => row.product_code)
);
return uniqueProducts.size;
}
async renderGrid() {
const columns = await this.getColumns();
const agg = pq.aggregate;
const gridOptions = {
selectionModel: { type: "row" },
width: "100%",
height: "100%",
editable: false,
freezeCols: 2,
stripeRows: true,
menuIcon: true, //show header menu icon initially.
menuUI: {
tabs: ['filter'] //display only filter tab.
},
filterModel: { on: true, mode: "AND", header: true, autoSearch: true, type: 'local', minLength: 1,menuIcon: true },
dataModel: { data: this.state.rows, location: "local", sorting: "local", paging: "local" },
colModel: columns,
toolbar: {
items: [
{
type: 'select',
label: '<span style="color: #555; font-size: 13px;">Format:</span>',
attr: 'id="export_format" style="margin-left: 10px; margin-right: 20px; padding: 5px 10px; border: 1px solid #ddd; border-radius: 4px; background: #fafafa; color: #333; font-size: 13px; min-width: 110px;"',
options: [{ xlsx: '📊 Excel', csv: '📝 CSV', htm: '🌐 HTML', json: '🔤 JSON' }]
},
{
type: "button", label: "Export", icon: "ui-icon-arrowthickstop-1-s",
attr: 'style="padding: 8px 16px; border: 1px solid #3498db; border-radius: 6px; background: linear-gradient(135deg, #3498db, #2980b9); color: white; font-weight: 600; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3);"',
listener: () => this.exportGrid()
},
{
type: 'button',
icon: 'ui-icon-print',
label: 'Print',
attr: 'style="padding: 8px 16px; border: 1px solid #3498db; border-radius: 6px; background: linear-gradient(135deg, #3498db, #2980b9); color: white; font-weight: 600; font-size: 13px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(52, 152, 219, 0.3);"',
listener: function () {
var exportHtml = this.exportData({
title: 'Stock Report',
format: 'htm',
render: true
}),
newWin = window.open('', '', 'width=1200, height=700'),
doc = newWin.document.open();
doc.write(exportHtml);
doc.close();
newWin.print();
}
},
],
},
detailModel: {
cache: false,
getCategoryCount() {
const categories = new Set();
this.state.rows.forEach(row => {
const category = (row.category || '').toLowerCase();
if (category === 'finished products') {
categories.add('Finished Products');
} else if (category) {
categories.add('Raw Materials & Packaging');
}
};
var grid = $(this.gridRef.el).pqGrid()
if (grid.length) {
grid.pqGrid("destroy");
}
$(this.gridRef.el)
.css({ height: '600px', width: '100%' })
.pqGrid(gridOptions);
const groupModel = {
on: true,
checkbox: true,
checkboxHead: true,
header: true,
cascade: true,
titleInFirstCol: true,
fixCols: true,
showSummary: [true, true],
grandSummary: true,
title: [
"{0} ({1})",
"{0} - {1}"
]
};
$(this.gridRef.el).pqGrid("groupOption", groupModel);
$(this.gridRef.el).pqGrid("refreshDataAndView");
});
return categories.size;
}
exportGrid() {
const format_ex = $("#export_format").val();
const grid = $(this.gridRef.el);
getOutOfStockCount() {
return this.state.rows.filter(row => {
const currentStock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
return currentStock <= 0;
}).length;
}
switch (format_ex) {
case 'xlsx':
const blobXlsx = grid.pqGrid("exportData", {
format: 'xlsx',
render: true
});
saveAs(blobXlsx, "StockData.xlsx");
break;
getLowStockCount() {
return this.state.rows.filter(row => {
const currentStock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
const minStock = this.parseFloatValue(row.min_stock) || 50;
return currentStock > 0 && currentStock < minStock;
}).length;
}
case 'csv':
const blobCsv = grid.pqGrid("exportData", {
format: 'csv',
render: true
});
if (typeof blobCsv === 'string') {
saveAs(new Blob([blobCsv], { type: 'text/csv' }), "StockData.csv");
} else {
saveAs(blobCsv, "StockData.csv");
}
break;
// Apply custom filters to a dataset
applyCustomFilters(data) {
let filtered = [...data];
case 'htm':
case 'html':
const blobHtml = grid.pqGrid("exportData", {
format: 'htm',
render: true
});
if (typeof blobHtml === 'string') {
saveAs(new Blob([blobHtml], { type: 'text/html' }), "StockData.html");
} else {
saveAs(blobHtml, "StockData.html");
}
break;
case 'json':
const blobJson = grid.pqGrid("exportData", {
format: 'json',
render: true
});
if (typeof blobJson === 'string') {
saveAs(new Blob([blobJson], { type: 'application/json' }), "StockData.json");
} else {
saveAs(blobJson, "StockData.json");
}
break;
default:
alert('Unsupported format: ' + format_ex);
// Apply product code filter
if (this.state.filters.productCode.trim() !== '') {
filtered = filtered.filter(row => {
const productCode = row.product_code || '';
return productCode.toLowerCase().includes(this.state.filters.productCode.toLowerCase().trim());
});
}
// Apply product name filter
if (this.state.filters.productName.trim() !== '') {
filtered = filtered.filter(row => {
const productName = row.product_name || '';
return productName.toLowerCase().includes(this.state.filters.productName.toLowerCase().trim());
});
}
// Apply stock range filters
if (this.state.filters.minStock !== '') {
const min = this.parseFloatValue(this.state.filters.minStock);
filtered = filtered.filter(row => {
const stock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
return stock >= min;
});
}
if (this.state.filters.maxStock !== '') {
const max = this.parseFloatValue(this.state.filters.maxStock);
filtered = filtered.filter(row => {
const stock = this.parseFloatValue(row.closing_stock || row.quantity || 0);
return stock <= max;
});
}
// Apply sorting if specified
if (this.state.sortField) {
filtered.sort((a, b) => {
let valA = a[this.state.sortField];
let valB = b[this.state.sortField];
// Handle undefined/null values
if (valA === null || valA === undefined) valA = '';
if (valB === null || valB === undefined) valB = '';
if (typeof valA === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return this.state.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.state.sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}
// Keyup filter handler with debouncing
onFilterKeyup(event) {
const field = event.target.placeholder.toLowerCase().includes('code') ? 'productCode' :
event.target.placeholder.toLowerCase().includes('name') ? 'productName' :
event.target.placeholder.toLowerCase().includes('min') ? 'minStock' :
event.target.placeholder.toLowerCase().includes('max') ? 'maxStock' : '';
if (field) {
this.state.filters[field] = event.target.value;
// Clear existing timeout
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
// Set new timeout for debouncing (300ms delay)
this.filterTimeout = setTimeout(() => {
this.updateFilter();
}, 300);
}
}
// Filter handler - called after debounce or button click
updateFilter() {
// Clear any pending timeout
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
this.filterTimeout = null;
}
// Update filteredRows based on active category
if (this.state.activeCategory === 'Finished Products') {
const filtered = this.getFilteredFinishedProducts();
this.state.filteredRows = filtered;
} else {
const filtered = this.getFilteredRawMaterials();
this.state.filteredRows = filtered;
}
}
clearFilters() {
// Clear timeout if any
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
this.filterTimeout = null;
}
this.state.filters = {
productCode: '',
productName: '',
minStock: '',
maxStock: ''
};
this.state.sortField = '';
this.state.sortDirection = 'asc';
// Reset to show all data based on active category
if (this.state.activeCategory === 'Finished Products') {
this.state.filteredRows = this.getFinishedProducts();
} else {
this.state.filteredRows = this.getRawMaterials();
}
}
sortTable(field) {
if (this.state.sortField === field) {
this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.state.sortField = field;
this.state.sortDirection = 'asc';
}
// Re-apply filters with new sort
this.updateFilter();
}
// Export methods
exportToCSV() {
let headers, rows;
if (this.state.activeCategory === 'Finished Products') {
const filteredData = this.getFilteredFinishedProducts();
headers = ['Product Code', 'Product Name', 'Opening Stock', 'Production', 'Dispatch', 'Closing Stock', 'UOM', 'Value'];
rows = filteredData.map(row => [
`"${row.product_code || ''}"`,
`"${row.product_name || ''}"`,
row.opening_stock || 0,
row.production || 0,
row.dispatch || 0,
row.closing_stock || 0,
`"${row.uom || ''}"`,
row.value || 0
]);
} else {
const filteredData = this.getFilteredRawMaterials();
headers = ['Product Code', 'Product Name', 'Opening Stock', 'Receipts', 'Consumption', 'Closing Stock', 'UOM', 'Value'];
rows = filteredData.map(row => [
`"${row.product_code || ''}"`,
`"${row.product_name || ''}"`,
row.opening_stock || 0,
row.receipts || 0,
row.consumption || 0,
row.closing_stock || 0,
`"${row.uom || ''}"`,
row.value || 0
]);
}
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
const fileName = `${this.state.activeCategory.toLowerCase().replace(/ /g, '-')}_${this.state.fromDate}_to_${this.state.toDate}.csv`;
this.downloadFile(csvContent, fileName, 'text/csv');
}
exportToExcel() {
let tableHeaders, rows;
if (this.state.activeCategory === 'Finished Products') {
const filteredData = this.getFilteredFinishedProducts();
tableHeaders = `
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>Opening Stock</th>
<th>Production</th>
<th>Dispatch</th>
<th>Closing Stock</th>
<th>UOM</th>
<th>Value</th>
</tr>
`;
rows = filteredData.map(row => `
<tr>
<td>${row.product_code || ''}</td>
<td>${row.product_name || ''}</td>
<td>${row.opening_stock || 0}</td>
<td>${row.production || 0}</td>
<td>${row.dispatch || 0}</td>
<td>${row.closing_stock || 0}</td>
<td>${row.uom || ''}</td>
<td>${row.value || 0}</td>
</tr>
`).join('');
} else {
const filteredData = this.getFilteredRawMaterials();
tableHeaders = `
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>Opening Stock</th>
<th>Receipts</th>
<th>Consumption</th>
<th>Closing Stock</th>
<th>UOM</th>
<th>Value</th>
</tr>
`;
rows = filteredData.map(row => `
<tr>
<td>${row.product_code || ''}</td>
<td>${row.product_name || ''}</td>
<td>${row.opening_stock || 0}</td>
<td>${row.receipts || 0}</td>
<td>${row.consumption || 0}</td>
<td>${row.closing_stock || 0}</td>
<td>${row.uom || ''}</td>
<td>${row.value || 0}</td>
</tr>
`).join('');
}
const tableHtml = `
<html>
<head>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid black; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
<meta charset="UTF-8">
</head>
<body>
<h2>${this.state.activeCategory} Report - ${this.state.fromDate} to ${this.state.toDate}</h2>
<table>
${tableHeaders}
${rows}
</table>
</body>
</html>
`;
const fileName = `${this.state.activeCategory.toLowerCase().replace(/ /g, '-')}_${this.state.fromDate}_to_${this.state.toDate}.xls`;
this.downloadFile(tableHtml, fileName, 'application/vnd.ms-excel');
}
exportToJSON() {
let data;
if (this.state.activeCategory === 'Finished Products') {
data = this.getFilteredFinishedProducts();
} else {
data = this.getFilteredRawMaterials();
}
const jsonData = JSON.stringify(data, null, 2);
const fileName = `${this.state.activeCategory.toLowerCase().replace(/ /g, '-')}_${this.state.fromDate}_to_${this.state.toDate}.json`;
this.downloadFile(jsonData, fileName, 'application/json');
}
downloadFile(content, fileName, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
printTable() {
const printWindow = window.open('', '_blank');
let tableHeaders, rows, totals;
if (this.state.activeCategory === 'Finished Products') {
const finishedProducts = this.getFilteredFinishedProducts();
tableHeaders = `
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>Opening Stock</th>
<th>Production</th>
<th>Dispatch</th>
<th>Closing Stock</th>
<th>UOM</th>
<th>Value</th>
</tr>
`;
rows = finishedProducts.map(row => {
let rowClass = '';
if (row.closing_stock <= 0) {
rowClass = 'danger';
} else if (row.closing_stock < 50) {
rowClass = 'warning';
}
return `
<tr class="${rowClass}">
<td>${row.product_code || ''}</td>
<td>${row.product_name || ''}</td>
<td>${this.formatFloat(row.opening_stock)}</td>
<td>${this.formatFloat(row.production)}</td>
<td>${this.formatFloat(row.dispatch)}</td>
<td>${this.formatFloat(row.closing_stock)}</td>
<td>${row.uom || ''}</td>
<td>${this.formatFloat(row.value)} </td>
</tr>
`;
}).join('');
totals = `
<tr class="total-row">
<td colspan="2"><strong>TOTALS</strong></td>
<td><strong>${this.formatFloat(finishedProducts.reduce((sum, row) => sum + (row.opening_stock || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(finishedProducts.reduce((sum, row) => sum + (row.production || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(finishedProducts.reduce((sum, row) => sum + (row.dispatch || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(finishedProducts.reduce((sum, row) => sum + (row.closing_stock || 0), 0))}</strong></td>
<td></td>
<td><strong>${this.formatFloat(finishedProducts.reduce((sum, row) => sum + (row.value || 0), 0))} </strong></td>
</tr>
`;
} else {
const rawMaterials = this.getFilteredRawMaterials();
tableHeaders = `
<tr>
<th>Product Code</th>
<th>Product Name</th>
<th>Opening Stock</th>
<th>Receipts</th>
<th>Consumption</th>
<th>Closing Stock</th>
<th>UOM</th>
<th>Value</th>
</tr>
`;
rows = rawMaterials.map(row => {
let rowClass = '';
if (row.closing_stock <= 0) {
rowClass = 'danger';
} else if (row.closing_stock < 50) {
rowClass = 'warning';
}
return `
<tr class="${rowClass}">
<td>${row.product_code || ''}</td>
<td>${row.product_name || ''}</td>
<td>${this.formatFloat(row.opening_stock)}</td>
<td>${this.formatFloat(row.receipts)}</td>
<td>${this.formatFloat(row.consumption)}</td>
<td>${this.formatFloat(row.closing_stock)}</td>
<td>${row.uom || ''}</td>
<td>${this.formatFloat(row.value)} </td>
</tr>
`;
}).join('');
totals = `
<tr class="total-row">
<td colspan="2"><strong>TOTALS</strong></td>
<td><strong>${this.formatFloat(rawMaterials.reduce((sum, row) => sum + (row.opening_stock || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(rawMaterials.reduce((sum, row) => sum + (row.receipts || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(rawMaterials.reduce((sum, row) => sum + (row.consumption || 0), 0))}</strong></td>
<td><strong>${this.formatFloat(rawMaterials.reduce((sum, row) => sum + (row.closing_stock || 0), 0))}</strong></td>
<td></td>
<td><strong>${this.formatFloat(rawMaterials.reduce((sum, row) => sum + (row.value || 0), 0))} </strong></td>
</tr>
`;
}
printWindow.document.write(`
<html>
<head>
<title>${this.state.activeCategory} Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; border: 2px solid #333; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background-color: #f2f2f2; font-weight: bold; }
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #333; padding-bottom: 20px; }
.footer { margin-top: 30px; font-size: 12px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; }
.total-row { background-color: #f8f9fa; font-weight: bold; }
.warning { background-color: #fff3cd; }
.danger { background-color: #f8d7da; }
</style>
</head>
<body>
<div class="header">
<h1>${this.state.activeCategory} Report</h1>
<h3>Date Range: ${this.state.fromDate} to ${this.state.toDate}</h3>
<p>Generated on: ${new Date().toLocaleString()}</p>
</div>
<table>
<thead>
${tableHeaders}
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
${totals}
</tfoot>
</table>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 500);
}
// Helper method to get sort icon
getSortIcon(field) {
if (this.state.sortField !== field) {
return 'fas fa-sort';
}
return this.state.sortDirection === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
}
}

View File

@ -1,135 +1,848 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="StockDashboard" owl="1">
<div class="p-4" style="height: 100%; overflow-y: auto;">
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row align-items-center">
<!-- Left Side: Title with Icon -->
<div class="col-md-6">
<div class="d-flex align-items-center">
<div class="bg-primary rounded-circle p-2 me-3">
<i class="fa fa-cubes text-white"></i>
</div>
<div>
<h4 class="text-primary fw-bold mb-1">Stock Dashboard</h4>
<p class="text-muted mb-0 small">
<i class="fa fa-calendar me-1"></i>
<span t-esc="state.fromDate || 'Start Date'"/>
<i class="fa fa-arrow-right mx-2"></i>
<span t-esc="state.toDate || 'End Date'"/>
</p>
<div class="bg-gradient-light" style="height: 750px; overflow-y: auto;">
<div class="container-fluid px-4 py-3">
<!-- Enhanced Header -->
<div class="glass-card rounded-4 mb-4">
<div class="p-4">
<div class="row align-items-center">
<!-- Left Side: Brand Header -->
<div class="col-lg-7 mb-3 mb-lg-0">
<div class="d-flex align-items-center">
<div class="bg-gradient-primary rounded-3 p-3 me-3 shadow-sm">
<i class="fas fa-chart-line text-white fs-4"></i>
</div>
<div>
<h3 class="text-dark fw-bold mb-1">Stock Analytics Dashboard</h3>
<div class="d-flex align-items-center mt-2">
<span class="badge bg-light text-dark border border-secondary-subtle px-3 py-2 rounded-pill">
<i class="fas fa-calendar-alt me-2"></i>
<span t-esc="state.fromDate || 'Start Date'"/>
<i class="fas fa-arrow-right mx-2"></i>
<span t-esc="state.toDate || 'End Date'"/>
</span>
</div>
</div>
</div>
</div>
<!-- Right Side: Modern Date Controls -->
<div class="col-lg-5">
<div class="row g-2 align-items-center justify-content-end">
<div class="col-12 col-sm-6">
<div class="input-group input-group-sm shadow-sm">
<!-- <span class="input-group-text bg-white border-end-0">-->
<!-- <i class="fas fa-calendar-day text-primary"></i>-->
<!-- </span>-->
<input type="date" id="fromDate" t-model="state.fromDate"
class="form-control form-control-sm border-start-0 py-2"
style="min-width: 100px;"/>
</div>
</div>
<div class="col-12 col-sm-6">
<div class="input-group input-group-sm shadow-sm">
<!-- <span class="input-group-text bg-white border-end-0">-->
<!-- <i class="fas fa-calendar-day text-primary"></i>-->
<!-- </span>-->
<input type="date" id="toDate" t-model="state.toDate"
class="form-control form-control-sm border-start-0 py-2"
style="min-width: 100px;"/>
</div>
</div>
<div class="col-12 mt-2">
<div class="d-flex gap-2">
<button type="button"
class="btn btn-primary btn-sm px-4 py-2 rounded-3 shadow-sm flex-grow-1 d-flex align-items-center justify-content-center"
t-on-click="loadGridData">
<i class="fas fa-sync-alt me-2"></i>
<span class="fw-medium">Update Dashboard</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Side: Date Controls -->
<div class="col-md-6">
<div class="row g-2 align-items-center justify-content-end">
<div class="col-auto">
<div class="input-group input-group-sm">
<span class="input-group-text">
<i class="fa fa-calendar"></i>
</span>
<input type="text" id="fromDate" t-model="state.fromDate"
class="form-control form-control-sm" placeholder="From Date"/>
<!-- Enhanced Stats Cards -->
<div class="row g-4 mb-4">
<!-- Total Products Card -->
<div class="col-xl-3 col-lg-6">
<div class="card border-0 shadow-sm h-100 hover-lift">
<div class="card-body p-4">
<div class="d-flex align-items-start" t-on-click="() => this.OpenAllProducts()">
<div class="bg-primary bg-opacity-10 rounded-3 p-3 me-3">
<i class="fas fa-boxes text-white fs-2"></i>
</div>
<div class="flex-grow-1">
<div class="text-muted small mb-1">TOTAL PRODUCTS</div>
<h3 class="mb-2 fw-bold text-dark" t-esc="state.cards_data['products_count'][0]"/>
<div class="text-muted small">
<i class="fas fa-tags me-1"></i>
<span t-esc="state.cards_data['category_count'] + ' categories'"/>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-auto">
<div class="input-group input-group-sm">
<span class="input-group-text">
<i class="fa fa-calendar"></i>
</span>
<input type="text" id="toDate" t-model="state.toDate"
class="form-control form-control-sm" placeholder="To Date"/>
<!-- Current Stock Value Card -->
<div class="col-xl-3 col-lg-6">
<div class="card border-0 shadow-sm h-100 hover-lift">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-success bg-opacity-10 rounded-3 p-3 me-3">
<i class="fas fa-dollar-sign text-white fs-2"></i>
</div>
<div class="flex-grow-1">
<div class="text-muted small mb-1">STOCK VALUE</div>
<h3 class="mb-2 fw-bold text-success" t-esc="this.getTotalStockValue()"/>
<div class="text-muted small">
<i class="fas fa-warehouse me-1"></i>
Total inventory value
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary btn-sm" t-on-click="loadGridData">
<i class="fa fa-refresh me-1"></i> Update
</button>
<!-- Out of Stock Products Card -->
<div class="col-xl-3 col-lg-6">
<div class="card border-0 shadow-sm h-100 hover-lift">
<div class="card-body p-4">
<div class="d-flex align-items-start" t-on-click="() => this.OpenOutofProducts()">
<div class="bg-danger bg-opacity-10 rounded-3 p-3 me-3">
<i class="fas fa-ban text-white fs-2"></i>
</div>
<div class="flex-grow-1">
<div class="text-muted small mb-1">OUT OF STOCK</div>
<h3 class="mb-2 fw-bold text-danger" t-esc="state.cards_data['out_stock_prods'][0]"/>
<div class="text-muted small">
<i class="fas fa-exclamation-circle me-1"></i>
Needs restocking
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Low Stock Alert Card -->
<div class="col-xl-3 col-lg-6">
<div class="card border-0 shadow-sm h-100 hover-lift">
<div class="card-body p-4">
<div class="d-flex align-items-start" t-on-click="() => this.OpenLowProducts()">
<div class="bg-warning bg-opacity-10 rounded-3 p-3 me-3">
<i class="fas fa-exclamation-triangle text-white fs-2"></i>
</div>
<div class="flex-grow-1">
<div class="text-muted small mb-1">LOW STOCK</div>
<h3 class="mb-2 fw-bold text-warning" t-esc="state.cards_data['low_stock_prods'][0]"/>
<div class="text-muted small">
<i class="fas fa-layer-group me-1"></i>
Below minimum levels
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Category Tabs -->
<div class="card border-0 shadow-sm mb-4 rounded-4">
<div class="card-header bg-white border-0 py-3 px-4">
<h5 class="mb-0 fw-semibold text-dark">Stock by Category</h5>
<small class="text-muted">Click on a category to view detailed inventory</small>
</div>
<div class="card-body p-3">
<!-- Category Tabs Navigation -->
<ul class="nav nav-tabs nav-tabs-custom mb-4" role="tablist">
<t t-foreach="this.getCategoryGroups()" t-as="category" t-key="category.name">
<li class="nav-item" role="presentation">
<button class="nav-link"
t-att-class="category.name === state.activeCategory ? 'active' : ''"
data-bs-toggle="tab"
t-att-data-bs-target="'#' + category.slug"
type="button"
role="tab"
t-on-click="() => this.switchCategory(category.name)">
<i t-att-class="category.icon + ' me-2'"></i>
<t t-esc="category.name"/>
<span t-att-class="'badge ' + category.badgeClass + ' ms-2'">
<t t-esc="category.count" t-att-class="'text-white'"/>
</span>
</button>
</li>
</t>
</ul>
<!-- Category Tabs Content -->
<div class="tab-content">
<!-- Finished Products Tab -->
<div class="tab-pane fade"
t-att-class="state.activeCategory === 'Finished Products' ? 'show active' : ''"
id="finished-products"
role="tabpanel">
<!-- Finished Products Table -->
<div class="card border-0 shadow-lg rounded-4 overflow-hidden" style="border: 2px solid #dee2e6;">
<div class="card-header bg-gradient-primary border-0 py-3">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between">
<div class="d-flex align-items-center mb-2 mb-md-0">
<div class="bg-opacity-20 rounded-3 p-2 me-3">
<i class="fas fa-box text-white"></i>
</div>
<div>
<h5 class="mb-0 text-white fw-semibold">Finished Products</h5>
<small class="text-white text-opacity-75">
Ready for dispatch products
</small>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-2 mt-md-0">
<div class="dropdown">
<button class="btn btn-outline-light btn-sm px-3 rounded-3 d-flex align-items-center"
type="button"
data-bs-toggle="dropdown">
<i class="fas fa-download me-2"></i>Export
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" t-on-click="exportToCSV">
<i class="fas fa-file-csv me-2"></i>CSV
</a>
</li>
<li>
<a class="dropdown-item" href="#" t-on-click="exportToExcel">
<i class="fas fa-file-excel me-2"></i>Excel
</a>
</li>
<li>
<a class="dropdown-item" href="#" t-on-click="exportToJSON">
<i class="fas fa-file-code me-2"></i>JSON
</a>
</li>
</ul>
</div>
<button class="btn btn-outline-light btn-sm px-3 rounded-3 d-flex align-items-center"
t-on-click="printTable">
<i class="fas fa-print me-2"></i>Print
</button>
</div>
</div>
</div>
<!-- Filters Row -->
<div class="table-filters p-3 border-bottom bg-light">
<div class="row g-2 align-items-center">
<div class="col-md-3">
<input type="text"
class="form-control form-control-sm"
placeholder="Product Code"
t-model="state.filters.productCode"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-3">
<input type="text"
class="form-control form-control-sm"
placeholder="Product Name"
t-model="state.filters.productName"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<input type="number"
class="form-control form-control-sm"
placeholder="Min Stock"
t-model="state.filters.minStock"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<input type="number"
class="form-control form-control-sm"
placeholder="Max Stock"
t-model="state.filters.maxStock"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm"
t-on-click="clearFilters">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div class="mt-2 d-flex justify-content-between align-items-center">
<small class="text-muted">
Showing <span t-esc="this.getFilteredFinishedProducts().length"/> of <span t-esc="this.getFinishedProducts().length"/> records
</small>
<small class="text-muted">
Total Value: <span t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.value || 0), 0))"/>
</small>
</div>
</div>
<!-- Scrollable Table Container -->
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-hover table-striped mb-0">
<thead class="table-light sticky-top" style="top: 0; z-index: 1;">
<tr>
<th style="min-width: 60px; text-align:center;">S.No</th>
<th t-on-click="() => this.sortTable('product_code')"
style="cursor: pointer; min-width: 100px;">
Product Code
<i t-att-class="this.getSortIcon('product_code')" class="ms-1"></i>
</th>
<th t-on-click="() => this.sortTable('product_name')"
style="cursor: pointer; min-width: 300px;">
Product Name
<i t-att-class="this.getSortIcon('product_name')" class="ms-1"></i>
</th>
<th style="min-width: 120px; text-align: right;">Opening Stock</th>
<th style="min-width: 120px; text-align: right;">Production</th>
<th style="min-width: 120px; text-align: right;">Dispatch</th>
<th t-on-click="() => this.sortTable('closing_stock')"
style="cursor: pointer; min-width: 120px; text-align: right;">
Closing Stock
<i t-att-class="this.getSortIcon('closing_stock')" class="ms-1"></i>
</th>
<th style="min-width: 80px;">UOM</th>
<th style="min-width: 120px; text-align: right;">Value</th>
</tr>
</thead>
<tbody>
<t t-foreach="this.getFilteredFinishedProducts()" t-as="row" t-key="row_index">
<tr>
<td class="text-center">
<t t-esc="row_index + 1"/>
</td>
<td>
<strong t-esc="row.product_code || 'N/A'"/>
</td>
<td t-esc="row.product_name || 'N/A'"/>
<td class="text-end" t-esc="this.formatFloat(row.opening_stock)"/>
<td class="text-end">
<span class="text-info" t-esc="this.formatFloat(row.production)"/>
</td>
<td class="text-end">
<span class="text-danger" t-esc="this.formatFloat(row.dispatch)"/>
</td>
<td class="text-end">
<t t-esc="this.formatFloat((row.opening_stock + row.production) - row.dispatch) > 0 ? this.formatFloat((row.opening_stock + row.production) - row.dispatch): 0"/>
</td>
<td>
<span class="badge bg-secondary bg-opacity-10 text-secondary px-2 py-1">
<t t-esc="row.uom || 'N/A'"/>
</span>
</td>
<td class="text-end fw-bold">
<span class="text-success">
<t t-esc="this.formatFloat(row.value)"/>
</span>
</td>
</tr>
</t>
<t t-if="this.getFilteredFinishedProducts().length === 0">
<tr>
<td colspan="9" class="text-center py-4">
<div class="text-muted">
<i class="fas fa-database fa-2x mb-3"></i>
<p class="mb-0">No finished products available</p>
<small>Try adjusting your filters or loading more data</small>
</div>
</td>
</tr>
</t>
</tbody>
<tfoot t-if="this.getFilteredFinishedProducts().length > 0">
<tr class="table-light">
<td colspan="3" class="fw-bold">Totals</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.opening_stock || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.production || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.dispatch || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.closing_stock || 0), 0))"/>
</td>
<td></td>
<td class="text-end fw-bold text-success">
<t t-esc="this.formatFloat(this.getFilteredFinishedProducts().reduce((sum, row) => sum + (row.value || 0), 0))"/>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Bottom Padding -->
<div class="card-footer bg-transparent border-0 py-4"></div>
</div>
</div>
<!-- Raw Materials & Packaging Tab -->
<div class="tab-pane fade"
t-att-class="state.activeCategory === 'Raw Materials &amp; Packaging' ? 'show active' : ''"
id="raw-materials-packaging"
role="tabpanel">
<!-- Raw Materials & Packaging Table -->
<div class="card border-0 shadow-lg rounded-4 overflow-hidden" style="border: 2px solid #dee2e6;">
<div class="card-header bg-gradient-success border-0 py-3">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between">
<div class="d-flex align-items-center mb-2 mb-md-0">
<div class="bg-opacity-20 rounded-3 p-2 me-3">
<i class="fas fa-industry text-white"></i>
</div>
<div>
<h5 class="mb-0 text-white fw-semibold">Raw Materials &amp; Packaging</h5>
<small class="text-white text-opacity-75">
Production input materials
</small>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-2 mt-md-0">
<div class="dropdown">
<button class="btn btn-outline-light btn-sm px-3 rounded-3 d-flex align-items-center"
type="button"
data-bs-toggle="dropdown">
<i class="fas fa-download me-2"></i>Export
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" t-on-click="exportToCSV">
<i class="fas fa-file-csv me-2"></i>CSV
</a>
</li>
<li>
<a class="dropdown-item" href="#" t-on-click="exportToExcel">
<i class="fas fa-file-excel me-2"></i>Excel
</a>
</li>
<li>
<a class="dropdown-item" href="#" t-on-click="exportToJSON">
<i class="fas fa-file-code me-2"></i>JSON
</a>
</li>
</ul>
</div>
<button class="btn btn-outline-light btn-sm px-3 rounded-3 d-flex align-items-center"
t-on-click="printTable">
<i class="fas fa-print me-2"></i>Print
</button>
</div>
</div>
</div>
<!-- Filters Row -->
<div class="table-filters p-3 border-bottom bg-light">
<div class="row g-2 align-items-center">
<div class="col-md-3">
<input type="text"
class="form-control form-control-sm"
placeholder="Product Code"
t-model="state.filters.productCode"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-3">
<input type="text"
class="form-control form-control-sm"
placeholder="Product Name"
t-model="state.filters.productName"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<input type="number"
class="form-control form-control-sm"
placeholder="Min Stock"
t-model="state.filters.minStock"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<input type="number"
class="form-control form-control-sm"
placeholder="Max Stock"
t-model="state.filters.maxStock"
t-on-input="onFilterKeyup"/>
</div>
<div class="col-md-2">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm"
t-on-click="clearFilters">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<div class="mt-2 d-flex justify-content-between align-items-center">
<small class="text-muted">
Showing <span t-esc="this.getFilteredRawMaterials().length"/> of <span t-esc="this.getRawMaterials().length"/> records
</small>
<small class="text-muted">
Total Value: <span t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.value || 0), 0))"/>
</small>
</div>
</div>
<!-- Scrollable Table Container -->
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-hover table-striped mb-0">
<thead class="table-light sticky-top" style="top: 0; z-index: 1;">
<tr>
<th style="min-width: 60px; text-align:center;">S.No</th>
<th t-on-click="() => this.sortTable('product_code')"
style="cursor: pointer; min-width: 100px;">
Product Code
<i t-att-class="this.getSortIcon('product_code')" class="ms-1"></i>
</th>
<th t-on-click="() => this.sortTable('product_name')"
style="cursor: pointer; min-width: 300px;">
Product Name
<i t-att-class="this.getSortIcon('product_name')" class="ms-1"></i>
</th>
<th style="min-width: 120px; text-align: right;">Opening Stock</th>
<th style="min-width: 120px; text-align: right;">Receipts</th>
<th style="min-width: 120px; text-align: right;">Consumption</th>
<th t-on-click="() => this.sortTable('closing_stock')"
style="cursor: pointer; min-width: 120px; text-align: right;">
Closing Stock
<i t-att-class="this.getSortIcon('closing_stock')" class="ms-1"></i>
</th>
<th style="min-width: 80px;">UOM</th>
<th style="min-width: 120px; text-align: right;">Value</th>
</tr>
</thead>
<tbody>
<t t-foreach="this.getFilteredRawMaterials()" t-as="row" t-key="row_index">
<tr>
<td class="text-center">
<t t-esc="row_index + 1"/>
</td>
<td>
<strong t-esc="row.product_code || 'N/A'"/>
</td>
<td t-esc="row.product_name || 'N/A'"/>
<td class="text-end" t-esc="this.formatFloat(row.opening_stock)"/>
<td class="text-end">
<span class="text-success" t-esc="this.formatFloat(row.receipts)"/>
</td>
<td class="text-end">
<span class="text-warning" t-esc="this.formatFloat(row.consumption)"/>
</td>
<td class="text-end">
<t t-esc="this.formatFloat(row.closing_stock)"/>
</td>
<td>
<span class="badge bg-secondary bg-opacity-10 text-secondary px-2 py-1">
<t t-esc="row.uom || 'N/A'"/>
</span>
</td>
<td class="text-end fw-bold">
<span class="text-success">
<t t-esc="this.formatFloat(row.value)"/>
</span>
</td>
</tr>
</t>
<t t-if="this.getFilteredRawMaterials().length === 0">
<tr>
<td colspan="9" class="text-center py-4">
<div class="text-muted">
<i class="fas fa-database fa-2x mb-3"></i>
<p class="mb-0">No raw materials available</p>
<small>Try adjusting your filters or loading more data</small>
</div>
</td>
</tr>
</t>
</tbody>
<tfoot t-if="this.getFilteredRawMaterials().length > 0">
<tr class="table-light">
<td colspan="3" class="fw-bold">Totals</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.opening_stock || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.receipts || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.consumption || 0), 0))"/>
</td>
<td class="text-end fw-bold">
<t t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.closing_stock || 0), 0))"/>
</td>
<td></td>
<td class="text-end fw-bold text-success">
<t t-esc="this.formatFloat(this.getFilteredRawMaterials().reduce((sum, row) => sum + (row.value || 0), 0))"/>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Bottom Padding -->
<div class="card-footer bg-transparent border-0 py-4"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<style>
/* Modern UI Enhancements */
.bg-gradient-light {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
<!-- Total Products Card -->
<div class="col mb-4">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-4">
<div class="text-primary mb-3" style="font-size: 2rem;">
📦
</div>
<h6 class="card-title text-muted small mb-2">Total Products</h6>
<h4 class="text-primary fw-bold mb-0">
<span t-esc="this.getTotalProductsCount()"/>
</h4>
<small class="text-muted" t-esc="'Across ' + this.getCategoryCount() + ' categories'"/>
</div>
</div>
</div>
.bg-gradient-primary {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
}
<!-- Current Stock Value Card -->
<div class="col mb-4">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-4">
<div class="text-success mb-3" style="font-size: 2rem;">
💰
</div>
<h6 class="card-title text-muted small mb-2">Current Stock Value</h6>
<h4 class="text-success fw-bold mb-0" t-esc="this.getTotalStockValue()"/>
<small class="text-muted">Total inventory value</small>
</div>
</div>
</div>
.bg-gradient-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
<!-- Out of Stock Products Card -->
<div class="col mb-4">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-4">
<div class="text-danger mb-3" style="font-size: 2rem;">
</div>
<h6 class="card-title text-muted small mb-2">Out of Stock</h6>
<h4 class="text-danger fw-bold mb-0" t-esc="this.getOutOfStockCount()"/>
<small class="text-muted">Products need restocking</small>
</div>
</div>
</div>
.glass-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
<!-- Low Stock Alert Card -->
<div class="col mb-4">
<div class="card shadow-sm h-100">
<div class="card-body text-center py-4">
<div class="text-info mb-3" style="font-size: 2rem;">
⚠️
</div>
<h6 class="card-title text-muted small mb-2">Low Stock Alert</h6>
<h4 class="text-info fw-bold mb-0" t-esc="this.getLowStockCount()"/>
<small class="text-muted">Below minimum levels</small>
</div>
</div>
</div>
.hover-lift {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
</div>
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12) !important;
border-color: #e2e8f0;
}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fa fa-cubes me-2"></i>Stock Data Grid
<small class="float-end" t-esc="'Showing data from ' + (state.fromDate || 'start') + ' to ' + (state.toDate || 'end')"/>
</h5>
</div>
<div class="card-body" style="padding: 0;">
<div t-ref="gridContainer" style="width: 100%; height: 600px;"></div>
</div>
</div>
</div>
.rounded-4 {
border-radius: 16px !important;
}
.rounded-3 {
border-radius: 12px !important;
}
.btn {
transition: all 0.2s ease;
font-weight: 500;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Custom Tab Styling */
.nav-tabs-custom {
border-bottom: 2px solid #e5e7eb;
}
.nav-tabs-custom .nav-link {
border: none;
color: #6b7280;
font-weight: 500;
padding: 0.75rem 1.25rem;
margin-right: 0.5rem;
border-radius: 8px 8px 0 0;
background: transparent;
transition: all 0.2s ease;
}
.nav-tabs-custom .nav-link:hover {
color: #4f46e5;
background: rgba(79, 70, 229, 0.05);
}
.nav-tabs-custom .nav-link.active {
color: #4f46e5;
background: white;
border-bottom: 3px solid #4f46e5;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
}
/* Table Styling */
.table-hover tbody tr:hover {
background-color: rgba(79, 70, 229, 0.05);
cursor: pointer;
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.02);
}
.table th {
font-weight: 600;
color: #4b5563;
border-bottom: 2px solid #e5e7eb;
white-space: nowrap;
}
.table td {
vertical-align: middle;
font-size: 0.8rem !important;
padding: 2px 4px !important;
}
/* Table Filters Styling */
.table-filters {
background: #f8fafc;
border-top: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
}
.table-filters .form-control-sm {
font-size: 0.8rem;
height: 32px;
}
.table-filters .btn-sm {
height: 32px;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
/* Table Outer Border */
.card.shadow-lg {
border: 2px solid #dee2e6 !important;
}
/* Scrollbar styling */
.table-responsive::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-responsive::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-radius: 4px;
}
.table-responsive::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
}
/* Badge styling */
.badge {
font-size: 0.75em;
font-weight: 500;
}
/* Category colors */
.bg-primary { background-color: #4f46e5 !important; }
.bg-success { background-color: #10b981 !important; }
.bg-warning { background-color: #f59e0b !important; }
.bg-info { background-color: #06b6d4 !important; }
.bg-secondary { background-color: #6b7280 !important; }
.bg-danger { background-color: #ef4444 !important; }
/* Gradient backgrounds */
.bg-gradient {
background: linear-gradient(135deg, currentColor 0%, rgba(0,0,0,0.1) 100%) !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container-fluid {
padding-left: 1rem;
padding-right: 1rem;
}
.card-body.p-4 {
padding: 1.25rem !important;
}
.table-responsive {
font-size: 0.875rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.nav-tabs-custom .nav-link {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.table-filters .row {
gap: 0.5rem;
}
.table-filters .col-md-3,
.table-filters .col-md-2 {
width: 100%;
}
}
/* Animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hover-lift {
animation: fadeIn 0.5s ease-out;
}
/* Form controls */
.form-control {
border-color: #e2e8f0;
font-size: 0.875rem;
}
.form-control:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.15);
}
/* Sticky header */
.sticky-top {
position: -webkit-sticky;
position: sticky;
background-color: white;
}
/* Table footer */
tfoot tr {
border-top: 2px solid #e5e7eb;
}
/* Bottom padding for tables */
.card-footer.bg-transparent {
min-height: 40px;
}
</style>
</t>
</templates>