diff --git a/docs/guides/management-modal-patterns.md b/docs/guides/management-modal-patterns.md
index 640686d..d69a900 100644
--- a/docs/guides/management-modal-patterns.md
+++ b/docs/guides/management-modal-patterns.md
@@ -1,6 +1,11 @@
# Management Modal Patterns
-Use this guide for modals that manage scoped lists of app-owned records, such as store items now and store zones or locations later.
+Use this guide for modals that manage scoped lists of app-owned records, such as store items, store zones, and store locations.
+
+Current adopters:
+- Store item catalog management.
+- Store location management.
+- Store location zone management.
## Purpose
- Management modals should keep users in the current workflow while they inspect, edit, add, or remove records for a scoped parent.
diff --git a/frontend/src/components/manage/ManageStores.jsx b/frontend/src/components/manage/ManageStores.jsx
index 1da5467..7394a37 100644
--- a/frontend/src/components/manage/ManageStores.jsx
+++ b/frontend/src/components/manage/ManageStores.jsx
@@ -1,21 +1,14 @@
-import { useContext, useEffect, useMemo, useState } from "react";
-import {
- addLocationToStore,
- createHouseholdStore,
- createLocationZone,
- deleteLocationZone,
- getLocationZones,
- removeLocation,
- setDefaultLocation,
- updateLocationZone,
-} from "../../api/stores";
-import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
+import { useContext, useMemo, useState } from "react";
+import { createHouseholdStore } from "../../api/stores";
import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
-import "../../styles/components/manage/ManageStores.css";
+import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
+import StoreLocationManager from "./StoreLocationManager";
+import StoreZoneManager from "./StoreZoneManager";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
+import "../../styles/components/manage/ManageStores.css";
function groupLocationsByStore(locations) {
const grouped = new Map();
@@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
-function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
- const toast = useActionToast();
- const [isOpen, setIsOpen] = useState(false);
- const [zones, setZones] = useState([]);
- const [loading, setLoading] = useState(false);
- const [newZoneName, setNewZoneName] = useState("");
-
- const loadZones = async () => {
- if (!householdId || !location?.id) return;
- setLoading(true);
- try {
- const response = await getLocationZones(householdId, location.id);
- setZones(response.data?.zones || []);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to load zones");
- toast.error("Load zones failed", `Load zones failed: ${message}`);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- if (isOpen) {
- loadZones();
- }
- }, [isOpen, householdId, location?.id]);
-
- const handleCreateZone = async () => {
- const name = newZoneName.trim();
- if (!name) return;
-
- try {
- const nextSortOrder =
- zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
- await createLocationZone(householdId, location.id, {
- name,
- sort_order: nextSortOrder,
- });
- setNewZoneName("");
- await loadZones();
- await refreshActiveZones();
- toast.success("Added zone", `Added zone ${name}`);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to add zone");
- toast.error("Add zone failed", `Add zone failed: ${message}`);
- }
- };
-
- const handleMoveZone = async (zone, direction) => {
- const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
- const swapIndex = currentIndex + direction;
- if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
-
- const other = zones[swapIndex];
- try {
- await Promise.all([
- updateLocationZone(householdId, location.id, zone.id, {
- sort_order: other.sort_order,
- }),
- updateLocationZone(householdId, location.id, other.id, {
- sort_order: zone.sort_order,
- }),
- ]);
- await loadZones();
- await refreshActiveZones();
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to reorder zones");
- toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
- }
- };
-
- const handleDeleteZone = async (zone) => {
- if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
- return;
- }
-
- try {
- await deleteLocationZone(householdId, location.id, zone.id);
- await loadZones();
- await refreshActiveZones();
- toast.success("Removed zone", `Removed zone ${zone.name}`);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to remove zone");
- toast.error("Remove zone failed", `Remove zone failed: ${message}`);
- }
- };
-
- return (
-
-
-
- {isOpen ? (
-
- {canManage ? (
-
- setNewZoneName(event.target.value)}
- placeholder="New zone name"
- />
-
-
- ) : null}
-
- {loading ? (
-
Loading zones...
- ) : zones.length === 0 ? (
-
No zones for this location.
- ) : (
-
- {zones.map((zone, index) => (
-
-
{index + 1}
-
{zone.name}
- {canManage ? (
-
-
-
-
-
- ) : null}
-
- ))}
-
- )}
-
- ) : null}
-
- );
+function locationLabel(location) {
+ return location.display_name || location.name;
}
export default function ManageStores() {
@@ -209,7 +47,6 @@ export default function ManageStores() {
location_name: "",
address: "",
});
- const [locationDrafts, setLocationDrafts] = useState({});
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
@@ -245,53 +82,6 @@ export default function ManageStores() {
}
};
- const handleAddLocation = async (householdStoreId, storeName) => {
- const draft = locationDrafts[householdStoreId] || {};
- const name = String(draft.name || "").trim();
- if (!name) return;
-
- try {
- await addLocationToStore(activeHousehold.id, householdStoreId, {
- name,
- address: String(draft.address || "").trim() || null,
- });
- setLocationDrafts((current) => ({
- ...current,
- [householdStoreId]: { name: "", address: "" },
- }));
- await refreshAfterStoreChange();
- toast.success("Added location", `Added ${name} to ${storeName}`);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to add location");
- toast.error("Add location failed", `Add location failed: ${message}`);
- }
- };
-
- const handleSetDefault = async (location) => {
- try {
- await setDefaultLocation(activeHousehold.id, location.id);
- await refreshAfterStoreChange();
- toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to set default location");
- toast.error("Set default failed", `Set default failed: ${message}`);
- }
- };
-
- const handleRemoveLocation = async (location) => {
- const label = location.display_name || location.name;
- if (!confirm(`Remove ${label} from this household?`)) return;
-
- try {
- await removeLocation(activeHousehold.id, location.id);
- await refreshAfterStoreChange();
- toast.success("Removed location", `Removed ${label}`);
- } catch (error) {
- const message = getApiErrorMessage(error, "Failed to remove location");
- toast.error("Remove location failed", `Remove location failed: ${message}`);
- }
- };
-
return (
@@ -310,11 +100,19 @@ export default function ManageStores() {
{storeGroup.name}
+
+
{storeGroup.locations.map((location) => (
-
{location.display_name || location.name}
+
{locationLabel(location)}
{location.address ? (
{location.address}
) : null}
@@ -323,30 +121,7 @@ export default function ManageStores() {
) : null}
-
- {isAdmin && !location.is_default ? (
-
- ) : null}
- {isAdmin ? (
-
- ) : null}
-
-
-
))}
-
- {isAdmin ? (
-
-
- setLocationDrafts((current) => ({
- ...current,
- [storeGroup.household_store_id]: {
- ...(current[storeGroup.household_store_id] || {}),
- name: event.target.value,
- },
- }))
- }
- placeholder="Location name"
- />
-
- setLocationDrafts((current) => ({
- ...current,
- [storeGroup.household_store_id]: {
- ...(current[storeGroup.household_store_id] || {}),
- address: event.target.value,
- },
- }))
- }
- placeholder="Address or notes"
- />
-
-
- ) : null}
))}
diff --git a/frontend/src/components/manage/StoreLocationManager.jsx b/frontend/src/components/manage/StoreLocationManager.jsx
new file mode 100644
index 0000000..ea765d8
--- /dev/null
+++ b/frontend/src/components/manage/StoreLocationManager.jsx
@@ -0,0 +1,380 @@
+import { useState } from "react";
+import {
+ addLocationToStore,
+ removeLocation,
+ setDefaultLocation,
+ updateLocation,
+} from "../../api/stores";
+import useActionToast from "../../hooks/useActionToast";
+import getApiErrorMessage from "../../lib/getApiErrorMessage";
+import ConfirmSlideModal from "../modals/ConfirmSlideModal";
+
+function locationLabel(location) {
+ return location.display_name || location.name;
+}
+
+function locationEditName(location) {
+ return location.location_name || location.name || "";
+}
+
+function LocationSettingsModal({
+ location,
+ draft,
+ setDraft,
+ canManage,
+ onCancel,
+ onSave,
+ onSetDefault,
+}) {
+ if (!location) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
+
{locationLabel(location)} Settings
+
Update this location name, notes, or default status.
+
+
+
+
+
+
+
+ );
+}
+
+export default function StoreLocationManager({
+ householdId,
+ storeGroup,
+ allLocationCount,
+ canManage,
+ refreshAfterStoreChange,
+}) {
+ const toast = useActionToast();
+ const [isOpen, setIsOpen] = useState(false);
+ const [locationDraft, setLocationDraft] = useState({ name: "", address: "" });
+ const [deleteMode, setDeleteMode] = useState(false);
+ const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
+ const [pendingDeleteLocations, setPendingDeleteLocations] = useState([]);
+ const [editingLocation, setEditingLocation] = useState(null);
+ const [editingLocationDraft, setEditingLocationDraft] = useState({ name: "", address: "" });
+
+ const selectedDeleteLocations = storeGroup.locations.filter((location) =>
+ selectedDeleteIds.has(location.id)
+ );
+ const selectedDeleteCount = selectedDeleteLocations.length;
+ const canConfirmDelete = selectedDeleteCount > 0 && allLocationCount - selectedDeleteCount >= 1;
+
+ const closeManager = () => {
+ setIsOpen(false);
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ setPendingDeleteLocations([]);
+ setEditingLocation(null);
+ };
+
+ const handleAddLocation = async () => {
+ const name = locationDraft.name.trim();
+ if (!name) return;
+
+ try {
+ await addLocationToStore(householdId, storeGroup.household_store_id, {
+ name,
+ address: locationDraft.address.trim() || null,
+ });
+ setLocationDraft({ name: "", address: "" });
+ await refreshAfterStoreChange();
+ toast.success("Added location", `Added ${name} to ${storeGroup.name}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to add location");
+ toast.error("Add location failed", `Add location failed: ${message}`);
+ }
+ };
+
+ const openLocationSettings = (location) => {
+ setEditingLocation(location);
+ setEditingLocationDraft({
+ name: locationEditName(location),
+ address: location.address || "",
+ });
+ };
+
+ const handleSaveLocation = async () => {
+ if (!editingLocation) return;
+
+ const name = editingLocationDraft.name.trim();
+ if (!name) return;
+
+ try {
+ await updateLocation(householdId, editingLocation.id, {
+ name,
+ address: editingLocationDraft.address.trim() || null,
+ });
+ await refreshAfterStoreChange();
+ setEditingLocation(null);
+ toast.success("Updated location", `Updated ${name}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to update location");
+ toast.error("Update location failed", `Update location failed: ${message}`);
+ }
+ };
+
+ const handleSetDefault = async () => {
+ if (!editingLocation) return;
+
+ try {
+ await setDefaultLocation(householdId, editingLocation.id);
+ await refreshAfterStoreChange();
+ setEditingLocation(null);
+ toast.success("Updated default location", `Default location set to ${locationLabel(editingLocation)}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to set default location");
+ toast.error("Set default failed", `Set default failed: ${message}`);
+ }
+ };
+
+ const toggleLocationSelection = (locationId) => {
+ setSelectedDeleteIds((currentIds) => {
+ const nextIds = new Set(currentIds);
+ if (nextIds.has(locationId)) {
+ nextIds.delete(locationId);
+ } else {
+ nextIds.add(locationId);
+ }
+ return nextIds;
+ });
+ };
+
+ const startDeleteMode = () => {
+ setDeleteMode(true);
+ setSelectedDeleteIds(new Set());
+ };
+
+ const cancelDeleteMode = () => {
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ };
+
+ const confirmSelectedDelete = () => {
+ if (!canConfirmDelete) return;
+ setPendingDeleteLocations(selectedDeleteLocations);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (pendingDeleteLocations.length === 0) {
+ return;
+ }
+
+ try {
+ await Promise.all(
+ pendingDeleteLocations.map((location) => removeLocation(householdId, location.id))
+ );
+ const count = pendingDeleteLocations.length;
+ await refreshAfterStoreChange();
+ setPendingDeleteLocations([]);
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ toast.success(
+ count === 1 ? "Removed location" : "Removed locations",
+ `Removed ${count} ${count === 1 ? "location" : "locations"} from ${storeGroup.name}`
+ );
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to remove locations");
+ toast.error("Remove locations failed", `Remove locations failed: ${message}`);
+ }
+ };
+
+ return (
+ <>
+
+
+ {isOpen ? (
+
+
event.stopPropagation()}>
+
+
+
{storeGroup.name} Locations
+
Manage locations, defaults, and location notes for this store.
+
+
+
+
+ {canManage ? (
+
+
+ setLocationDraft((current) => ({ ...current, name: event.target.value }))
+ }
+ placeholder="Location name"
+ />
+
+ setLocationDraft((current) => ({ ...current, address: event.target.value }))
+ }
+ placeholder="Address or notes"
+ />
+
+
+ ) : null}
+
+ {canManage && storeGroup.locations.length > 0 ? (
+
+
+ {deleteMode ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+ {storeGroup.locations.map((location) => {
+ const isSelectedForDelete = selectedDeleteIds.has(location.id);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+ ) : null}
+
+ setEditingLocation(null)}
+ onSave={handleSaveLocation}
+ onSetDefault={handleSetDefault}
+ />
+
+ 0}
+ title={
+ pendingDeleteLocations.length === 1
+ ? `Delete ${locationLabel(pendingDeleteLocations[0])}?`
+ : `Delete ${pendingDeleteLocations.length} locations?`
+ }
+ description={
+ pendingDeleteLocations.length > 0
+ ? `Slide to confirm. This removes ${pendingDeleteLocations.length === 1 ? locationLabel(pendingDeleteLocations[0]) : `${pendingDeleteLocations.length} locations`} from this household.`
+ : ""
+ }
+ confirmLabel={pendingDeleteLocations.length === 1 ? "Delete Location" : "Delete Locations"}
+ onClose={() => setPendingDeleteLocations([])}
+ onConfirm={handleDeleteConfirm}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/manage/StoreZoneManager.jsx b/frontend/src/components/manage/StoreZoneManager.jsx
new file mode 100644
index 0000000..32f4a57
--- /dev/null
+++ b/frontend/src/components/manage/StoreZoneManager.jsx
@@ -0,0 +1,391 @@
+import { useEffect, useState } from "react";
+import {
+ createLocationZone,
+ deleteLocationZone,
+ getLocationZones,
+ updateLocationZone,
+} from "../../api/stores";
+import useActionToast from "../../hooks/useActionToast";
+import getApiErrorMessage from "../../lib/getApiErrorMessage";
+import ConfirmSlideModal from "../modals/ConfirmSlideModal";
+
+function locationLabel(location) {
+ return location.display_name || location.name;
+}
+
+function ZoneSettingsModal({
+ zone,
+ draft,
+ setDraft,
+ canManage,
+ canMoveUp,
+ canMoveDown,
+ onCancel,
+ onSave,
+ onMove,
+}) {
+ if (!zone) return null;
+
+ return (
+
+
event.stopPropagation()}>
+
+
+
{zone.name} Settings
+
Update this zone name or adjust its shopping order.
+
+
+
+
+
+
+
+ {canManage ? (
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
+
+export default function StoreZoneManager({ householdId, location, canManage, refreshActiveZones }) {
+ const toast = useActionToast();
+ const [isOpen, setIsOpen] = useState(false);
+ const [zones, setZones] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [newZoneName, setNewZoneName] = useState("");
+ const [deleteMode, setDeleteMode] = useState(false);
+ const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
+ const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
+ const [editingZone, setEditingZone] = useState(null);
+ const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" });
+
+ const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
+ const selectedDeleteCount = selectedDeleteZones.length;
+
+ const loadZones = async () => {
+ if (!householdId || !location?.id) return;
+ setLoading(true);
+ try {
+ const response = await getLocationZones(householdId, location.id);
+ setZones(response.data?.zones || []);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to load zones");
+ toast.error("Load zones failed", `Load zones failed: ${message}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const closeManager = () => {
+ setIsOpen(false);
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ setPendingDeleteZones([]);
+ setEditingZone(null);
+ };
+
+ const openZoneSettings = (zone) => {
+ setEditingZone(zone);
+ setEditingZoneDraft({ name: zone.name || "" });
+ };
+
+ const toggleZoneSelection = (zoneId) => {
+ setSelectedDeleteIds((currentIds) => {
+ const nextIds = new Set(currentIds);
+ if (nextIds.has(zoneId)) {
+ nextIds.delete(zoneId);
+ } else {
+ nextIds.add(zoneId);
+ }
+ return nextIds;
+ });
+ };
+
+ const startDeleteMode = () => {
+ setDeleteMode(true);
+ setSelectedDeleteIds(new Set());
+ };
+
+ const cancelDeleteMode = () => {
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ };
+
+ const confirmSelectedDelete = () => {
+ if (selectedDeleteCount === 0) return;
+ setPendingDeleteZones(selectedDeleteZones);
+ };
+
+ useEffect(() => {
+ if (isOpen) {
+ loadZones();
+ }
+ }, [isOpen, householdId, location?.id]);
+
+ const handleCreateZone = async () => {
+ const name = newZoneName.trim();
+ if (!name) return;
+
+ try {
+ const nextSortOrder =
+ zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
+ await createLocationZone(householdId, location.id, {
+ name,
+ sort_order: nextSortOrder,
+ });
+ setNewZoneName("");
+ await loadZones();
+ await refreshActiveZones();
+ toast.success("Added zone", `Added zone ${name}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to add zone");
+ toast.error("Add zone failed", `Add zone failed: ${message}`);
+ }
+ };
+
+ const handleMoveZone = async (zone, direction) => {
+ const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
+ const swapIndex = currentIndex + direction;
+ if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
+
+ const other = zones[swapIndex];
+ try {
+ await Promise.all([
+ updateLocationZone(householdId, location.id, zone.id, {
+ sort_order: other.sort_order,
+ }),
+ updateLocationZone(householdId, location.id, other.id, {
+ sort_order: zone.sort_order,
+ }),
+ ]);
+ await loadZones();
+ await refreshActiveZones();
+ setEditingZone(null);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to reorder zones");
+ toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
+ }
+ };
+
+ const handleSaveZone = async () => {
+ if (!editingZone) return;
+
+ const name = editingZoneDraft.name.trim();
+ if (!name) return;
+
+ try {
+ await updateLocationZone(householdId, location.id, editingZone.id, { name });
+ await loadZones();
+ await refreshActiveZones();
+ setEditingZone(null);
+ toast.success("Updated zone", `Updated zone ${name}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to update zone");
+ toast.error("Update zone failed", `Update zone failed: ${message}`);
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (pendingDeleteZones.length === 0) {
+ return;
+ }
+
+ try {
+ await Promise.all(
+ pendingDeleteZones.map((zone) => deleteLocationZone(householdId, location.id, zone.id))
+ );
+ const count = pendingDeleteZones.length;
+ await loadZones();
+ await refreshActiveZones();
+ setPendingDeleteZones([]);
+ setDeleteMode(false);
+ setSelectedDeleteIds(new Set());
+ toast.success(count === 1 ? "Removed zone" : "Removed zones", `Removed ${count} ${count === 1 ? "zone" : "zones"}`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to remove zones");
+ toast.error("Remove zones failed", `Remove zones failed: ${message}`);
+ }
+ };
+
+ const editingZoneIndex = editingZone
+ ? zones.findIndex((zone) => zone.id === editingZone.id)
+ : -1;
+
+ return (
+ <>
+
+
+ {isOpen ? (
+
+
event.stopPropagation()}>
+
+
+
{locationLabel(location)} Zones
+
Manage shopping order zones for this location.
+
+
+
+
+ {canManage ? (
+
+ setNewZoneName(event.target.value)}
+ placeholder="New zone name"
+ />
+
+
+ ) : null}
+
+ {canManage && zones.length > 0 ? (
+
+
+ {deleteMode ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ {loading ? (
+
Loading zones...
+ ) : zones.length === 0 ? (
+
No zones for this location.
+ ) : (
+
+
+ {zones.map((zone, index) => {
+ const isSelectedForDelete = selectedDeleteIds.has(zone.id);
+
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+
+ ) : null}
+
+ 0}
+ canMoveDown={editingZoneIndex >= 0 && editingZoneIndex < zones.length - 1}
+ onCancel={() => setEditingZone(null)}
+ onSave={handleSaveZone}
+ onMove={(direction) => handleMoveZone(editingZone, direction)}
+ />
+
+ 0}
+ title={
+ pendingDeleteZones.length === 1
+ ? `Delete ${pendingDeleteZones[0].name}?`
+ : `Delete ${pendingDeleteZones.length} zones?`
+ }
+ description={
+ pendingDeleteZones.length > 0
+ ? `Slide to confirm. This removes ${pendingDeleteZones.length === 1 ? pendingDeleteZones[0].name : `${pendingDeleteZones.length} zones`} from ${locationLabel(location)}.`
+ : ""
+ }
+ confirmLabel={pendingDeleteZones.length === 1 ? "Delete Zone" : "Delete Zones"}
+ onClose={() => setPendingDeleteZones([])}
+ onConfirm={handleDeleteConfirm}
+ />
+ >
+ );
+}
diff --git a/frontend/src/styles/components/manage/ManageStores.css b/frontend/src/styles/components/manage/ManageStores.css
index 55b5683..a1bfd7b 100644
--- a/frontend/src/styles/components/manage/ManageStores.css
+++ b/frontend/src/styles/components/manage/ManageStores.css
@@ -87,12 +87,6 @@
margin-top: 0.5rem;
}
-.store-actions {
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
-}
-
.store-location-list {
display: flex;
flex-direction: column;
@@ -109,22 +103,30 @@
background: var(--card-bg);
}
-.store-location-row > .store-zones-panel,
+.store-location-manager-trigger,
+.store-zone-manager-trigger {
+ width: 100%;
+}
+
+.store-location-row > .store-zone-manager-trigger,
.store-location-row > .store-available-items-trigger {
grid-column: 1 / -1;
}
-.add-location-panel,
-.store-zone-create-row {
+.store-items-modal-toolbar.store-management-create-row {
display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
-.add-location-panel input,
+.store-items-modal-toolbar.store-location-create-row {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
+}
+
.add-store-panel input,
-.store-zone-create-row input {
+.store-management-create-row input,
+.store-settings-form input {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
@@ -134,47 +136,75 @@
color: var(--text-primary);
}
-.store-zones-panel {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
+.store-management-row {
+ grid-template-columns: minmax(0, 1fr) auto;
}
-.store-zones-content {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- padding: 0.75rem;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--background);
+.store-management-row-with-order {
+ grid-template-columns: auto minmax(0, 1fr) auto;
}
-.store-zone-list {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.store-zone-row {
- display: grid;
- grid-template-columns: 2rem minmax(0, 1fr) auto;
- gap: 0.75rem;
- align-items: center;
-}
-
-.store-zone-order {
+.store-management-order {
+ width: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
-.store-zone-name {
+.store-management-name {
min-width: 0;
color: var(--text-primary);
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.store-zone-actions {
+.store-management-meta {
+ grid-column: 1 / -1;
+ min-width: 0;
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.store-management-badge {
+ display: inline-flex;
+ align-items: center;
+ margin-left: var(--spacing-xs);
+ padding: 2px 6px;
+ border-radius: var(--border-radius-full);
+ background: var(--color-primary-light);
+ color: var(--color-primary);
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+}
+
+.store-settings-modal {
+ width: min(520px, 100%);
+}
+
+.store-settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.store-settings-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.store-settings-form label span {
+ color: var(--text-secondary);
+ font-size: var(--font-size-sm);
+ font-weight: 700;
+}
+
+.store-settings-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
@@ -244,14 +274,6 @@
grid-template-columns: 1fr;
}
- .store-actions {
- width: 100%;
- }
-
- .store-actions button {
- flex: 1;
- }
-
.available-store-card {
flex-direction: column;
align-items: flex-start;
@@ -262,21 +284,21 @@
}
.store-location-row,
- .add-location-panel,
- .store-zone-create-row,
- .store-zone-row {
+ .store-items-modal-toolbar.store-management-create-row,
+ .store-items-modal-toolbar.store-location-create-row,
+ .store-management-row {
grid-template-columns: 1fr;
}
- .store-zone-order {
+ .store-management-order {
text-align: left;
}
- .store-zone-actions {
+ .store-settings-actions {
justify-content: stretch;
}
- .store-zone-actions button {
+ .store-settings-actions button {
flex: 1;
}
}
diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts
index 5887228..39b0ed6 100644
--- a/frontend/tests/available-items-catalog.spec.ts
+++ b/frontend/tests/available-items-catalog.spec.ts
@@ -174,6 +174,254 @@ test("manage stores opens a modal to edit and delete household store items", asy
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
});
+test("manage stores uses modal flows for locations and zones", async ({ page }) => {
+ await seedAuthStorage(page, { username: "store-manager", role: "owner" });
+ await mockConfig(page);
+ await mockHouseholdAndStoreShell(page, {
+ household: { name: "Modal House", role: "owner" },
+ });
+
+ let locations = [
+ {
+ id: 10,
+ household_store_id: 100,
+ name: "Costco",
+ location_name: "Default Location",
+ display_name: "Costco",
+ address: "",
+ is_default: true,
+ },
+ {
+ id: 11,
+ household_store_id: 100,
+ name: "Costco",
+ location_name: "Fontana",
+ display_name: "Costco - Fontana",
+ address: "Sierra Ave",
+ is_default: false,
+ },
+ ];
+ let zones = [
+ { id: 901, name: "Bakery", sort_order: 10 },
+ { id: 902, name: "Frozen Foods", sort_order: 20 },
+ ];
+
+ await page.route("**/households/1/stores", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(locations),
+ });
+ });
+
+ await page.route("**/households/1/stores/100/locations", async (route) => {
+ const request = route.request();
+ if (request.method() === "POST") {
+ const body = request.postDataJSON() as { name?: string; address?: string };
+ const locationName = body.name || "New Location";
+ locations = [
+ ...locations,
+ {
+ id: 12,
+ household_store_id: 100,
+ name: "Costco",
+ location_name: locationName,
+ display_name: `Costco - ${locationName}`,
+ address: body.address || "",
+ is_default: false,
+ },
+ ];
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify({ store: locations[locations.length - 1] }),
+ });
+ return;
+ }
+
+ await route.fulfill({ status: 405 });
+ });
+
+ await page.route("**/households/1/locations/11", async (route) => {
+ const request = route.request();
+ if (request.method() === "PATCH") {
+ const body = request.postDataJSON() as { name?: string; address?: string };
+ locations = locations.map((location) =>
+ location.id === 11
+ ? {
+ ...location,
+ location_name: body.name || location.location_name,
+ display_name: `Costco - ${body.name || location.location_name}`,
+ address: body.address || "",
+ }
+ : location
+ );
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ store: locations.find((location) => location.id === 11) }),
+ });
+ return;
+ }
+
+ if (request.method() === "DELETE") {
+ locations = locations.filter((location) => location.id !== 11);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: "Location removed successfully" }),
+ });
+ return;
+ }
+
+ await route.fulfill({ status: 405 });
+ });
+
+ await page.route("**/households/1/locations/11/default", async (route) => {
+ if (route.request().method() === "PATCH") {
+ locations = locations.map((location) => ({
+ ...location,
+ is_default: location.id === 11,
+ }));
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: "Default location updated successfully" }),
+ });
+ return;
+ }
+
+ await route.fulfill({ status: 405 });
+ });
+
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ const request = route.request();
+ if (request.method() === "GET") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zones }),
+ });
+ return;
+ }
+
+ if (request.method() === "POST") {
+ const body = request.postDataJSON() as { name?: string; sort_order?: number };
+ zones = [
+ ...zones,
+ { id: 903, name: body.name || "New Zone", sort_order: body.sort_order || 30 },
+ ];
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify({ zone: zones[zones.length - 1] }),
+ });
+ return;
+ }
+
+ await route.fulfill({ status: 405 });
+ });
+
+ await page.route("**/households/1/locations/10/zones/901", async (route) => {
+ const request = route.request();
+ if (request.method() === "PATCH") {
+ const body = request.postDataJSON() as { name?: string; sort_order?: number };
+ zones = zones.map((zone) =>
+ zone.id === 901
+ ? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order }
+ : zone
+ );
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zone: zones.find((zone) => zone.id === 901) }),
+ });
+ return;
+ }
+
+ if (request.method() === "DELETE") {
+ zones = zones.filter((zone) => zone.id !== 901);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: "Zone removed successfully" }),
+ });
+ return;
+ }
+
+ await route.fulfill({ status: 405 });
+ });
+
+ await page.goto("/manage?tab=stores");
+
+ const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
+ await expect(storeCard).toBeVisible();
+ await expect(storeCard.getByRole("button", { name: "Set Default" })).toHaveCount(0);
+ 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();
+ const locationsModal = page.locator(".store-items-modal").filter({
+ has: page.getByRole("heading", { name: "Costco Locations" }),
+ });
+ await expect(locationsModal).toBeVisible();
+ await expect(locationsModal.getByPlaceholder("Location name")).toBeVisible();
+ await expect(locationsModal.getByRole("button", { name: "Delete Locations" })).toBeVisible();
+
+ await locationsModal.getByRole("button", { name: "Edit location Costco - Fontana" }).click();
+ const locationSettings = page.locator(".store-items-modal").filter({
+ has: page.getByRole("heading", { name: "Costco - Fontana Settings" }),
+ });
+ await expect(locationSettings).toBeVisible();
+ await locationSettings.getByLabel("Location name").fill("Upland");
+ await locationSettings.getByLabel("Address or notes").fill("Mountain Ave");
+ await locationSettings.getByRole("button", { name: "Save Changes" }).click();
+ await expect(
+ page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated location" })
+ ).toContainText("Updated location");
+
+ await locationsModal.getByRole("button", { name: "Delete Locations" }).click();
+ await locationsModal.getByRole("button", { name: "Select Costco - Upland for deletion" }).click();
+ await expect(locationsModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
+ await locationsModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
+ await expect(page.getByRole("heading", { name: "Delete Costco - Upland?" })).toBeVisible();
+ await confirmSlide(page);
+ await expect(
+ page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed location" })
+ ).toContainText("Removed location");
+
+ await page.getByLabel("Close manage locations modal").click();
+ await storeCard.getByRole("button", { name: "Manage Zones" }).click();
+ const zonesModal = page.locator(".store-items-modal").filter({
+ has: page.getByRole("heading", { name: "Costco Zones" }),
+ });
+ await expect(zonesModal).toBeVisible();
+ await expect(zonesModal.getByPlaceholder("New zone name")).toBeVisible();
+ await expect(zonesModal.getByRole("button", { name: "Up" })).toHaveCount(0);
+ await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0);
+ await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0);
+
+ await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click();
+ const zoneSettings = page.locator(".store-items-modal").filter({
+ has: page.getByRole("heading", { name: "Bakery Settings" }),
+ });
+ await expect(zoneSettings).toBeVisible();
+ await zoneSettings.getByLabel("Zone name").fill("Bread");
+ await zoneSettings.getByRole("button", { name: "Save Changes" }).click();
+ await expect(
+ page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated zone" })
+ ).toContainText("Updated zone");
+
+ await zonesModal.getByRole("button", { name: "Delete Zones" }).click();
+ await zonesModal.getByRole("button", { name: "Select Bread for deletion" }).click();
+ await zonesModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
+ await expect(page.getByRole("heading", { name: "Delete Bread?" })).toBeVisible();
+ await confirmSlide(page);
+ await expect(
+ page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed zone" })
+ ).toContainText("Removed zone");
+});
+
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);