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.

+
+ +
+ +
+ + + + {canManage ? ( +
+ {!location.is_default ? ( + + ) : null} + +
+ ) : null} +
+
+
+ ); +} + +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);