Show store manager counts #16
@ -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]
|
||||||
|
|||||||
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}
|
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>
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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" }),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user