Move store zones and locations into modals #11
@ -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.
|
||||
|
||||
@ -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 (
|
||||
<div className="store-zones-panel">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
{isOpen ? "Hide Zones" : "Manage Zones"}
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-zones-content">
|
||||
{canManage ? (
|
||||
<div className="store-zone-create-row">
|
||||
<input
|
||||
value={newZoneName}
|
||||
onChange={(event) => setNewZoneName(event.target.value)}
|
||||
placeholder="New zone name"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
|
||||
Add Zone
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-message">Loading zones...</p>
|
||||
) : zones.length === 0 ? (
|
||||
<p className="empty-message">No zones for this location.</p>
|
||||
) : (
|
||||
<div className="store-zone-list">
|
||||
{zones.map((zone, index) => (
|
||||
<div key={zone.id} className="store-zone-row">
|
||||
<span className="store-zone-order">{index + 1}</span>
|
||||
<span className="store-zone-name">{zone.name}</span>
|
||||
{canManage ? (
|
||||
<div className="store-zone-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={index === 0}
|
||||
onClick={() => handleMoveZone(zone, -1)}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={index === zones.length - 1}
|
||||
onClick={() => handleMoveZone(zone, 1)}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => handleDeleteZone(zone)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="manage-stores">
|
||||
<section className="manage-section">
|
||||
@ -310,11 +100,19 @@ export default function ManageStores() {
|
||||
<h3>{storeGroup.name}</h3>
|
||||
</div>
|
||||
|
||||
<StoreLocationManager
|
||||
householdId={activeHousehold.id}
|
||||
storeGroup={storeGroup}
|
||||
allLocationCount={householdStores.length}
|
||||
canManage={isAdmin}
|
||||
refreshAfterStoreChange={refreshAfterStoreChange}
|
||||
/>
|
||||
|
||||
<div className="store-location-list">
|
||||
{storeGroup.locations.map((location) => (
|
||||
<div key={location.id} className="store-location-row">
|
||||
<div className="store-info">
|
||||
<strong>{location.display_name || location.name}</strong>
|
||||
<strong>{locationLabel(location)}</strong>
|
||||
{location.address ? (
|
||||
<p className="store-location">{location.address}</p>
|
||||
) : null}
|
||||
@ -323,30 +121,7 @@ export default function ManageStores() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="store-actions">
|
||||
{isAdmin && !location.is_default ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetDefault(location)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveLocation(location)}
|
||||
className="btn-danger btn-small"
|
||||
disabled={householdStores.length === 1}
|
||||
title={householdStores.length === 1 ? "Cannot remove last location" : ""}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ZoneManager
|
||||
<StoreZoneManager
|
||||
householdId={activeHousehold.id}
|
||||
location={location}
|
||||
canManage={isAdmin}
|
||||
@ -361,44 +136,6 @@ export default function ManageStores() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<div className="add-location-panel">
|
||||
<input
|
||||
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
|
||||
onChange={(event) =>
|
||||
setLocationDrafts((current) => ({
|
||||
...current,
|
||||
[storeGroup.household_store_id]: {
|
||||
...(current[storeGroup.household_store_id] || {}),
|
||||
name: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Location name"
|
||||
/>
|
||||
<input
|
||||
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
|
||||
onChange={(event) =>
|
||||
setLocationDrafts((current) => ({
|
||||
...current,
|
||||
[storeGroup.household_store_id]: {
|
||||
...(current[storeGroup.household_store_id] || {}),
|
||||
address: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary btn-small"
|
||||
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
|
||||
>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
380
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
380
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
@ -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 (
|
||||
<div className="store-items-modal-overlay" onClick={onCancel}>
|
||||
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{locationLabel(location)} Settings</h3>
|
||||
<p>Update this location name, notes, or default status.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={onCancel}
|
||||
aria-label="Close location settings"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="store-settings-form">
|
||||
<label>
|
||||
<span>Location name</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Address or notes</span>
|
||||
<input
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-settings-actions">
|
||||
{!location.is_default ? (
|
||||
<button type="button" className="btn-secondary btn-small" onClick={onSetDefault}>
|
||||
Set Default
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn-primary btn-small" onClick={onSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-location-manager-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Locations
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-items-modal-overlay" onClick={closeManager}>
|
||||
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{storeGroup.name} Locations</h3>
|
||||
<p>Manage locations, defaults, and location notes for this store.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={closeManager}
|
||||
aria-label="Close manage locations modal"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-items-modal-toolbar store-management-create-row store-location-create-row">
|
||||
<input
|
||||
value={locationDraft.name}
|
||||
onChange={(event) =>
|
||||
setLocationDraft((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
placeholder="Location name"
|
||||
/>
|
||||
<input
|
||||
value={locationDraft.address}
|
||||
onChange={(event) =>
|
||||
setLocationDraft((current) => ({ ...current, address: event.target.value }))
|
||||
}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleAddLocation}>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && storeGroup.locations.length > 0 ? (
|
||||
<div className="store-items-bulk-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small store-items-delete-toggle"
|
||||
disabled={deleteMode ? !canConfirmDelete : allLocationCount <= 1}
|
||||
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
|
||||
title={
|
||||
deleteMode && selectedDeleteCount > 0 && !canConfirmDelete
|
||||
? "At least one household location must remain"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Locations"}
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-items-delete-cancel"
|
||||
onClick={cancelDeleteMode}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-body">
|
||||
{storeGroup.locations.map((location) => {
|
||||
const isSelectedForDelete = selectedDeleteIds.has(location.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={location.id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button store-management-row ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${locationLabel(location)} for deletion`
|
||||
: `Edit location ${locationLabel(location)}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onClick={() => {
|
||||
if (deleteMode) {
|
||||
toggleLocationSelection(location.id);
|
||||
} else {
|
||||
openLocationSettings(location);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="store-management-name">
|
||||
{locationLabel(location)}
|
||||
{location.is_default ? (
|
||||
<span className="store-management-badge">Default</span>
|
||||
) : null}
|
||||
</span>
|
||||
{location.address ? (
|
||||
<span className="store-management-meta">{location.address}</span>
|
||||
) : null}
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "\u2713" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LocationSettingsModal
|
||||
location={editingLocation}
|
||||
draft={editingLocationDraft}
|
||||
setDraft={setEditingLocationDraft}
|
||||
canManage={canManage}
|
||||
onCancel={() => setEditingLocation(null)}
|
||||
onSave={handleSaveLocation}
|
||||
onSetDefault={handleSetDefault}
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={pendingDeleteLocations.length > 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
391
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
391
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
@ -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 (
|
||||
<div className="store-items-modal-overlay" onClick={onCancel}>
|
||||
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{zone.name} Settings</h3>
|
||||
<p>Update this zone name or adjust its shopping order.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={onCancel}
|
||||
aria-label="Close zone settings"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="store-settings-form">
|
||||
<label>
|
||||
<span>Zone name</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={!canMoveUp}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
Move Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={!canMoveDown}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
Move Down
|
||||
</button>
|
||||
<button type="button" className="btn-primary btn-small" onClick={onSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-zone-manager-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Zones
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-items-modal-overlay" onClick={closeManager}>
|
||||
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{locationLabel(location)} Zones</h3>
|
||||
<p>Manage shopping order zones for this location.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={closeManager}
|
||||
aria-label="Close manage zones modal"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-items-modal-toolbar store-management-create-row">
|
||||
<input
|
||||
value={newZoneName}
|
||||
onChange={(event) => setNewZoneName(event.target.value)}
|
||||
placeholder="New zone name"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
|
||||
Add Zone
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && zones.length > 0 ? (
|
||||
<div className="store-items-bulk-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small store-items-delete-toggle"
|
||||
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
|
||||
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
|
||||
>
|
||||
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Zones"}
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-items-delete-cancel"
|
||||
onClick={cancelDeleteMode}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
{loading ? (
|
||||
<p className="empty-message">Loading zones...</p>
|
||||
) : zones.length === 0 ? (
|
||||
<p className="empty-message">No zones for this location.</p>
|
||||
) : (
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-body">
|
||||
{zones.map((zone, index) => {
|
||||
const isSelectedForDelete = selectedDeleteIds.has(zone.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={zone.id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
|
||||
: `Edit zone ${zone.name}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onClick={() => {
|
||||
if (deleteMode) {
|
||||
toggleZoneSelection(zone.id);
|
||||
} else {
|
||||
openZoneSettings(zone);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="store-management-order">{index + 1}</span>
|
||||
<span className="store-management-name">{zone.name}</span>
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "\u2713" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ZoneSettingsModal
|
||||
zone={editingZone}
|
||||
draft={editingZoneDraft}
|
||||
setDraft={setEditingZoneDraft}
|
||||
canManage={canManage}
|
||||
canMoveUp={editingZoneIndex > 0}
|
||||
canMoveDown={editingZoneIndex >= 0 && editingZoneIndex < zones.length - 1}
|
||||
onCancel={() => setEditingZone(null)}
|
||||
onSave={handleSaveZone}
|
||||
onMove={(direction) => handleMoveZone(editingZone, direction)}
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={pendingDeleteZones.length > 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user