Compare commits
26 Commits
main
...
feature-cu
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fd9d68fe3 | |||
|
|
5e2b2e6e5a | ||
| d94a169417 | |||
|
|
fb35b2b695 | ||
| bafbc0fdac | |||
|
|
346fb917b7 | ||
| 27a6a50744 | |||
|
|
f80ea472ce | ||
| 8387e22b4f | |||
|
|
4b98740a7b | ||
| 737aa6d66e | |||
|
|
0ef35c3f92 | ||
| 6891fc2235 | |||
|
|
a432db93bc | ||
| 99d42f6dd0 | |||
|
|
31eade066f | ||
| ce04ac6951 | |||
|
|
3816f255a0 | ||
| fe9f1eeacc | |||
|
|
7e46e25366 | ||
| f968d304cc | |||
|
|
6252c0538f | ||
| eef2c15e8c | |||
|
|
6731ba3d09 | ||
| cb38b051b3 | |||
|
|
ec7b403546 |
@ -162,6 +162,8 @@ For `app/api/**/[param]/route.ts`:
|
||||
- Touch: long-press affordance for item-level actions when no visible button.
|
||||
- Mouse: hover affordance on interactive rows/cards.
|
||||
- Tap targets remain >= 40px on mobile.
|
||||
- Avoid redundant nearby labels. If a tab, section title, count chip, row label, or control state already communicates the meaning, do not repeat it with an eyebrow label, explanatory zero-state sentence, or duplicate card label.
|
||||
- Prefer compact label/value rows for dense settings controls instead of stacked labels with large vertical gaps.
|
||||
- Modal overlays must close on outside click/tap.
|
||||
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
|
||||
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
|
||||
|
||||
@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
|
||||
sl.address,
|
||||
sl.is_default,
|
||||
sl.map_data,
|
||||
COALESCE(zone_counts.zone_count, 0)::int AS zone_count,
|
||||
COALESCE(item_counts.item_count, 0)::int AS item_count,
|
||||
sl.created_at,
|
||||
sl.updated_at,
|
||||
CASE
|
||||
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
|
||||
END AS display_name
|
||||
FROM store_locations sl
|
||||
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS zone_count
|
||||
FROM store_location_zones slz
|
||||
WHERE slz.household_id = sl.household_id
|
||||
AND slz.store_location_id = sl.id
|
||||
AND slz.is_active = TRUE
|
||||
) zone_counts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS item_count
|
||||
FROM household_store_items hsi
|
||||
WHERE hsi.household_id = sl.household_id
|
||||
AND hsi.store_location_id = sl.id
|
||||
) item_counts ON TRUE
|
||||
WHERE sl.household_id = $1
|
||||
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||
[householdId, DEFAULT_LOCATION_NAME]
|
||||
|
||||
48
backend/tests/store.model.test.js
Normal file
48
backend/tests/store.model.test.js
Normal file
@ -0,0 +1,48 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const Stores = require("../models/store.model");
|
||||
|
||||
describe("store.model", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("lists household stores with location manager counts", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
location_id: 10,
|
||||
id: 10,
|
||||
household_id: 1,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
is_default: true,
|
||||
zone_count: 2,
|
||||
item_count: 5,
|
||||
display_name: "Costco",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await Stores.getHouseholdStores(1);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
display_name: "Costco",
|
||||
zone_count: 2,
|
||||
item_count: 5,
|
||||
}),
|
||||
]);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("AS zone_count"),
|
||||
[1, "Default Location"]
|
||||
);
|
||||
expect(pool.query.mock.calls[0][0]).toContain("FROM store_location_zones slz");
|
||||
expect(pool.query.mock.calls[0][0]).toContain("FROM household_store_items hsi");
|
||||
});
|
||||
});
|
||||
@ -24,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
|
||||
## Guides
|
||||
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
|
||||
- `guides/frontend-readme.md`: frontend development notes.
|
||||
- `guides/management-modal-patterns.md`: reusable modal patterns for managing scoped item/list records.
|
||||
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
|
||||
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.
|
||||
|
||||
|
||||
47
docs/guides/management-modal-patterns.md
Normal file
47
docs/guides/management-modal-patterns.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Management Modal Patterns
|
||||
|
||||
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.
|
||||
- The parent scope must be obvious in the title, for example `Costco Items`.
|
||||
- Modals should avoid repeating table labels inside every row. Use row layout, grouping, and the edit surface for detail.
|
||||
|
||||
## Structure
|
||||
- Header: title, one short description when the scope is not obvious, and a close button.
|
||||
- Primary toolbar: search input plus the primary create action inline.
|
||||
- Bulk toolbar: destructive or multi-select actions above the list, separate from search/create controls.
|
||||
- List: compact rows with the record's primary identity and any essential visual affordance.
|
||||
- Editor: clicking or tapping a row opens the edit/settings modal for that record.
|
||||
- Confirmation: destructive actions must use `ConfirmSlideModal`, not browser dialogs.
|
||||
|
||||
## Row Behavior
|
||||
- Normal mode: the entire row opens settings for that record.
|
||||
- Delete mode: the row toggles selected/unselected state and does not open settings.
|
||||
- Selection state must be visible on the row and must not rely only on color.
|
||||
- Avoid per-row action buttons when the same action applies to every row.
|
||||
|
||||
## Bulk Delete Pattern
|
||||
- Show a `Delete Items` button above the list for users with delete permission.
|
||||
- Clicking `Delete Items` enters delete mode, clears any previous selection, and changes the button to `Confirm Delete (#)`.
|
||||
- Show a `Cancel` button while delete mode is active.
|
||||
- Disable confirm while zero items are selected.
|
||||
- Clicking confirm opens `ConfirmSlideModal`; only the slide confirmation performs the mutation.
|
||||
- On success, exit delete mode, clear selection, refresh the list, and show a toast.
|
||||
- On failure, keep the modal open and show a toast with the API error summary.
|
||||
|
||||
## Permission Rules
|
||||
- Keep authorization server-side. Client visibility only improves UX.
|
||||
- Members can open item settings when the API allows them to manage item details.
|
||||
- Delete controls should be shown only to owners/admins when deletion is admin-scoped.
|
||||
|
||||
## Accessibility
|
||||
- Modal containers should use dialog semantics when practical.
|
||||
- Rows that perform actions should be keyboard reachable.
|
||||
- Delete-mode rows should expose selected state with `aria-pressed` or an equivalent state.
|
||||
- Buttons must have stable labels that describe the action in the current mode.
|
||||
@ -71,6 +71,7 @@ export default function ManageHousehold() {
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
||||
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
|
||||
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
const isOwner = activeHousehold?.role === "owner";
|
||||
@ -85,6 +86,19 @@ export default function ManageHousehold() {
|
||||
}
|
||||
}, [activeHousehold?.id, isManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMember) return undefined;
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedMember(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedMember]);
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!activeHousehold?.id) return;
|
||||
setLoading(true);
|
||||
@ -360,13 +374,34 @@ export default function ManageHousehold() {
|
||||
|
||||
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
|
||||
const memberCount = members.filter((member) => member.role === "member").length;
|
||||
const selectedRoleMeta = selectedMember
|
||||
? ROLE_METADATA[selectedMember.role] || { icon: "👤", label: selectedMember.role }
|
||||
: null;
|
||||
const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10);
|
||||
const canManageSelectedMember =
|
||||
Boolean(selectedMember) &&
|
||||
isManager &&
|
||||
!selectedMemberIsSelf &&
|
||||
selectedMember.role !== "owner";
|
||||
const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin";
|
||||
|
||||
const openMemberRoleChange = (nextRole) => {
|
||||
if (!selectedMember) return;
|
||||
handleUpdateRole(selectedMember.id, nextRole, selectedMember.username);
|
||||
setSelectedMember(null);
|
||||
};
|
||||
|
||||
const openMemberRemoval = () => {
|
||||
if (!selectedMember) return;
|
||||
handleRemoveMember(selectedMember.id, selectedMember.username);
|
||||
setSelectedMember(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="manage-household">
|
||||
<section key="household-name" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Household</p>
|
||||
<h2>Identity</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -411,7 +446,6 @@ export default function ManageHousehold() {
|
||||
<section key="join-and-invites" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
||||
<h2>Invite Links</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -435,9 +469,7 @@ export default function ManageHousehold() {
|
||||
|
||||
{inviteLoading ? (
|
||||
<p>Loading invite settings...</p>
|
||||
) : pendingRequests.length === 0 ? (
|
||||
<p className="section-description">No pending join requests right now.</p>
|
||||
) : (
|
||||
) : pendingRequests.length > 0 ? (
|
||||
<div className="pending-requests-list">
|
||||
{pendingRequests.map((request) => {
|
||||
const requesterLabel = getRequesterLabel(request);
|
||||
@ -473,7 +505,7 @@ export default function ManageHousehold() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="invite-controls">
|
||||
<label>
|
||||
@ -547,7 +579,6 @@ export default function ManageHousehold() {
|
||||
<section key="members" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">People</p>
|
||||
<h2>Members ({members.length})</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -560,7 +591,13 @@ export default function ManageHousehold() {
|
||||
const isSelf = member.id === parseInt(userId, 10);
|
||||
|
||||
return (
|
||||
<div key={member.id} className="member-card">
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
className="member-card member-card-button"
|
||||
onClick={() => setSelectedMember(member)}
|
||||
aria-label={`Open member actions for ${member.username}`}
|
||||
>
|
||||
<div className="member-main">
|
||||
<div className="member-info">
|
||||
<span className={`member-role member-role-${member.role}`}>
|
||||
@ -570,46 +607,76 @@ export default function ManageHousehold() {
|
||||
{isSelf && <span className="member-self-pill">You</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isManager && !isSelf && member.role !== "owner" && (
|
||||
<div className="member-actions">
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
|
||||
className="btn-primary btn-small member-owner-action"
|
||||
>
|
||||
Make Owner
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleUpdateRole(
|
||||
member.id,
|
||||
member.role === "admin" ? "member" : "admin",
|
||||
member.username
|
||||
)}
|
||||
className="btn-secondary btn-small member-role-action"
|
||||
>
|
||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.id, member.username)}
|
||||
className="btn-danger btn-small"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedMember && (
|
||||
<div className="member-actions-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||
<div
|
||||
className="member-actions-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="member-actions-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="member-actions-modal-header">
|
||||
<div className="member-actions-modal-copy">
|
||||
<h3 id="member-actions-title">{selectedMember.username}</h3>
|
||||
<span className={`member-role member-role-${selectedMember.role}`}>
|
||||
{selectedRoleMeta.icon} {selectedRoleMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="member-actions-modal-close"
|
||||
onClick={() => setSelectedMember(null)}
|
||||
aria-label="Close member actions"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManageSelectedMember ? (
|
||||
<div className="member-actions-modal-actions">
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMemberRoleChange("owner")}
|
||||
className="btn-primary member-owner-action"
|
||||
>
|
||||
Make Owner
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMemberRoleChange(selectedMemberNextRole)}
|
||||
className="btn-secondary member-role-action"
|
||||
>
|
||||
{selectedMember.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMemberRemoval}
|
||||
className="btn-danger"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-actions-modal-empty">No actions available for this member.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isManager || isMemberOnly) && (
|
||||
<section key="danger-zone" className="manage-section danger-zone">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Final Actions</p>
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
{isMemberOnly ? (
|
||||
|
||||
@ -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() {
|
||||
@ -204,12 +42,7 @@ export default function ManageStores() {
|
||||
refreshZones,
|
||||
} = useContext(StoreContext);
|
||||
const toast = useActionToast();
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
location_name: "",
|
||||
address: "",
|
||||
});
|
||||
const [locationDrafts, setLocationDrafts] = useState({});
|
||||
const [newStoreName, setNewStoreName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
@ -225,18 +58,17 @@ export default function ManageStores() {
|
||||
|
||||
const handleCreateStore = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!createForm.name.trim()) return;
|
||||
const storeName = newStoreName.trim();
|
||||
if (!storeName) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createHouseholdStore(activeHousehold.id, {
|
||||
name: createForm.name.trim(),
|
||||
location_name: createForm.location_name.trim() || "Default Location",
|
||||
address: createForm.address.trim() || null,
|
||||
name: storeName,
|
||||
});
|
||||
setCreateForm({ name: "", location_name: "", address: "" });
|
||||
setNewStoreName("");
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Created store", `Created store ${createForm.name.trim()}`);
|
||||
toast.success("Created store", `Created store ${storeName}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to create store");
|
||||
toast.error("Create store failed", `Create store failed: ${message}`);
|
||||
@ -245,60 +77,29 @@ 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">
|
||||
<h2>Store Locations ({householdStores.length})</h2>
|
||||
<div className="manage-stores-topline">
|
||||
<h2>Store Locations ({householdStores.length})</h2>
|
||||
{isAdmin ? (
|
||||
<form className="add-store-inline" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={newStoreName}
|
||||
onChange={(event) => setNewStoreName(event.target.value)}
|
||||
placeholder="Store name"
|
||||
aria-label="Store name"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="manage-stores-help">
|
||||
Stores and locations are private to this household. Each location has its own zones,
|
||||
item defaults, and shopping order.
|
||||
Stores are private to this household. Locations define map-specific zones, item
|
||||
placement, and shopping order.
|
||||
</p>
|
||||
{householdStores.length === 0 ? (
|
||||
<p className="empty-message">No store locations added yet.</p>
|
||||
@ -306,133 +107,64 @@ export default function ManageStores() {
|
||||
<div className="stores-list">
|
||||
{groupedStores.map((storeGroup) => (
|
||||
<div key={storeGroup.household_store_id} className="store-card">
|
||||
<div className="store-info">
|
||||
<h3>{storeGroup.name}</h3>
|
||||
<div className="store-card-header">
|
||||
<div className="store-info">
|
||||
<h3>{storeGroup.name}</h3>
|
||||
</div>
|
||||
|
||||
<StoreLocationManager
|
||||
householdId={activeHousehold.id}
|
||||
storeGroup={storeGroup}
|
||||
allLocationCount={householdStores.length}
|
||||
canManage={isAdmin}
|
||||
refreshAfterStoreChange={refreshAfterStoreChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{location.address ? (
|
||||
<p className="store-location">{location.address}</p>
|
||||
) : null}
|
||||
{location.is_default ? (
|
||||
<p className="store-location">Default shopping location</p>
|
||||
) : null}
|
||||
{storeGroup.locations.map((location) => {
|
||||
const label = locationLabel(location);
|
||||
const showLocationName =
|
||||
storeGroup.locations.length > 1 || label !== storeGroup.name;
|
||||
|
||||
return (
|
||||
<div key={location.id} className="store-location-row">
|
||||
<div className="store-info store-location-copy">
|
||||
{showLocationName ? <strong>{label}</strong> : null}
|
||||
{location.address ? (
|
||||
<p className="store-location">{location.address}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="store-location-controls">
|
||||
<StoreZoneManager
|
||||
householdId={activeHousehold.id}
|
||||
location={location}
|
||||
canManage={isAdmin}
|
||||
refreshActiveZones={refreshZones}
|
||||
refreshStoreCounts={refreshStores}
|
||||
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
|
||||
/>
|
||||
|
||||
<StoreAvailableItemsManager
|
||||
householdId={activeHousehold.id}
|
||||
store={location}
|
||||
isAdmin={isAdmin}
|
||||
refreshStoreCounts={refreshStores}
|
||||
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
householdId={activeHousehold.id}
|
||||
location={location}
|
||||
canManage={isAdmin}
|
||||
refreshActiveZones={refreshZones}
|
||||
/>
|
||||
|
||||
<StoreAvailableItemsManager
|
||||
householdId={activeHousehold.id}
|
||||
store={location}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isAdmin ? (
|
||||
<section className="manage-section">
|
||||
<h2>Add Store</h2>
|
||||
<form className="add-store-panel" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Store name, e.g. Costco"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={createForm.location_name}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
|
||||
}
|
||||
placeholder="Location name, e.g. Fontana"
|
||||
/>
|
||||
<input
|
||||
value={createForm.address}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "+ Add Store"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : activeStore ? (
|
||||
{!isAdmin && activeStore ? (
|
||||
<p className="manage-stores-note">
|
||||
Household members can manage item defaults. Only owners and admins can manage stores,
|
||||
locations, zones, and item deletion.
|
||||
|
||||
@ -20,10 +20,17 @@ function itemImageSource(item) {
|
||||
return `data:${mimeType};base64,${item.item_image}`;
|
||||
}
|
||||
|
||||
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
|
||||
export default function StoreAvailableItemsManager({
|
||||
householdId,
|
||||
store,
|
||||
isAdmin,
|
||||
refreshStoreCounts,
|
||||
itemCount = 0,
|
||||
}) {
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [items, setItems] = useState([]);
|
||||
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [catalogReady, setCatalogReady] = useState(true);
|
||||
const [catalogMessage, setCatalogMessage] = useState("");
|
||||
@ -31,7 +38,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editorItem, setEditorItem] = useState(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
|
||||
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
|
||||
|
||||
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
|
||||
const selectedDeleteCount = selectedDeleteItems.length;
|
||||
|
||||
const loadItems = useCallback(async (search = query) => {
|
||||
if (!householdId || !store?.id) {
|
||||
@ -80,9 +92,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
loadZones();
|
||||
}, [isOpen, query, loadItems, loadZones]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayItemCount(itemCount);
|
||||
}, [itemCount]);
|
||||
|
||||
const closeManager = () => {
|
||||
setIsOpen(false);
|
||||
setPendingDeleteItem(null);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
setPendingDeleteItems([]);
|
||||
};
|
||||
|
||||
const handleUpdate = async (payload) => {
|
||||
@ -108,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
setShowEditor(false);
|
||||
setEditorItem(null);
|
||||
await loadItems(query);
|
||||
await refreshStoreCounts?.();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update store item");
|
||||
toast.error("Update store item failed", `Update store item failed: ${message}`);
|
||||
@ -115,19 +134,65 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = (item) => {
|
||||
setEditorItem(item);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const toggleDeleteSelection = (itemId) => {
|
||||
setSelectedDeleteIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
|
||||
if (nextIds.has(itemId)) {
|
||||
nextIds.delete(itemId);
|
||||
} else {
|
||||
nextIds.add(itemId);
|
||||
}
|
||||
|
||||
return nextIds;
|
||||
});
|
||||
};
|
||||
|
||||
const startDeleteMode = () => {
|
||||
setDeleteMode(true);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const cancelDeleteMode = () => {
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const confirmSelectedDelete = () => {
|
||||
if (selectedDeleteCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingDeleteItems(selectedDeleteItems);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!pendingDeleteItem) {
|
||||
if (pendingDeleteItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
||||
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
|
||||
setPendingDeleteItem(null);
|
||||
await Promise.all(
|
||||
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
|
||||
);
|
||||
const count = pendingDeleteItems.length;
|
||||
toast.success(
|
||||
count === 1 ? "Deleted store item" : "Deleted store items",
|
||||
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
|
||||
);
|
||||
setPendingDeleteItems([]);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
await loadItems(query);
|
||||
await refreshStoreCounts?.();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to delete store item");
|
||||
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
|
||||
toast.error("Delete store items failed", `Delete store items failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -138,7 +203,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
className="btn-secondary btn-small store-available-items-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Items
|
||||
Manage Items ({displayItemCount})
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
@ -186,6 +251,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdmin && catalogReady && items.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 Items"}
|
||||
</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">
|
||||
{!catalogReady ? (
|
||||
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
|
||||
@ -195,26 +282,40 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
<p className="empty-message">No household items found for this store yet.</p>
|
||||
) : (
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-head" aria-hidden="true">
|
||||
<span>Item</span>
|
||||
<span>Store Defaults</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
<div className="store-items-table-body">
|
||||
{items.map((item) => {
|
||||
const imageSrc = itemImageSource(item);
|
||||
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
|
||||
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
|
||||
|
||||
return (
|
||||
<div key={item.item_id} className="store-items-table-row">
|
||||
<button
|
||||
key={item.item_id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
|
||||
: `Edit settings for ${item.item_name}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onClick={() => {
|
||||
if (deleteMode) {
|
||||
toggleDeleteSelection(item.item_id);
|
||||
} else {
|
||||
openEditor(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="store-items-table-cell store-items-table-item">
|
||||
<span className="store-items-mobile-label">Item</span>
|
||||
<div className="store-available-items-summary">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
||||
) : (
|
||||
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
||||
<span
|
||||
className="store-available-items-thumb store-available-items-thumb-placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{"\uD83D\uDCE6"}
|
||||
</span>
|
||||
)}
|
||||
<div className="store-available-items-copy">
|
||||
@ -223,38 +324,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-items-table-cell">
|
||||
<span className="store-items-mobile-label">Store Defaults</span>
|
||||
<span className="store-items-defaults-text">
|
||||
{details.join(" | ") || "No store defaults set"}
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "✓" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="store-items-table-cell store-items-table-actions">
|
||||
<span className="store-items-mobile-label">Actions</span>
|
||||
<div className="store-available-items-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={() => {
|
||||
setEditorItem(item);
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => setPendingDeleteItem(item)}
|
||||
>
|
||||
Delete Item
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -277,15 +352,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingDeleteItem)}
|
||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
||||
isOpen={pendingDeleteItems.length > 0}
|
||||
title={
|
||||
pendingDeleteItems.length === 1
|
||||
? `Delete ${pendingDeleteItems[0].item_name}?`
|
||||
: `Delete ${pendingDeleteItems.length} items?`
|
||||
}
|
||||
description={
|
||||
pendingDeleteItem
|
||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
||||
pendingDeleteItems.length > 0
|
||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete Item"
|
||||
onClose={() => setPendingDeleteItem(null)}
|
||||
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
|
||||
onClose={() => setPendingDeleteItems([])}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
|
||||
381
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
381
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
@ -0,0 +1,381 @@
|
||||
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 locationCount = storeGroup.locations.length;
|
||||
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 ({locationCount})
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
506
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
506
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
@ -0,0 +1,506 @@
|
||||
import { useEffect, useRef, 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,
|
||||
refreshStoreCounts,
|
||||
zoneCount = 0,
|
||||
}) {
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount);
|
||||
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 [draggedZoneId, setDraggedZoneId] = useState(null);
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState(null);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
const draggedZoneIdRef = useRef(null);
|
||||
const hasDraggedZoneRef = useRef(false);
|
||||
|
||||
const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
|
||||
const selectedDeleteCount = selectedDeleteZones.length;
|
||||
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
|
||||
|
||||
const loadZones = async () => {
|
||||
if (!householdId || !location?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getLocationZones(householdId, location.id);
|
||||
const nextZones = response.data?.zones || [];
|
||||
setZones(nextZones);
|
||||
setDisplayZoneCount(nextZones.length);
|
||||
} 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);
|
||||
setDraggedZoneId(null);
|
||||
setDragOverZoneId(null);
|
||||
draggedZoneIdRef.current = null;
|
||||
hasDraggedZoneRef.current = false;
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayZoneCount(zoneCount);
|
||||
}, [zoneCount]);
|
||||
|
||||
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();
|
||||
await refreshStoreCounts?.();
|
||||
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 persistZoneOrder = async (orderedZones) => {
|
||||
const zonesWithSortOrder = orderedZones.map((zone, index) => ({
|
||||
...zone,
|
||||
sort_order: (index + 1) * 10,
|
||||
}));
|
||||
const changedZones = zonesWithSortOrder.filter((zone) => {
|
||||
const currentZone = zones.find((candidate) => candidate.id === zone.id);
|
||||
return currentZone && currentZone.sort_order !== zone.sort_order;
|
||||
});
|
||||
|
||||
if (changedZones.length === 0) return true;
|
||||
|
||||
setReordering(true);
|
||||
setZones(zonesWithSortOrder);
|
||||
try {
|
||||
await Promise.all(
|
||||
changedZones.map((zone) =>
|
||||
updateLocationZone(householdId, location.id, zone.id, {
|
||||
sort_order: zone.sort_order,
|
||||
})
|
||||
)
|
||||
);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
toast.success("Reordered zones", "Updated shopping order");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
|
||||
await loadZones();
|
||||
return false;
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveZone = async (fromIndex, toIndex) => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= zones.length || toIndex >= zones.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const orderedZones = [...zones];
|
||||
const [movedZone] = orderedZones.splice(fromIndex, 1);
|
||||
orderedZones.splice(toIndex, 0, movedZone);
|
||||
return persistZoneOrder(orderedZones);
|
||||
};
|
||||
|
||||
const handleMoveZone = async (zone, direction) => {
|
||||
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
|
||||
const moved = await moveZone(currentIndex, currentIndex + direction);
|
||||
if (moved) {
|
||||
setEditingZone(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoneDragStart = (event, zoneId) => {
|
||||
if (!canDragReorder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
draggedZoneIdRef.current = zoneId;
|
||||
hasDraggedZoneRef.current = true;
|
||||
setDraggedZoneId(zoneId);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", String(zoneId));
|
||||
};
|
||||
|
||||
const clearZoneDrag = () => {
|
||||
draggedZoneIdRef.current = null;
|
||||
setDraggedZoneId(null);
|
||||
setDragOverZoneId(null);
|
||||
window.setTimeout(() => {
|
||||
hasDraggedZoneRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleZoneDrop = async (event, targetZoneId) => {
|
||||
event.preventDefault();
|
||||
const sourceZoneId = draggedZoneIdRef.current || event.dataTransfer.getData("text/plain");
|
||||
clearZoneDrag();
|
||||
|
||||
if (!sourceZoneId || String(sourceZoneId) === String(targetZoneId)) return;
|
||||
|
||||
const sourceIndex = zones.findIndex((zone) => String(zone.id) === String(sourceZoneId));
|
||||
const targetIndex = zones.findIndex((zone) => String(zone.id) === String(targetZoneId));
|
||||
await moveZone(sourceIndex, targetIndex);
|
||||
};
|
||||
|
||||
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();
|
||||
await refreshStoreCounts?.();
|
||||
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 ({displayZoneCount})
|
||||
</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 ${canDragReorder ? "store-management-row-with-drag" : ""} ${draggedZoneId === zone.id ? "is-dragging" : ""} ${dragOverZoneId === zone.id ? "is-drag-target" : ""} ${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}
|
||||
onDragOver={(event) => {
|
||||
if (!canDragReorder || !draggedZoneIdRef.current) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDragOverZoneId(zone.id);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setDragOverZoneId(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => handleZoneDrop(event, zone.id)}
|
||||
onClick={() => {
|
||||
if (hasDraggedZoneRef.current) return;
|
||||
if (deleteMode) {
|
||||
toggleZoneSelection(zone.id);
|
||||
} else {
|
||||
openZoneSettings(zone);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{canDragReorder ? (
|
||||
<span
|
||||
className="store-zone-drag-handle"
|
||||
draggable
|
||||
aria-hidden="true"
|
||||
title="Drag to reorder"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => handleZoneDragStart(event, zone.id)}
|
||||
onDragEnd={clearZoneDrag}
|
||||
/>
|
||||
) : null}
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -39,20 +39,11 @@ body.dark-mode .manage-section {
|
||||
}
|
||||
|
||||
.manage-section-header h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manage-section-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--primary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
@ -131,7 +122,7 @@ body.dark-mode .edit-name-form input {
|
||||
}
|
||||
|
||||
.manage-household-join-policy-toggle {
|
||||
margin-bottom: 0.2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pending-requests-summary {
|
||||
@ -243,15 +234,16 @@ body.dark-mode .pending-request-card {
|
||||
|
||||
.invite-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
|
||||
gap: 0.8rem;
|
||||
align-items: end;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.45rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invite-controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@ -265,14 +257,19 @@ body.dark-mode .pending-request-card {
|
||||
}
|
||||
|
||||
.invite-controls select {
|
||||
min-width: 120px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.7rem 0.75rem;
|
||||
padding: 0.62rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.invite-controls .btn-primary {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .invite-controls select,
|
||||
body.dark-mode .invite-controls select {
|
||||
background: rgba(12, 19, 30, 0.92);
|
||||
@ -377,20 +374,30 @@ body.dark-mode .invite-status-badge.is-used {
|
||||
.members-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
||||
gap: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.member-card-button {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .member-card,
|
||||
body.dark-mode .member-card {
|
||||
background: rgba(12, 19, 30, 0.9);
|
||||
@ -408,6 +415,11 @@ body.dark-mode .member-card:hover {
|
||||
background: rgba(20, 32, 48, 0.98);
|
||||
}
|
||||
|
||||
.member-card-button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.member-main {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -415,7 +427,7 @@ body.dark-mode .member-card:hover {
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
@ -427,15 +439,15 @@ body.dark-mode .member-card:hover {
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.28rem;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.82rem;
|
||||
text-transform: capitalize;
|
||||
font-weight: 700;
|
||||
}
|
||||
@ -466,16 +478,6 @@ body.dark-mode .member-card:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
|
||||
}
|
||||
|
||||
.member-owner-action {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
@ -506,11 +508,6 @@ body.dark-mode .member-role-action:hover:not(:disabled) {
|
||||
color: #f3f9ff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .member-actions,
|
||||
body.dark-mode .member-actions {
|
||||
border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent);
|
||||
}
|
||||
|
||||
/* Danger Zone */
|
||||
.danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
@ -527,8 +524,7 @@ body.dark-mode .danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
|
||||
}
|
||||
|
||||
.danger-zone h2,
|
||||
.danger-zone .manage-section-eyebrow {
|
||||
.danger-zone h2 {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@ -559,16 +555,88 @@ body.dark-mode .danger-zone {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.member-actions-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--modal-backdrop-bg);
|
||||
}
|
||||
|
||||
.member-actions-modal {
|
||||
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
|
||||
max-height: calc(100vh - (2 * var(--spacing-md)));
|
||||
overflow: auto;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--modal-bg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.member-actions-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-actions-modal-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-actions-modal-copy h3 {
|
||||
margin: 0.15rem 0 0.45rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.member-actions-modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--button-ghost-bg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-actions-modal-close:hover,
|
||||
.member-actions-modal-close:focus-visible {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.member-actions-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.member-actions-modal-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-actions-modal-empty {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.invite-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-controls .btn-primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.invite-link-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -616,12 +684,6 @@ body.dark-mode .danger-zone {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.member-actions button,
|
||||
.pending-request-actions button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manage-stores-topline {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@ -50,11 +58,11 @@
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.store-card:hover {
|
||||
@ -62,16 +70,23 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.store-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-info h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-location {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -87,44 +102,65 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.store-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.store-location-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.store-location-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.store-location-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.store-location-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.store-location-copy strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.store-location-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.store-location-manager-trigger,
|
||||
.store-zone-manager-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-card-header > .store-location-manager-trigger {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.store-items-modal-toolbar.store-management-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.store-location-row > .store-zones-panel,
|
||||
.store-location-row > .store-available-items-trigger {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.add-location-panel,
|
||||
.store-zone-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-location-panel input,
|
||||
.add-store-panel input,
|
||||
.store-zone-create-row input {
|
||||
.store-items-modal-toolbar.store-location-create-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.add-store-inline input,
|
||||
.store-management-create-row input,
|
||||
.store-settings-form input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.75rem;
|
||||
@ -134,99 +170,128 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.store-zone-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
.store-management-row-with-order.is-delete-selectable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-zone-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
.store-management-row-with-drag {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.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-zone-drag-handle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
background-image: radial-gradient(currentColor 1.4px, transparent 1.4px);
|
||||
background-position: center;
|
||||
background-size: 6px 6px;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
.store-zone-drag-handle:hover,
|
||||
.store-zone-drag-handle:focus-visible {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-management-row.is-dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.store-management-row.is-drag-target {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Add Store Panel */
|
||||
.add-store-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.available-stores {
|
||||
.add-store-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.available-store-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.available-store-card .store-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.available-store-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.available-store-card .store-location {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
.add-store-inline .btn-primary {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@ -239,44 +304,49 @@
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.stores-list,
|
||||
.available-stores {
|
||||
.stores-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.available-store-card button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-location-row,
|
||||
.add-location-panel,
|
||||
.store-zone-create-row,
|
||||
.store-zone-row {
|
||||
.store-card-header,
|
||||
.store-location-controls,
|
||||
.manage-stores-topline,
|
||||
.add-store-inline,
|
||||
.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-card-header > .store-location-manager-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-store-inline .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-management-order {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.store-zone-actions {
|
||||
.store-management-row-with-order {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-management-row-with-order.is-delete-selectable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-management-row-with-drag {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-settings-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.store-zone-actions button {
|
||||
.store-settings-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,11 +60,35 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
background: var(--modal-bg);
|
||||
}
|
||||
|
||||
.store-items-modal-toolbar .btn-small {
|
||||
flex: 0 0 auto;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-items-bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.store-items-delete-toggle,
|
||||
.store-items-delete-cancel {
|
||||
min-height: 38px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.store-available-items-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
@ -92,24 +116,20 @@
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.store-items-table-head,
|
||||
.store-items-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-items-table-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 0 var(--spacing-sm) var(--spacing-xs);
|
||||
background: var(--modal-bg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.store-items-table-row-button {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-items-table-body {
|
||||
@ -125,6 +145,22 @@
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.store-items-table-row-button:hover,
|
||||
.store-items-table-row-button:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-items-table-row-button.is-delete-selectable {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border-light));
|
||||
}
|
||||
|
||||
.store-items-table-row-button.is-selected {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
|
||||
.store-items-table-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -153,8 +189,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-border-medium);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.store-available-items-copy {
|
||||
@ -171,24 +210,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-items-defaults-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.store-items-table-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.store-available-items-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
.store-items-delete-indicator {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-full);
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--color-bg-surface);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.store-items-mobile-label {
|
||||
display: none;
|
||||
.store-items-table-row-button.is-selected .store-items-delete-indicator {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@ -197,37 +239,21 @@
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.store-items-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.store-items-table-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.store-items-mobile-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.store-items-table-row-button.is-delete-selectable {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-items-table-actions {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.store-available-items-actions {
|
||||
.store-items-bulk-toolbar {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.store-available-items-actions button {
|
||||
.store-items-bulk-toolbar button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,15 +36,24 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco" }]),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
is_default: true,
|
||||
zone_count: 0,
|
||||
item_count: availableItems.length,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items*", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const query = (url.searchParams.get("query") || "").toLowerCase();
|
||||
@ -62,7 +71,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items/777", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
availableItems = availableItems.map((item) =>
|
||||
item.item_id === 777
|
||||
@ -89,7 +98,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items/501", async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
||||
await route.fulfill({
|
||||
@ -105,18 +114,46 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Store name")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
|
||||
|
||||
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||
await expect(storeCard).toBeVisible();
|
||||
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
|
||||
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);
|
||||
await expect(storeCard.getByText("Default location")).toHaveCount(0);
|
||||
await expect(storeCard.getByText("Default shopping location")).toHaveCount(0);
|
||||
await expect(storeCard.getByRole("button", { name: "Manage Items (2)" })).toBeVisible();
|
||||
|
||||
await storeCard.getByRole("button", { name: "Manage Items" }).click();
|
||||
await storeCard.getByRole("button", { name: "Manage Items (2)" }).click();
|
||||
|
||||
const managerModal = page.locator(".store-items-modal");
|
||||
await expect(managerModal).toBeVisible();
|
||||
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
|
||||
await expect(managerModal.getByText("apples", { exact: true })).toBeVisible();
|
||||
await expect(managerModal.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
|
||||
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
|
||||
await expect(managerModal.getByText("No store defaults set")).toHaveCount(0);
|
||||
await expect(managerModal.getByText("Edit Settings", { exact: true })).toHaveCount(0);
|
||||
await expect(managerModal.getByText("Delete Item", { exact: true })).toHaveCount(0);
|
||||
await expect(managerModal.locator(".store-available-items-action")).toHaveCount(0);
|
||||
|
||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
|
||||
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
|
||||
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).boundingBox();
|
||||
expect(searchBox).not.toBeNull();
|
||||
expect(addButtonBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2) -
|
||||
((addButtonBox?.y ?? 0) + (addButtonBox?.height ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThan(2);
|
||||
|
||||
const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
|
||||
const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
|
||||
|
||||
await appleRow.click();
|
||||
const editorModal = page.locator(".available-item-editor-modal");
|
||||
await expect(editorModal).toBeVisible();
|
||||
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
||||
@ -126,9 +163,22 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await editorModal.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
||||
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
|
||||
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0);
|
||||
|
||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
|
||||
await managerModal.getByRole("button", { name: "Delete Items" }).click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
|
||||
await expect(managerModal.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
await milkRow.click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
|
||||
await expect(milkRow).toHaveClass(/is-selected/);
|
||||
|
||||
await milkRow.click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
|
||||
await expect(milkRow).not.toHaveClass(/is-selected/);
|
||||
|
||||
await milkRow.click();
|
||||
await managerModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
|
||||
const confirmModal = page.locator(".confirm-slide-modal");
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
||||
@ -141,6 +191,290 @@ 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 page.setViewportSize({ width: 460, height: 1000 });
|
||||
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,
|
||||
zone_count: 2,
|
||||
item_count: 3,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Fontana",
|
||||
display_name: "Costco - Fontana",
|
||||
address: "Sierra Ave",
|
||||
is_default: false,
|
||||
zone_count: 0,
|
||||
item_count: 0,
|
||||
},
|
||||
];
|
||||
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.map((location) =>
|
||||
location.id === 10 ? { ...location, zone_count: zones.length } : location
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
zone_count: 0,
|
||||
item_count: 0,
|
||||
},
|
||||
];
|
||||
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: [...zones].sort((a, b) => a.sort_order - b.sort_order),
|
||||
}),
|
||||
});
|
||||
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/*", async (route) => {
|
||||
const request = route.request();
|
||||
const zoneId = Number(new URL(request.url()).pathname.split("/").pop());
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { name?: string; sort_order?: number };
|
||||
zones = zones.map((zone) =>
|
||||
zone.id === zoneId
|
||||
? { ...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 === zoneId) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE") {
|
||||
zones = zones.filter((zone) => zone.id !== zoneId);
|
||||
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 (2)" }).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 expect(storeCard.getByRole("button", { name: "Manage Locations (1)" })).toBeVisible();
|
||||
await storeCard.getByRole("button", { name: "Manage Zones (2)" }).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);
|
||||
|
||||
const bakeryZoneRow = zonesModal.getByRole("button", { name: "Edit zone Bakery" });
|
||||
const frozenZoneRow = zonesModal.getByRole("button", { name: "Edit zone Frozen Foods" });
|
||||
await expect(bakeryZoneRow.locator(".store-zone-drag-handle")).toBeVisible();
|
||||
const bakeryOrderBox = await bakeryZoneRow.locator(".store-management-order").boundingBox();
|
||||
const bakeryNameBox = await bakeryZoneRow.locator(".store-management-name").boundingBox();
|
||||
expect(bakeryOrderBox).not.toBeNull();
|
||||
expect(bakeryNameBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
((bakeryOrderBox?.y ?? 0) + (bakeryOrderBox?.height ?? 0) / 2) -
|
||||
((bakeryNameBox?.y ?? 0) + (bakeryNameBox?.height ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThan(3);
|
||||
|
||||
await bakeryZoneRow.locator(".store-zone-drag-handle").dragTo(frozenZoneRow);
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Reordered zones" })
|
||||
).toContainText("Reordered zones");
|
||||
await expect(zonesModal.locator(".store-management-row-with-order").first()).toContainText("Frozen Foods");
|
||||
|
||||
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);
|
||||
@ -156,7 +490,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -164,7 +498,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -172,7 +506,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
@ -180,7 +514,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
|
||||
@ -176,7 +176,9 @@ test("household management shows pending invite approvals and can approve them",
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
|
||||
await expect(page.getByText("No pending join requests right now.")).toBeVisible();
|
||||
await expect(page.getByText("No pending join requests right now.")).toHaveCount(0);
|
||||
await expect(page.locator(".pending-requests-summary")).toContainText("0");
|
||||
await expect(page.locator(".manage-section-eyebrow")).toHaveCount(0);
|
||||
await expect(page.getByText("Members (2)")).toBeVisible();
|
||||
});
|
||||
|
||||
@ -231,7 +233,11 @@ test("household member removal opens slide confirmation instead of browser dialo
|
||||
await page.goto("/manage?tab=household");
|
||||
|
||||
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
|
||||
await memberCard.getByRole("button", { name: "Remove" }).click();
|
||||
await expect(memberCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
|
||||
await memberCard.click();
|
||||
const memberActionsDialog = page.getByRole("dialog", { name: "remove-me" });
|
||||
await expect(memberActionsDialog).toBeVisible();
|
||||
await memberActionsDialog.getByRole("button", { name: "Remove" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
|
||||
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
|
||||
@ -266,11 +272,21 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
|
||||
body: JSON.stringify([
|
||||
{ id: 10, household_store_id: 100, name: "Costco", location: "Warehouse", is_default: true },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
@ -346,9 +362,13 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
|
||||
await page.goto("/manage?tab=household");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Make Owner" }).click();
|
||||
const memberCard = page.locator(".member-card").filter({ hasText: "nico-admin" });
|
||||
await memberCard.click();
|
||||
const memberActionsDialog = page.getByRole("dialog", { name: "nico-admin" });
|
||||
await expect(memberActionsDialog).toBeVisible();
|
||||
await memberActionsDialog.getByRole("button", { name: "Make Owner" }).click();
|
||||
await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
|
||||
await confirmSlide(page);
|
||||
|
||||
|
||||
@ -47,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
let linkedStoreIds = [10];
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
let stores = [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -61,65 +66,63 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
const payload = linkedStoreIds.map((id, index) => {
|
||||
const store = allStores.find((candidate) => candidate.id === id);
|
||||
return {
|
||||
...store,
|
||||
is_default: index === 0,
|
||||
};
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { storeId?: number };
|
||||
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
|
||||
linkedStoreIds = [...linkedStoreIds, body.storeId];
|
||||
}
|
||||
const body = request.postDataJSON() as { name?: string };
|
||||
const name = body.name || "New Store";
|
||||
stores = [
|
||||
...stores,
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 101,
|
||||
name,
|
||||
location_name: "Default Location",
|
||||
display_name: name,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
body: JSON.stringify({ store: stores[stores.length - 1] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Stater Bros");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
|
||||
});
|
||||
|
||||
test("manage stores add failure shows normalized error toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@ -128,13 +131,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Costco");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user