Smart opened-product expiry: days countdown, edibility, correct sort
PHP getStats() opened section: - Primary detection: opened_at IS NOT NULL (reliable, set by useFromInventory) Fallback: fractional-qty pattern (legacy items) - Per-item compute opened_expiry = min(opened_at + estimateOpenedExpiryDaysPHP, original_expiry) → vacuum_sealed items get 1.5× multiplier → always take sooner of 'opened shelf life' vs 'original sealed expiry' - Add days_to_expiry, opened_expiry, is_edible, has_opened_at to each item - Filter legacy items (no opened_at) with expiry > 14 days (too much noise) - Sort by days_to_expiry ASC (soonest/spoiled first) instead of updated_at DESC JS dashboard opened render: - Expiry badge: ⛔ Scaduto / ⚠️ Scade oggi / ⏰ Xgg (urgent≤2, soon≤5, ok>5) - 🔒 icon added when vacuum_sealed=1 - Spoiled items shown with strikethrough name + muted styling (.alert-item-spoiled) - Cap display at 10 items; 'e altri N prodotti aperti...' note if more - Sort comes from server (removed JS openedFraction sort) CSS: - .opened-expiry-{ok,soon,urgent,today,spoiled} badge classes - .alert-item-spoiled strikethrough styling - .alert-more-note
This commit is contained in:
+58
-11
@@ -1055,24 +1055,71 @@ function getStats(PDO $db): void {
|
|||||||
ORDER BY i.expiry_date ASC
|
ORDER BY i.expiry_date ASC
|
||||||
")->fetchAll();
|
")->fetchAll();
|
||||||
|
|
||||||
// Opened (partially used items with known package capacity)
|
// Opened (items with opened_at set by the app, OR fractional-qty items as legacy fallback)
|
||||||
$opened = $db->query("
|
// opened_at IS NOT NULL → already has recalculated expiry_date stored when first opened
|
||||||
|
$openedRaw = $db->query("
|
||||||
SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, p.image_url,
|
SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, p.image_url,
|
||||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed
|
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed
|
||||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
WHERE i.quantity > 0 AND p.default_quantity > 0
|
WHERE i.quantity > 0
|
||||||
AND (
|
AND (
|
||||||
-- conf products with fractional quantity
|
-- Primary: tracked as opened by the app (expiry_date already recalculated)
|
||||||
(p.unit = 'conf' AND p.package_unit IS NOT NULL
|
i.opened_at IS NOT NULL
|
||||||
AND CAST(i.quantity AS REAL) != CAST(CAST(i.quantity AS INTEGER) AS REAL))
|
|
||||||
OR
|
OR
|
||||||
-- non-conf products where quantity is not a clean multiple of package size (>2% tolerance)
|
-- Fallback: fractional quantity pattern (legacy items before opened_at tracking)
|
||||||
(p.unit != 'conf'
|
(p.default_quantity > 0 AND (
|
||||||
AND ABS(i.quantity - ROUND(CAST(i.quantity AS REAL) / p.default_quantity) * p.default_quantity) > (p.default_quantity * 0.02))
|
(p.unit = 'conf' AND p.package_unit IS NOT NULL
|
||||||
|
AND CAST(i.quantity AS REAL) != CAST(CAST(i.quantity AS INTEGER) AS REAL))
|
||||||
|
OR
|
||||||
|
(p.unit != 'conf'
|
||||||
|
AND ABS(i.quantity - ROUND(CAST(i.quantity AS REAL) / p.default_quantity) * p.default_quantity) > (p.default_quantity * 0.02))
|
||||||
|
))
|
||||||
)
|
)
|
||||||
ORDER BY i.updated_at DESC
|
|
||||||
")->fetchAll();
|
")->fetchAll();
|
||||||
|
|
||||||
|
// Compute opened_expiry and days_to_expiry for each opened item
|
||||||
|
$opened = [];
|
||||||
|
$today = strtotime('today midnight');
|
||||||
|
foreach ($openedRaw as $item) {
|
||||||
|
$vacuum = (int)($item['vacuum_sealed'] ?? 0);
|
||||||
|
$originalExpiry = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null;
|
||||||
|
|
||||||
|
if (!empty($item['opened_at'])) {
|
||||||
|
// Compute the opened shelf-life from the moment it was opened
|
||||||
|
$openedDays = estimateOpenedExpiryDaysPHP($item['name'], $item['category'], $item['location']);
|
||||||
|
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
|
||||||
|
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
|
||||||
|
// Use the sooner of computed opened expiry vs original sealed expiry
|
||||||
|
if ($originalExpiry !== null) {
|
||||||
|
$finalExpiry = min($computedExpiry, $originalExpiry);
|
||||||
|
} else {
|
||||||
|
$finalExpiry = $computedExpiry;
|
||||||
|
}
|
||||||
|
$item['opened_expiry'] = date('Y-m-d', $finalExpiry);
|
||||||
|
$item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400);
|
||||||
|
} else {
|
||||||
|
// Legacy: no opened_at, use stored expiry_date as-is
|
||||||
|
$item['opened_expiry'] = $item['expiry_date'] ?? null;
|
||||||
|
$item['days_to_expiry'] = $originalExpiry !== null
|
||||||
|
? (int)round(($originalExpiry - $today) / 86400)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
$item['is_edible'] = $item['days_to_expiry'] === null || $item['days_to_expiry'] >= 0;
|
||||||
|
$item['has_opened_at'] = !empty($item['opened_at']);
|
||||||
|
// Hide legacy fractional items (no opened_at) with far-off expiry — not useful for home widget
|
||||||
|
if (!$item['has_opened_at'] && ($item['days_to_expiry'] === null || $item['days_to_expiry'] > 14)) continue;
|
||||||
|
$opened[] = $item;
|
||||||
|
}
|
||||||
|
// Sort by days_to_expiry ascending (soonest first; nulls last)
|
||||||
|
usort($opened, function($a, $b) {
|
||||||
|
$da = $a['days_to_expiry'];
|
||||||
|
$db2 = $b['days_to_expiry'];
|
||||||
|
if ($da === null && $db2 === null) return 0;
|
||||||
|
if ($da === null) return 1;
|
||||||
|
if ($db2 === null) return -1;
|
||||||
|
return $da <=> $db2;
|
||||||
|
});
|
||||||
|
|
||||||
// Waste vs consumption stats (last 30 days)
|
// Waste vs consumption stats (last 30 days)
|
||||||
$wasteStats = $db->query("
|
$wasteStats = $db->query("
|
||||||
SELECT type, COUNT(*) as count
|
SELECT type, COUNT(*) as count
|
||||||
|
|||||||
@@ -4304,6 +4304,34 @@ body {
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opened expiry badges */
|
||||||
|
.alert-item-badge.opened-expiry {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
.opened-expiry-ok { background: #dcfce7; color: #166534; }
|
||||||
|
.opened-expiry-soon { background: #fef9c3; color: #854d0e; }
|
||||||
|
.opened-expiry-urgent { background: #fee2e2; color: #991b1b; }
|
||||||
|
.opened-expiry-today { background: #f97316; color: #fff; }
|
||||||
|
.opened-expiry-spoiled { background: #1f2937; color: #f9fafb; }
|
||||||
|
|
||||||
|
.alert-item-spoiled {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.alert-item-spoiled .alert-item-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-more-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.review-hint {
|
.review-hint {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
|
|||||||
+35
-8
@@ -1079,13 +1079,12 @@ async function loadDashboard() {
|
|||||||
const openedSection = document.getElementById('alert-opened');
|
const openedSection = document.getElementById('alert-opened');
|
||||||
const openedList = document.getElementById('opened-list');
|
const openedList = document.getElementById('opened-list');
|
||||||
if (statsData.opened && statsData.opened.length > 0) {
|
if (statsData.opened && statsData.opened.length > 0) {
|
||||||
// Sort by remaining fraction ascending (least remaining first)
|
// Sorted server-side by days_to_expiry ASC
|
||||||
statsData.opened.sort((a, b) => {
|
|
||||||
const fA = openedFraction(a), fB = openedFraction(b);
|
|
||||||
return fA - fB;
|
|
||||||
});
|
|
||||||
openedSection.style.display = 'block';
|
openedSection.style.display = 'block';
|
||||||
openedList.innerHTML = statsData.opened.map(item => {
|
const MAX_SHOWN = 10;
|
||||||
|
const visible = statsData.opened.slice(0, MAX_SHOWN);
|
||||||
|
const extra = statsData.opened.length - visible.length;
|
||||||
|
openedList.innerHTML = visible.map(item => {
|
||||||
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
||||||
const qty = parseFloat(item.quantity);
|
const qty = parseFloat(item.quantity);
|
||||||
const pkgSize = parseFloat(item.default_quantity);
|
const pkgSize = parseFloat(item.default_quantity);
|
||||||
@@ -1116,8 +1115,35 @@ async function loadDashboard() {
|
|||||||
qtyText = `${qty}${unitLabel}`;
|
qtyText = `${qty}${unitLabel}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expiry badge
|
||||||
|
const days = item.days_to_expiry;
|
||||||
|
const isEdible = item.is_edible;
|
||||||
|
let expiryBadge = '';
|
||||||
|
if (days !== null && days !== undefined) {
|
||||||
|
let expiryClass, expiryText;
|
||||||
|
if (!isEdible) {
|
||||||
|
expiryClass = 'opened-expiry-spoiled';
|
||||||
|
expiryText = '⛔ Scaduto!';
|
||||||
|
} else if (days === 0) {
|
||||||
|
expiryClass = 'opened-expiry-today';
|
||||||
|
expiryText = '⚠️ Scade oggi!';
|
||||||
|
} else if (days <= 2) {
|
||||||
|
expiryClass = 'opened-expiry-urgent';
|
||||||
|
expiryText = `⏰ Scade fra ${days}gg`;
|
||||||
|
} else if (days <= 5) {
|
||||||
|
expiryClass = 'opened-expiry-soon';
|
||||||
|
expiryText = `⏰ Scade fra ${days}gg`;
|
||||||
|
} else {
|
||||||
|
expiryClass = 'opened-expiry-ok';
|
||||||
|
expiryText = `✅ Ancora ${days}gg`;
|
||||||
|
}
|
||||||
|
const vacuumNote = item.vacuum_sealed ? ' 🔒' : '';
|
||||||
|
expiryBadge = `<span class="alert-item-badge opened-expiry ${expiryClass}">${expiryText}${vacuumNote}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="alert-item alert-item-clickable" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
|
<div class="alert-item alert-item-clickable${!isEdible ? ' alert-item-spoiled' : ''}" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
|
||||||
<div class="alert-item-info">
|
<div class="alert-item-info">
|
||||||
<span class="alert-item-name">${escapeHtml(item.name)}</span>
|
<span class="alert-item-name">${escapeHtml(item.name)}</span>
|
||||||
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
|
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
|
||||||
@@ -1125,9 +1151,10 @@ async function loadDashboard() {
|
|||||||
<div class="alert-item-badges">
|
<div class="alert-item-badges">
|
||||||
<span class="alert-item-qty">${locInfo.icon} ${locInfo.label}</span>
|
<span class="alert-item-qty">${locInfo.icon} ${locInfo.label}</span>
|
||||||
<span class="alert-item-badge opened">${qtyText}</span>
|
<span class="alert-item-badge opened">${qtyText}</span>
|
||||||
|
${expiryBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('') + (extra > 0 ? `<div class="alert-more-note">e altri ${extra} prodotti aperti...</div>` : '');
|
||||||
} else {
|
} else {
|
||||||
openedSection.style.display = 'none';
|
openedSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user