Show store manager counts #16

Merged
nalalangan merged 1 commits from feature/show-store-manager-counts into feature-custom-store-locations 2026-05-31 19:22:54 -09:00
7 changed files with 129 additions and 12 deletions
Showing only changes of commit 346fb917b7 - Show all commits

View File

@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
sl.address, sl.address,
sl.is_default, sl.is_default,
sl.map_data, 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.created_at,
sl.updated_at, sl.updated_at,
CASE CASE
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
END AS display_name END AS display_name
FROM store_locations sl FROM store_locations sl
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id 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 WHERE sl.household_id = $1
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`, ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
[householdId, DEFAULT_LOCATION_NAME] [householdId, DEFAULT_LOCATION_NAME]

View File

@ -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");
});
});

View File

@ -145,12 +145,16 @@ export default function ManageStores() {
location={location} location={location}
canManage={isAdmin} canManage={isAdmin}
refreshActiveZones={refreshZones} refreshActiveZones={refreshZones}
refreshStoreCounts={refreshStores}
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
/> />
<StoreAvailableItemsManager <StoreAvailableItemsManager
householdId={activeHousehold.id} householdId={activeHousehold.id}
store={location} store={location}
isAdmin={isAdmin} isAdmin={isAdmin}
refreshStoreCounts={refreshStores}
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
/> />
</div> </div>
</div> </div>

View File

@ -20,10 +20,17 @@ function itemImageSource(item) {
return `data:${mimeType};base64,${item.item_image}`; 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 toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
const [zones, setZones] = useState([]); const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true); const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState(""); const [catalogMessage, setCatalogMessage] = useState("");
@ -85,6 +92,10 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
loadZones(); loadZones();
}, [isOpen, query, loadItems, loadZones]); }, [isOpen, query, loadItems, loadZones]);
useEffect(() => {
setDisplayItemCount(itemCount);
}, [itemCount]);
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
setDeleteMode(false); setDeleteMode(false);
@ -115,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item"); const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`); toast.error("Update store item failed", `Update store item failed: ${message}`);
@ -177,6 +189,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
setDeleteMode(false); setDeleteMode(false);
setSelectedDeleteIds(new Set()); setSelectedDeleteIds(new Set());
await loadItems(query); await loadItems(query);
await refreshStoreCounts?.();
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item"); const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store items failed", `Delete store items failed: ${message}`); 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" className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Manage Items Manage Items ({displayItemCount})
</button> </button>
{isOpen ? ( {isOpen ? (

View File

@ -90,6 +90,7 @@ export default function StoreLocationManager({
refreshAfterStoreChange, refreshAfterStoreChange,
}) { }) {
const toast = useActionToast(); const toast = useActionToast();
const locationCount = storeGroup.locations.length;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [locationDraft, setLocationDraft] = useState({ name: "", address: "" }); const [locationDraft, setLocationDraft] = useState({ name: "", address: "" });
const [deleteMode, setDeleteMode] = useState(false); const [deleteMode, setDeleteMode] = useState(false);
@ -230,7 +231,7 @@ export default function StoreLocationManager({
className="btn-secondary btn-small store-location-manager-trigger" className="btn-secondary btn-small store-location-manager-trigger"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Manage Locations Manage Locations ({locationCount})
</button> </button>
{isOpen ? ( {isOpen ? (

View File

@ -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 toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [zones, setZones] = useState([]); const [zones, setZones] = useState([]);
const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState(""); const [newZoneName, setNewZoneName] = useState("");
const [deleteMode, setDeleteMode] = useState(false); const [deleteMode, setDeleteMode] = useState(false);
@ -103,7 +111,9 @@ export default function StoreZoneManager({ householdId, location, canManage, ref
setLoading(true); setLoading(true);
try { try {
const response = await getLocationZones(householdId, location.id); const response = await getLocationZones(householdId, location.id);
setZones(response.data?.zones || []); const nextZones = response.data?.zones || [];
setZones(nextZones);
setDisplayZoneCount(nextZones.length);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to load zones"); const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`); 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]); }, [isOpen, householdId, location?.id]);
useEffect(() => {
setDisplayZoneCount(zoneCount);
}, [zoneCount]);
const handleCreateZone = async () => { const handleCreateZone = async () => {
const name = newZoneName.trim(); const name = newZoneName.trim();
if (!name) return; if (!name) return;
@ -172,6 +186,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref
setNewZoneName(""); setNewZoneName("");
await loadZones(); await loadZones();
await refreshActiveZones(); await refreshActiveZones();
await refreshStoreCounts?.();
toast.success("Added zone", `Added zone ${name}`); toast.success("Added zone", `Added zone ${name}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to add zone"); const message = getApiErrorMessage(error, "Failed to add zone");
@ -233,6 +248,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref
const count = pendingDeleteZones.length; const count = pendingDeleteZones.length;
await loadZones(); await loadZones();
await refreshActiveZones(); await refreshActiveZones();
await refreshStoreCounts?.();
setPendingDeleteZones([]); setPendingDeleteZones([]);
setDeleteMode(false); setDeleteMode(false);
setSelectedDeleteIds(new Set()); setSelectedDeleteIds(new Set());
@ -254,7 +270,7 @@ export default function StoreZoneManager({ householdId, location, canManage, ref
className="btn-secondary btn-small store-zone-manager-trigger" className="btn-secondary btn-small store-zone-manager-trigger"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Manage Zones Manage Zones ({displayZoneCount})
</button> </button>
{isOpen ? ( {isOpen ? (

View File

@ -40,7 +40,16 @@ test("manage stores opens a modal to edit and delete household store items", asy
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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("Costco", { exact: true })).toHaveCount(1);
await expect(storeCard.getByText("Default location")).toBeVisible(); await expect(storeCard.getByText("Default location")).toBeVisible();
await expect(storeCard.getByText("Default shopping location")).toHaveCount(0); 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"); const managerModal = page.locator(".store-items-modal");
await expect(managerModal).toBeVisible(); await expect(managerModal).toBeVisible();
@ -198,6 +207,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
display_name: "Costco", display_name: "Costco",
address: "", address: "",
is_default: true, is_default: true,
zone_count: 2,
item_count: 3,
}, },
{ {
id: 11, id: 11,
@ -207,6 +218,8 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
display_name: "Costco - Fontana", display_name: "Costco - Fontana",
address: "Sierra Ave", address: "Sierra Ave",
is_default: false, is_default: false,
zone_count: 0,
item_count: 0,
}, },
]; ];
let zones = [ let zones = [
@ -218,7 +231,11 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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}`, display_name: `Costco - ${locationName}`,
address: body.address || "", address: body.address || "",
is_default: false, is_default: false,
zone_count: 0,
item_count: 0,
}, },
]; ];
await route.fulfill({ 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.getByRole("button", { name: "Remove" })).toHaveCount(0);
await expect(storeCard.getByPlaceholder("Location name")).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({ const locationsModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Locations" }), 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"); ).toContainText("Removed location");
await page.getByLabel("Close manage locations modal").click(); 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({ const zonesModal = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Costco Zones" }), has: page.getByRole("heading", { name: "Costco Zones" }),
}); });