Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
5 changed files with 130 additions and 31 deletions
Showing only changes of commit 1658b7f979 - Show all commits

View File

@ -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>

View File

@ -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

View File

@ -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}

View File

@ -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;

View File

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