further apply zoning and add ordering by zone feature
This commit is contained in:
parent
8894cf21ea
commit
aee9cd3244
@ -100,17 +100,57 @@ const ITEM_GROUPS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZONES = [
|
// Store zones - path-oriented physical shopping areas
|
||||||
"Front Entry",
|
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||||
"Fresh Foods Right",
|
const ZONES = {
|
||||||
"Fresh Foods Left",
|
ENTRANCE: "Entrance & Seasonal",
|
||||||
"Center Aisles",
|
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||||
"Bakery",
|
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||||
"Meat Department",
|
DELI_PREPARED: "Deli & Prepared Foods",
|
||||||
"Dairy Cooler",
|
BAKERY_SECTION: "Bakery",
|
||||||
"Freezer Section",
|
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||||
"Back Wall",
|
FROZEN_FOODS: "Frozen Foods",
|
||||||
"Checkout Area",
|
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
|
||||||
|
BEVERAGES: "Beverages & Water",
|
||||||
|
SNACKS_CANDY: "Snacks & Candy",
|
||||||
|
HOUSEHOLD_CLEANING: "Household & Cleaning",
|
||||||
|
HEALTH_BEAUTY: "Health & Beauty",
|
||||||
|
CHECKOUT_AREA: "Checkout Area",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default zone mapping for each item type
|
||||||
|
// This determines where items are typically found in the store
|
||||||
|
const ITEM_TYPE_TO_ZONE = {
|
||||||
|
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
|
||||||
|
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
|
||||||
|
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
|
||||||
|
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
|
||||||
|
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
|
||||||
|
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
|
||||||
|
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
|
||||||
|
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
|
||||||
|
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
|
||||||
|
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
|
||||||
|
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimal walking flow through the store
|
||||||
|
// Represents a typical shopping path that minimizes backtracking
|
||||||
|
// Start with perimeter (fresh items), then move to center aisles, end at checkout
|
||||||
|
const ZONE_FLOW = [
|
||||||
|
ZONES.ENTRANCE,
|
||||||
|
ZONES.PRODUCE_SECTION,
|
||||||
|
ZONES.MEAT_SEAFOOD,
|
||||||
|
ZONES.DELI_PREPARED,
|
||||||
|
ZONES.BAKERY_SECTION,
|
||||||
|
ZONES.DAIRY_SECTION,
|
||||||
|
ZONES.FROZEN_FOODS,
|
||||||
|
ZONES.DRY_GOODS_CENTER,
|
||||||
|
ZONES.BEVERAGES,
|
||||||
|
ZONES.SNACKS_CANDY,
|
||||||
|
ZONES.HOUSEHOLD_CLEANING,
|
||||||
|
ZONES.HEALTH_BEAUTY,
|
||||||
|
ZONES.CHECKOUT_AREA,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validation helpers
|
// Validation helpers
|
||||||
@ -121,13 +161,20 @@ const isValidItemGroup = (type, group) => {
|
|||||||
return ITEM_GROUPS[type]?.includes(group) || false;
|
return ITEM_GROUPS[type]?.includes(group) || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidZone = (zone) => ZONES.includes(zone);
|
const isValidZone = (zone) => Object.values(ZONES).includes(zone);
|
||||||
|
|
||||||
|
const getSuggestedZone = (itemType) => {
|
||||||
|
return ITEM_TYPE_TO_ZONE[itemType] || null;
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ITEM_TYPES,
|
ITEM_TYPES,
|
||||||
ITEM_GROUPS,
|
ITEM_GROUPS,
|
||||||
ZONES,
|
ZONES,
|
||||||
|
ITEM_TYPE_TO_ZONE,
|
||||||
|
ZONE_FLOW,
|
||||||
isValidItemType,
|
isValidItemType,
|
||||||
isValidItemGroup,
|
isValidItemGroup,
|
||||||
isValidZone,
|
isValidZone,
|
||||||
|
getSuggestedZone,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,13 +11,17 @@ exports.getUnboughtItems = async () => {
|
|||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
||||||
gl.modified_on as last_added_on
|
gl.modified_on as last_added_on,
|
||||||
|
ic.item_type,
|
||||||
|
ic.item_group,
|
||||||
|
ic.zone
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
LEFT JOIN users creator ON gl.added_by = creator.id
|
||||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
||||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
||||||
|
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||||
WHERE gl.bought = FALSE
|
WHERE gl.bought = FALSE
|
||||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on
|
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone
|
||||||
ORDER BY gl.id ASC`
|
ORDER BY gl.id ASC`
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications";
|
||||||
import "../styles/AddItemWithDetailsModal.css";
|
import "../styles/AddItemWithDetailsModal.css";
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||||
@ -159,7 +159,7 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
className="add-item-details-select"
|
className="add-item-details-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{ZONES.map((z) => (
|
{getZoneValues().map((z) => (
|
||||||
<option key={z} value={z}>
|
<option key={z} value={z}>
|
||||||
{z}
|
{z}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../constants/classifications";
|
||||||
import "../styles/EditItemModal.css";
|
import "../styles/EditItemModal.css";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||||
@ -134,7 +134,7 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
|||||||
className="edit-modal-select"
|
className="edit-modal-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{ZONES.map((z) => (
|
{getZoneValues().map((z) => (
|
||||||
<option key={z} value={z}>
|
<option key={z} value={z}>
|
||||||
{z}
|
{z}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export default function SortDropdown({ value, onChange }) {
|
|||||||
<option value="za">Z → A</option>
|
<option value="za">Z → A</option>
|
||||||
<option value="qty-high">Quantity: High → Low</option>
|
<option value="qty-high">Quantity: High → Low</option>
|
||||||
<option value="qty-low">Quantity: Low → High</option>
|
<option value="qty-low">Quantity: Low → High</option>
|
||||||
|
<option value="zone">By Zone</option>
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -101,18 +101,57 @@ export const ITEM_GROUPS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store zones for Costco layout
|
// Store zones - path-oriented physical shopping areas
|
||||||
export const ZONES = [
|
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||||
"Front Entry",
|
export const ZONES = {
|
||||||
"Fresh Foods Right",
|
ENTRANCE: "Entrance & Seasonal",
|
||||||
"Fresh Foods Left",
|
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||||
"Center Aisles",
|
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||||
"Bakery",
|
DELI_PREPARED: "Deli & Prepared Foods",
|
||||||
"Meat Department",
|
BAKERY_SECTION: "Bakery",
|
||||||
"Dairy Cooler",
|
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||||
"Freezer Section",
|
FROZEN_FOODS: "Frozen Foods",
|
||||||
"Back Wall",
|
DRY_GOODS_CENTER: "Center Aisles (Dry Goods)",
|
||||||
"Checkout Area",
|
BEVERAGES: "Beverages & Water",
|
||||||
|
SNACKS_CANDY: "Snacks & Candy",
|
||||||
|
HOUSEHOLD_CLEANING: "Household & Cleaning",
|
||||||
|
HEALTH_BEAUTY: "Health & Beauty",
|
||||||
|
CHECKOUT_AREA: "Checkout Area",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default zone mapping for each item type
|
||||||
|
// This determines where items are typically found in the store
|
||||||
|
export const ITEM_TYPE_TO_ZONE = {
|
||||||
|
[ITEM_TYPES.PRODUCE]: ZONES.PRODUCE_SECTION,
|
||||||
|
[ITEM_TYPES.MEAT]: ZONES.MEAT_SEAFOOD,
|
||||||
|
[ITEM_TYPES.DAIRY]: ZONES.DAIRY_SECTION,
|
||||||
|
[ITEM_TYPES.BAKERY]: ZONES.BAKERY_SECTION,
|
||||||
|
[ITEM_TYPES.FROZEN]: ZONES.FROZEN_FOODS,
|
||||||
|
[ITEM_TYPES.PANTRY]: ZONES.DRY_GOODS_CENTER,
|
||||||
|
[ITEM_TYPES.BEVERAGE]: ZONES.BEVERAGES,
|
||||||
|
[ITEM_TYPES.SNACK]: ZONES.SNACKS_CANDY,
|
||||||
|
[ITEM_TYPES.HOUSEHOLD]: ZONES.HOUSEHOLD_CLEANING,
|
||||||
|
[ITEM_TYPES.PERSONAL_CARE]: ZONES.HEALTH_BEAUTY,
|
||||||
|
[ITEM_TYPES.OTHER]: ZONES.DRY_GOODS_CENTER,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimal walking flow through the store
|
||||||
|
// Represents a typical shopping path that minimizes backtracking
|
||||||
|
// Start with perimeter (fresh items), then move to center aisles, end at checkout
|
||||||
|
export const ZONE_FLOW = [
|
||||||
|
ZONES.ENTRANCE,
|
||||||
|
ZONES.PRODUCE_SECTION,
|
||||||
|
ZONES.MEAT_SEAFOOD,
|
||||||
|
ZONES.DELI_PREPARED,
|
||||||
|
ZONES.BAKERY_SECTION,
|
||||||
|
ZONES.DAIRY_SECTION,
|
||||||
|
ZONES.FROZEN_FOODS,
|
||||||
|
ZONES.DRY_GOODS_CENTER,
|
||||||
|
ZONES.BEVERAGES,
|
||||||
|
ZONES.SNACKS_CANDY,
|
||||||
|
ZONES.HOUSEHOLD_CLEANING,
|
||||||
|
ZONES.HEALTH_BEAUTY,
|
||||||
|
ZONES.CHECKOUT_AREA,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper to get display label for item type
|
// Helper to get display label for item type
|
||||||
@ -132,3 +171,11 @@ export const getItemTypeLabel = (type) => {
|
|||||||
};
|
};
|
||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to get all zone values as array (for dropdowns)
|
||||||
|
export const getZoneValues = () => Object.values(ZONES);
|
||||||
|
|
||||||
|
// Helper to get suggested zone for an item type
|
||||||
|
export const getSuggestedZone = (itemType) => {
|
||||||
|
return ITEM_TYPE_TO_ZONE[itemType] || null;
|
||||||
|
};
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export default function GroceryList() {
|
|||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||||
const [sortedItems, setSortedItems] = useState([]);
|
const [sortedItems, setSortedItems] = useState([]);
|
||||||
const [sortMode, setSortMode] = useState("az");
|
const [sortMode, setSortMode] = useState("zone");
|
||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -60,6 +60,29 @@ export default function GroceryList() {
|
|||||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||||
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
||||||
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
||||||
|
if (sortMode === "zone") {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
// Items without classification go to the end
|
||||||
|
if (!a.item_type && b.item_type) return 1;
|
||||||
|
if (a.item_type && !b.item_type) return -1;
|
||||||
|
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
||||||
|
|
||||||
|
// Sort by item_type
|
||||||
|
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||||
|
if (typeCompare !== 0) return typeCompare;
|
||||||
|
|
||||||
|
// Then by item_group
|
||||||
|
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||||
|
if (groupCompare !== 0) return groupCompare;
|
||||||
|
|
||||||
|
// Then by zone
|
||||||
|
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
||||||
|
if (zoneCompare !== 0) return zoneCompare;
|
||||||
|
|
||||||
|
// Finally by name
|
||||||
|
return a.item_name.localeCompare(b.item_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setSortedItems(sorted);
|
setSortedItems(sorted);
|
||||||
}, [items, sortMode]);
|
}, [items, sortMode]);
|
||||||
@ -277,6 +300,19 @@ export default function GroceryList() {
|
|||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Group items by zone for classification view
|
||||||
|
const groupItemsByZone = (items) => {
|
||||||
|
const groups = {};
|
||||||
|
items.forEach(item => {
|
||||||
|
const zone = item.zone || 'unclassified';
|
||||||
|
if (!groups[zone]) {
|
||||||
|
groups[zone] = [];
|
||||||
|
}
|
||||||
|
groups[zone].push(item);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <p>Loading...</p>;
|
if (loading) return <p>Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -295,27 +331,59 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="glist-ul">
|
{sortMode === "zone" ? (
|
||||||
{sortedItems.map((item) => (
|
// Grouped view by zone
|
||||||
<GroceryListItem
|
(() => {
|
||||||
key={item.id}
|
const grouped = groupItemsByZone(sortedItems);
|
||||||
item={item}
|
return Object.keys(grouped).map(zone => (
|
||||||
onClick={(quantity) =>
|
<div key={zone} className="glist-classification-group">
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
<h3 className="glist-classification-header">
|
||||||
}
|
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||||
onImageAdded={
|
</h3>
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
<ul className="glist-ul">
|
||||||
}
|
{grouped[zone].map((item) => (
|
||||||
onLongPress={
|
<GroceryListItem
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
key={item.id}
|
||||||
}
|
item={item}
|
||||||
/>
|
onClick={(quantity) =>
|
||||||
))}
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||||
</ul>
|
}
|
||||||
|
onImageAdded={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
|
}
|
||||||
|
onLongPress={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
// Regular flat list view
|
||||||
|
<ul className="glist-ul">
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<GroceryListItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onClick={(quantity) =>
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
||||||
|
}
|
||||||
|
onImageAdded={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
|
}
|
||||||
|
onLongPress={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{recentlyBoughtItems.length > 0 && (
|
{recentlyBoughtItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<h2 className="glist-section-title">Recently Bought (Last 24 Hours)</h2>
|
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
||||||
<ul className="glist-ul">
|
<ul className="glist-ul">
|
||||||
{recentlyBoughtItems.map((item) => (
|
{recentlyBoughtItems.map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
|
|||||||
@ -31,6 +31,22 @@
|
|||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Classification Groups */
|
||||||
|
.glist-classification-group {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glist-classification-header {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #007bff;
|
||||||
|
margin: 1em 0 0.5em 0;
|
||||||
|
padding: 0.5em 0.8em;
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
.glist-input {
|
.glist-input {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user