Add store location map manager #20
@ -44,6 +44,7 @@ export default function LocationMapBottomSheet({
|
||||
onDeleteObject,
|
||||
onShowHiddenAreas,
|
||||
onSelectZoneItem,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
|
||||
const selectedZoneId = objectZoneId(selectedObject);
|
||||
@ -292,6 +293,7 @@ export default function LocationMapBottomSheet({
|
||||
hiddenUnmappedItemCount={hiddenUnmappedItemCount}
|
||||
hasHiddenUnmappedItems={hasHiddenUnmappedItems}
|
||||
onShowUnmappedItems={showUnmappedItems}
|
||||
onSelectUnmappedItem={onSelectUnmappedItem}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
@ -13,6 +13,27 @@ export const MAP_DISPLAY_CONTROLS = [
|
||||
|
||||
const UNMAPPED_PREVIEW_LIMIT = 8;
|
||||
|
||||
function MapItemActionRow({ item, onSelectItem }) {
|
||||
const isBought = Boolean(item.bought);
|
||||
|
||||
return (
|
||||
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-item-button"
|
||||
onClick={() => {
|
||||
if (!isBought) onSelectItem?.(item);
|
||||
}}
|
||||
disabled={isBought}
|
||||
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
|
||||
>
|
||||
<span>{item.item_name}</span>
|
||||
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapLayerPanel({ filters, setFilters }) {
|
||||
return (
|
||||
<div className="location-map-display-panel">
|
||||
@ -139,20 +160,7 @@ export function SelectedZoneItemsPanel({
|
||||
<div className="location-map-zone-items">
|
||||
<ul>
|
||||
{selectedZoneItems.map((item) => (
|
||||
<li key={item.id} className={item.bought ? "is-bought" : ""}>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-zone-item-button"
|
||||
onClick={() => {
|
||||
if (!item.bought) onSelectZoneItem?.(item);
|
||||
}}
|
||||
disabled={Boolean(item.bought)}
|
||||
aria-label={item.bought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
|
||||
>
|
||||
<span>{item.item_name}</span>
|
||||
<small>{item.bought ? "Bought" : `x${item.quantity}`}</small>
|
||||
</button>
|
||||
</li>
|
||||
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectZoneItem} />
|
||||
))}
|
||||
</ul>
|
||||
{hasHiddenSelectedItems ? (
|
||||
@ -174,6 +182,7 @@ export function UnmappedItemsPanel({
|
||||
hiddenUnmappedItemCount,
|
||||
hasHiddenUnmappedItems,
|
||||
onShowUnmappedItems,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
|
||||
return (
|
||||
@ -203,7 +212,7 @@ export function UnmappedItemsPanel({
|
||||
<strong>Unmapped Items</strong>
|
||||
<ul>
|
||||
{unmappedItemPreview.map((item) => (
|
||||
<li key={item.id}>{item.item_name}</li>
|
||||
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectUnmappedItem} />
|
||||
))}
|
||||
{overflowUnmappedItemCount > 0 ? (
|
||||
<li
|
||||
|
||||
@ -119,6 +119,10 @@ export default function LocationMapManager() {
|
||||
() => unmappedItems(mapItems, filters, username),
|
||||
[filters, mapItems, username]
|
||||
);
|
||||
const buyModalItems = useMemo(
|
||||
() => (selectedObject ? selectedZoneItems : visibleUnmappedItems),
|
||||
[selectedObject, selectedZoneItems, visibleUnmappedItems]
|
||||
);
|
||||
const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
|
||||
let nextVisibleAssignedItemCount = 0;
|
||||
let nextUnmappedItemCount = 0;
|
||||
@ -166,17 +170,17 @@ export default function LocationMapManager() {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = selectedZoneItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
|
||||
const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
|
||||
|
||||
try {
|
||||
const currentIndex = selectedZoneItems.findIndex((candidate) => candidate.id === item.id);
|
||||
const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id);
|
||||
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
|
||||
await markBought(activeHousehold.id, locationId, item.item_name, quantity, true);
|
||||
|
||||
if (quantity >= item.quantity) {
|
||||
updateMapItems((currentItems) => currentItems.filter((candidate) => candidate.id !== item.id));
|
||||
setBuyModalItem(getNextMapBuyItem(selectedZoneItems, resolvedIndex, item.id));
|
||||
setBuyModalItem(getNextMapBuyItem(buyModalItems, resolvedIndex, item.id));
|
||||
} else {
|
||||
const response = await getItemByName(activeHousehold.id, locationId, item.item_name);
|
||||
const updatedItem = response.data || {
|
||||
@ -195,7 +199,7 @@ export default function LocationMapManager() {
|
||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||
}
|
||||
}, [activeHousehold?.id, buyModalItem, locationId, selectedZoneItems, toast, updateMapItems]);
|
||||
}, [activeHousehold?.id, buyModalItem, buyModalItems, locationId, toast, updateMapItems]);
|
||||
|
||||
const mapSize = useMemo(
|
||||
() => ({
|
||||
@ -435,6 +439,7 @@ export default function LocationMapManager() {
|
||||
onDeleteObject={requestDeleteObject}
|
||||
onShowHiddenAreas={handleShowHiddenAreas}
|
||||
onSelectZoneItem={setBuyModalItem}
|
||||
onSelectUnmappedItem={setBuyModalItem}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@ -443,7 +448,7 @@ export default function LocationMapManager() {
|
||||
{buyModalItem ? (
|
||||
<ConfirmBuyModal
|
||||
item={buyModalItem}
|
||||
allItems={selectedZoneItems}
|
||||
allItems={buyModalItems}
|
||||
onNavigate={handleMapBuyNavigate}
|
||||
onCancel={handleMapBuyCancel}
|
||||
onConfirm={handleMapBuyConfirm}
|
||||
|
||||
@ -752,12 +752,13 @@
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.location-map-zone-items li {
|
||||
.location-map-zone-items li.location-map-item-row,
|
||||
.location-map-unmapped-list li.location-map-item-row {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-map-zone-item-button {
|
||||
.location-map-item-button {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
@ -774,40 +775,44 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-map-zone-item-button span {
|
||||
.location-map-item-button span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.location-map-zone-item-button:hover,
|
||||
.location-map-zone-item-button:focus-visible {
|
||||
.location-map-item-button:hover,
|
||||
.location-map-item-button:focus-visible {
|
||||
outline: none;
|
||||
background: rgba(96, 165, 250, 0.16);
|
||||
}
|
||||
|
||||
.location-map-zone-items li.is-bought {
|
||||
.location-map-zone-items li.is-bought,
|
||||
.location-map-unmapped-list li.is-bought {
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
border-color: rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.location-map-zone-items li.is-bought .location-map-zone-item-button {
|
||||
.location-map-zone-items li.is-bought .location-map-item-button,
|
||||
.location-map-unmapped-list li.is-bought .location-map-item-button {
|
||||
color: #cbd5e1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.location-map-zone-items li.is-bought .location-map-zone-item-button span {
|
||||
.location-map-zone-items li.is-bought .location-map-item-button span,
|
||||
.location-map-unmapped-list li.is-bought .location-map-item-button span {
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
text-decoration-color: rgba(20, 184, 166, 0.85);
|
||||
}
|
||||
|
||||
.location-map-zone-item-button:disabled {
|
||||
.location-map-item-button:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.location-map-zone-items small {
|
||||
.location-map-zone-items small,
|
||||
.location-map-unmapped-list small {
|
||||
color: #cbd5e1;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
|
||||
@ -2119,7 +2119,7 @@ test("viewer shows a compact overflow cue for long unmapped lists", async ({ pag
|
||||
|
||||
await page.goto("/stores/100/locations/10/map");
|
||||
await page.getByRole("button", { name: "Layers" }).click();
|
||||
const unmappedToggle = page.getByRole("button", { name: "Unmapped" });
|
||||
const unmappedToggle = page.locator(".location-map-display-panel").getByRole("button", { name: "Unmapped" });
|
||||
await expect(unmappedToggle).toHaveAttribute("aria-pressed", "false");
|
||||
await unmappedToggle.click();
|
||||
await expect(unmappedToggle).toHaveAttribute("aria-pressed", "true");
|
||||
@ -2130,6 +2130,84 @@ test("viewer shows a compact overflow cue for long unmapped lists", async ({ pag
|
||||
await expect(page.getByLabel("3 more unmapped items")).toHaveText("+3 more");
|
||||
});
|
||||
|
||||
test("viewer can buy unmapped map items in place", async ({ page }) => {
|
||||
await mockMapShell(page);
|
||||
|
||||
const unmappedMapItems = [
|
||||
{
|
||||
id: 4201,
|
||||
item_name: "loose batteries",
|
||||
quantity: 1,
|
||||
bought: false,
|
||||
zone_id: null,
|
||||
zone: null,
|
||||
added_by_users: ["map-user"],
|
||||
},
|
||||
{
|
||||
id: 4202,
|
||||
item_name: "paper plates",
|
||||
quantity: 2,
|
||||
bought: false,
|
||||
zone_id: null,
|
||||
zone: null,
|
||||
added_by_users: ["map-user"],
|
||||
},
|
||||
];
|
||||
const mapState = {
|
||||
...publishedMapState([], true),
|
||||
items: unmappedMapItems,
|
||||
unmapped_count: unmappedMapItems.length,
|
||||
};
|
||||
const boughtPayloads: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route("**/households/1/locations/10/map", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mapState),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/list/item", async (route) => {
|
||||
boughtPayloads.push(await route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/stores/100/locations/10/map");
|
||||
await page.getByRole("button", { name: "Layers" }).click();
|
||||
await page.getByRole("button", { name: "Unmapped" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Mark loose batteries bought" }).click();
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("loose batteries");
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
|
||||
await expect.poll(() => boughtPayloads.length).toBe(1);
|
||||
expect(boughtPayloads[0]).toMatchObject({
|
||||
item_name: "loose batteries",
|
||||
bought: true,
|
||||
quantity_bought: 1,
|
||||
});
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("paper plates");
|
||||
await expect(page.getByText("loose batteries")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Map summary").locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("1");
|
||||
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
|
||||
await expect.poll(() => boughtPayloads.length).toBe(2);
|
||||
expect(boughtPayloads[1]).toMatchObject({
|
||||
item_name: "paper plates",
|
||||
bought: true,
|
||||
quantity_bought: 2,
|
||||
});
|
||||
await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
|
||||
await expect(page.getByText("paper plates")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Map summary").locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("0");
|
||||
});
|
||||
|
||||
test("viewer hides unrelated unmapped items while a zone is selected", async ({ page }) => {
|
||||
await mockMapShell(page);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user