Merge pull request 'Show store manager counts' (#16) from feature/show-store-manager-counts into feature-custom-store-locations
This commit is contained in:
commit
bafbc0fdac
@ -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]
|
||||
|
||||
48
backend/tests/store.model.test.js
Normal file
48
backend/tests/store.model.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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" }),
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user