From 346fb917b7c324088d82a2cc6607d4b999412dab Mon Sep 17 00:00:00 2001 From: Nico Date: Sun, 31 May 2026 21:21:12 -0700 Subject: [PATCH] feat: show store manager counts --- backend/models/store.model.js | 15 ++++++ backend/tests/store.model.test.js | 48 +++++++++++++++++++ .../src/components/manage/ManageStores.jsx | 4 ++ .../manage/StoreAvailableItemsManager.jsx | 17 ++++++- .../manage/StoreLocationManager.jsx | 3 +- .../components/manage/StoreZoneManager.jsx | 22 +++++++-- .../tests/available-items-catalog.spec.ts | 32 ++++++++++--- 7 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 backend/tests/store.model.test.js diff --git a/backend/models/store.model.js b/backend/models/store.model.js index 64bdb68..60620c5 100644 --- a/backend/models/store.model.js +++ b/backend/models/store.model.js @@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => { sl.address, sl.is_default, sl.map_data, + COALESCE(zone_counts.zone_count, 0)::int AS zone_count, + COALESCE(item_counts.item_count, 0)::int AS item_count, sl.created_at, sl.updated_at, CASE @@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => { END AS display_name FROM store_locations sl JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS zone_count + FROM store_location_zones slz + WHERE slz.household_id = sl.household_id + AND slz.store_location_id = sl.id + AND slz.is_active = TRUE + ) zone_counts ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS item_count + FROM household_store_items hsi + WHERE hsi.household_id = sl.household_id + AND hsi.store_location_id = sl.id + ) item_counts ON TRUE WHERE sl.household_id = $1 ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`, [householdId, DEFAULT_LOCATION_NAME] diff --git a/backend/tests/store.model.test.js b/backend/tests/store.model.test.js new file mode 100644 index 0000000..9ee5ef6 --- /dev/null +++ b/backend/tests/store.model.test.js @@ -0,0 +1,48 @@ +jest.mock("../db/pool", () => ({ + query: jest.fn(), +})); + +const pool = require("../db/pool"); +const Stores = require("../models/store.model"); + +describe("store.model", () => { + beforeEach(() => { + pool.query.mockReset(); + }); + + test("lists household stores with location manager counts", async () => { + pool.query.mockResolvedValueOnce({ + rows: [ + { + location_id: 10, + id: 10, + household_id: 1, + household_store_id: 100, + name: "Costco", + location_name: "Default Location", + is_default: true, + zone_count: 2, + item_count: 5, + display_name: "Costco", + }, + ], + }); + + const result = await Stores.getHouseholdStores(1); + + expect(result).toEqual([ + expect.objectContaining({ + id: 10, + display_name: "Costco", + zone_count: 2, + item_count: 5, + }), + ]); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("AS zone_count"), + [1, "Default Location"] + ); + expect(pool.query.mock.calls[0][0]).toContain("FROM store_location_zones slz"); + expect(pool.query.mock.calls[0][0]).toContain("FROM household_store_items hsi"); + }); +}); diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx index bf278b4..6dcab9b 100644 --- a/frontend/src/components/manage/ManageStores.jsx +++ b/frontend/src/components/manage/ManageStores.jsx @@ -145,12 +145,16 @@ export default function ManageStores() { location={location} canManage={isAdmin} refreshActiveZones={refreshZones} + refreshStoreCounts={refreshStores} + zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)} /> diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx index 2661193..4b9c797 100644 --- a/frontend/src/components/manage/StoreAvailableItemsManager.jsx +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -20,10 +20,17 @@ function itemImageSource(item) { return `data:${mimeType};base64,${item.item_image}`; } -export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) { +export default function StoreAvailableItemsManager({ + householdId, + store, + isAdmin, + refreshStoreCounts, + itemCount = 0, +}) { const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); const [items, setItems] = useState([]); + const [displayItemCount, setDisplayItemCount] = useState(itemCount); const [zones, setZones] = useState([]); const [catalogReady, setCatalogReady] = useState(true); const [catalogMessage, setCatalogMessage] = useState(""); @@ -85,6 +92,10 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin loadZones(); }, [isOpen, query, loadItems, loadZones]); + useEffect(() => { + setDisplayItemCount(itemCount); + }, [itemCount]); + const closeManager = () => { setIsOpen(false); setDeleteMode(false); @@ -115,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin setShowEditor(false); setEditorItem(null); await loadItems(query); + await refreshStoreCounts?.(); } catch (error) { const message = getApiErrorMessage(error, "Failed to update store item"); toast.error("Update store item failed", `Update store item failed: ${message}`); @@ -177,6 +189,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin setDeleteMode(false); setSelectedDeleteIds(new Set()); await loadItems(query); + await refreshStoreCounts?.(); } catch (error) { const message = getApiErrorMessage(error, "Failed to delete store item"); toast.error("Delete store items failed", `Delete store items failed: ${message}`); @@ -190,7 +203,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin className="btn-secondary btn-small store-available-items-trigger" onClick={() => setIsOpen(true)} > - Manage Items + Manage Items ({displayItemCount}) {isOpen ? ( diff --git a/frontend/src/components/manage/StoreLocationManager.jsx b/frontend/src/components/manage/StoreLocationManager.jsx index ea765d8..cbb41ae 100644 --- a/frontend/src/components/manage/StoreLocationManager.jsx +++ b/frontend/src/components/manage/StoreLocationManager.jsx @@ -90,6 +90,7 @@ export default function StoreLocationManager({ refreshAfterStoreChange, }) { const toast = useActionToast(); + const locationCount = storeGroup.locations.length; const [isOpen, setIsOpen] = useState(false); const [locationDraft, setLocationDraft] = useState({ name: "", address: "" }); const [deleteMode, setDeleteMode] = useState(false); @@ -230,7 +231,7 @@ export default function StoreLocationManager({ className="btn-secondary btn-small store-location-manager-trigger" onClick={() => setIsOpen(true)} > - Manage Locations + Manage Locations ({locationCount}) {isOpen ? ( diff --git a/frontend/src/components/manage/StoreZoneManager.jsx b/frontend/src/components/manage/StoreZoneManager.jsx index 32f4a57..71e274a 100644 --- a/frontend/src/components/manage/StoreZoneManager.jsx +++ b/frontend/src/components/manage/StoreZoneManager.jsx @@ -83,10 +83,18 @@ function ZoneSettingsModal({ ); } -export default function StoreZoneManager({ householdId, location, canManage, refreshActiveZones }) { +export default function StoreZoneManager({ + householdId, + location, + canManage, + refreshActiveZones, + refreshStoreCounts, + zoneCount = 0, +}) { const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); const [zones, setZones] = useState([]); + const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount); const [loading, setLoading] = useState(false); const [newZoneName, setNewZoneName] = useState(""); const [deleteMode, setDeleteMode] = useState(false); @@ -103,7 +111,9 @@ export default function StoreZoneManager({ householdId, location, canManage, ref setLoading(true); try { const response = await getLocationZones(householdId, location.id); - setZones(response.data?.zones || []); + const nextZones = response.data?.zones || []; + setZones(nextZones); + setDisplayZoneCount(nextZones.length); } catch (error) { const message = getApiErrorMessage(error, "Failed to load zones"); toast.error("Load zones failed", `Load zones failed: ${message}`); @@ -158,6 +168,10 @@ export default function StoreZoneManager({ householdId, location, canManage, ref } }, [isOpen, householdId, location?.id]); + useEffect(() => { + setDisplayZoneCount(zoneCount); + }, [zoneCount]); + const handleCreateZone = async () => { const name = newZoneName.trim(); if (!name) return; @@ -172,6 +186,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref setNewZoneName(""); await loadZones(); await refreshActiveZones(); + await refreshStoreCounts?.(); toast.success("Added zone", `Added zone ${name}`); } catch (error) { const message = getApiErrorMessage(error, "Failed to add zone"); @@ -233,6 +248,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref const count = pendingDeleteZones.length; await loadZones(); await refreshActiveZones(); + await refreshStoreCounts?.(); setPendingDeleteZones([]); setDeleteMode(false); setSelectedDeleteIds(new Set()); @@ -254,7 +270,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref className="btn-secondary btn-small store-zone-manager-trigger" onClick={() => setIsOpen(true)} > - Manage Zones + Manage Zones ({displayZoneCount}) {isOpen ? ( diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts index ad5b460..729bed2 100644 --- a/frontend/tests/available-items-catalog.spec.ts +++ b/frontend/tests/available-items-catalog.spec.ts @@ -40,7 +40,16 @@ test("manage stores opens a modal to edit and delete household store items", asy await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify([{ id: 10, household_store_id: 100, name: "Costco", is_default: true }]), + body: JSON.stringify([ + { + id: 10, + household_store_id: 100, + name: "Costco", + is_default: true, + zone_count: 0, + item_count: availableItems.length, + }, + ]), }); }); @@ -115,9 +124,9 @@ test("manage stores opens a modal to edit and delete household store items", asy await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1); await expect(storeCard.getByText("Default location")).toBeVisible(); await expect(storeCard.getByText("Default shopping location")).toHaveCount(0); - await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible(); + await expect(storeCard.getByRole("button", { name: "Manage Items (2)" })).toBeVisible(); - await storeCard.getByRole("button", { name: "Manage Items" }).click(); + await storeCard.getByRole("button", { name: "Manage Items (2)" }).click(); const managerModal = page.locator(".store-items-modal"); await expect(managerModal).toBeVisible(); @@ -198,6 +207,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) display_name: "Costco", address: "", is_default: true, + zone_count: 2, + item_count: 3, }, { id: 11, @@ -207,6 +218,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) display_name: "Costco - Fontana", address: "Sierra Ave", is_default: false, + zone_count: 0, + item_count: 0, }, ]; let zones = [ @@ -218,7 +231,11 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(locations), + body: JSON.stringify( + locations.map((location) => + location.id === 10 ? { ...location, zone_count: zones.length } : location + ) + ), }); }); @@ -237,6 +254,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) display_name: `Costco - ${locationName}`, address: body.address || "", is_default: false, + zone_count: 0, + item_count: 0, }, ]; await route.fulfill({ @@ -368,7 +387,7 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) await expect(storeCard.getByRole("button", { name: "Remove" })).toHaveCount(0); await expect(storeCard.getByPlaceholder("Location name")).toHaveCount(0); - await storeCard.getByRole("button", { name: "Manage Locations" }).click(); + await storeCard.getByRole("button", { name: "Manage Locations (2)" }).click(); const locationsModal = page.locator(".store-items-modal").filter({ has: page.getByRole("heading", { name: "Costco Locations" }), }); @@ -399,7 +418,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page }) ).toContainText("Removed location"); await page.getByLabel("Close manage locations modal").click(); - await storeCard.getByRole("button", { name: "Manage Zones" }).click(); + await expect(storeCard.getByRole("button", { name: "Manage Locations (1)" })).toBeVisible(); + await storeCard.getByRole("button", { name: "Manage Zones (2)" }).click(); const zonesModal = page.locator(".store-items-modal").filter({ has: page.getByRole("heading", { name: "Costco Zones" }), }); -- 2.39.5