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.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]

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}
canManage={isAdmin}
refreshActiveZones={refreshZones}
refreshStoreCounts={refreshStores}
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
/>
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={location}
isAdmin={isAdmin}
refreshStoreCounts={refreshStores}
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
/>
</div>
</div>

View File

@ -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})
</button>
{isOpen ? (

View File

@ -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})
</button>
{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 [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})
</button>
{isOpen ? (

View File

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