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 = [
|
||||
"Front Entry",
|
||||
"Fresh Foods Right",
|
||||
"Fresh Foods Left",
|
||||
"Center Aisles",
|
||||
"Bakery",
|
||||
"Meat Department",
|
||||
"Dairy Cooler",
|
||||
"Freezer Section",
|
||||
"Back Wall",
|
||||
"Checkout Area",
|
||||
// Store zones - path-oriented physical shopping areas
|
||||
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||
const ZONES = {
|
||||
ENTRANCE: "Entrance & Seasonal",
|
||||
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||
DELI_PREPARED: "Deli & Prepared Foods",
|
||||
BAKERY_SECTION: "Bakery",
|
||||
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||
FROZEN_FOODS: "Frozen Foods",
|
||||
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
|
||||
@ -121,13 +161,20 @@ const isValidItemGroup = (type, group) => {
|
||||
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 = {
|
||||
ITEM_TYPES,
|
||||
ITEM_GROUPS,
|
||||
ZONES,
|
||||
ITEM_TYPE_TO_ZONE,
|
||||
ZONE_FLOW,
|
||||
isValidItemType,
|
||||
isValidItemGroup,
|
||||
isValidZone,
|
||||
getSuggestedZone,
|
||||
};
|
||||
|
||||
@ -11,13 +11,17 @@ exports.getUnboughtItems = async () => {
|
||||
ENCODE(gl.item_image, 'base64') as item_image,
|
||||
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,
|
||||
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
|
||||
LEFT JOIN users creator ON gl.added_by = creator.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 item_classification ic ON gl.id = ic.id
|
||||
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`
|
||||
);
|
||||
return result.rows;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
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"
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{ZONES.map((z) => (
|
||||
{getZoneValues().map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
@ -134,7 +134,7 @@ export default function EditItemModal({ item, onSave, onCancel }) {
|
||||
className="edit-modal-select"
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{ZONES.map((z) => (
|
||||
{getZoneValues().map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
|
||||
@ -5,6 +5,7 @@ export default function SortDropdown({ value, onChange }) {
|
||||
<option value="za">Z → A</option>
|
||||
<option value="qty-high">Quantity: High → Low</option>
|
||||
<option value="qty-low">Quantity: Low → High</option>
|
||||
<option value="zone">By Zone</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@ -101,18 +101,57 @@ export const ITEM_GROUPS = {
|
||||
],
|
||||
};
|
||||
|
||||
// Store zones for Costco layout
|
||||
export const ZONES = [
|
||||
"Front Entry",
|
||||
"Fresh Foods Right",
|
||||
"Fresh Foods Left",
|
||||
"Center Aisles",
|
||||
"Bakery",
|
||||
"Meat Department",
|
||||
"Dairy Cooler",
|
||||
"Freezer Section",
|
||||
"Back Wall",
|
||||
"Checkout Area",
|
||||
// Store zones - path-oriented physical shopping areas
|
||||
// Applicable to Costco, 99 Ranch, Stater Bros, and similar large grocery stores
|
||||
export const ZONES = {
|
||||
ENTRANCE: "Entrance & Seasonal",
|
||||
PRODUCE_SECTION: "Produce & Fresh Vegetables",
|
||||
MEAT_SEAFOOD: "Meat & Seafood Counter",
|
||||
DELI_PREPARED: "Deli & Prepared Foods",
|
||||
BAKERY_SECTION: "Bakery",
|
||||
DAIRY_SECTION: "Dairy & Refrigerated",
|
||||
FROZEN_FOODS: "Frozen Foods",
|
||||
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
|
||||
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
|
||||
@ -132,3 +171,11 @@ export const getItemTypeLabel = (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 [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [sortedItems, setSortedItems] = useState([]);
|
||||
const [sortMode, setSortMode] = useState("az");
|
||||
const [sortMode, setSortMode] = useState("zone");
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
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 === "qty-high") sorted.sort((a, b) => b.quantity - a.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);
|
||||
}, [items, sortMode]);
|
||||
@ -277,6 +300,19 @@ export default function GroceryList() {
|
||||
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>;
|
||||
|
||||
return (
|
||||
@ -295,6 +331,37 @@ export default function GroceryList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{sortMode === "zone" ? (
|
||||
// Grouped view by zone
|
||||
(() => {
|
||||
const grouped = groupItemsByZone(sortedItems);
|
||||
return Object.keys(grouped).map(zone => (
|
||||
<div key={zone} className="glist-classification-group">
|
||||
<h3 className="glist-classification-header">
|
||||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||||
</h3>
|
||||
<ul className="glist-ul">
|
||||
{grouped[zone].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>
|
||||
</div>
|
||||
));
|
||||
})()
|
||||
) : (
|
||||
// Regular flat list view
|
||||
<ul className="glist-ul">
|
||||
{sortedItems.map((item) => (
|
||||
<GroceryListItem
|
||||
@ -312,10 +379,11 @@ export default function GroceryList() {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{recentlyBoughtItems.map((item) => (
|
||||
<GroceryListItem
|
||||
|
||||
@ -31,6 +31,22 @@
|
||||
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 */
|
||||
.glist-input {
|
||||
font-size: 1em;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user