feat: add custom store location UI

This commit is contained in:
Nico 2026-05-26 00:38:53 -07:00
parent d1c4fcdfe6
commit f45473cbff
24 changed files with 1059 additions and 478 deletions

View File

@ -9,7 +9,7 @@ function appendClassification(formData, classification) {
} }
export const getAvailableItems = (householdId, storeId, query = "") => export const getAvailableItems = (householdId, storeId, query = "") =>
api.get(`/households/${householdId}/stores/${storeId}/available-items`, { api.get(`/households/${householdId}/locations/${storeId}/available-items`, {
params: query ? { query } : undefined, params: query ? { query } : undefined,
}); });
@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => {
formData.append("image", payload.imageFile); formData.append("image", payload.imageFile);
} }
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/available-items`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
formData.append("image", payload.imageFile); formData.append("image", payload.imageFile);
} }
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, { return api.patch(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
}; };
export const deleteAvailableItem = (householdId, storeId, itemId) => export const deleteAvailableItem = (householdId, storeId, itemId) =>
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`); api.delete(`/households/${householdId}/locations/${storeId}/available-items/${itemId}`);
export const importCurrentAvailableItems = (householdId, storeId) => export const importCurrentAvailableItems = (householdId, storeId) =>
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`); api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`);

View File

@ -4,13 +4,13 @@ import api from "./axios";
* Get grocery list for household and store * Get grocery list for household and store
*/ */
export const getList = (householdId, storeId) => export const getList = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list`); api.get(`/households/${householdId}/locations/${storeId}/list`);
/** /**
* Get specific item by name * Get specific item by name
*/ */
export const getItemByName = (householdId, storeId, itemName) => export const getItemByName = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/item`, { api.get(`/households/${householdId}/locations/${storeId}/list/item`, {
params: { item_name: itemName } params: { item_name: itemName }
}); });
@ -39,7 +39,7 @@ export const addItem = (
formData.append("image", imageFile); formData.append("image", imageFile);
} }
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/list/add`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
@ -50,7 +50,7 @@ export const addItem = (
* Get item classification * Get item classification
*/ */
export const getClassification = (householdId, storeId, itemName) => export const getClassification = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, { api.get(`/households/${householdId}/locations/${storeId}/list/classification`, {
params: { item_name: itemName } params: { item_name: itemName }
}); });
@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) =>
* Set item classification * Set item classification
*/ */
export const setClassification = (householdId, storeId, itemName, classification) => export const setClassification = (householdId, storeId, itemName, classification) =>
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, { api.post(`/households/${householdId}/locations/${storeId}/list/classification`, {
item_name: itemName, item_name: itemName,
classification classification
}); });
@ -104,7 +104,7 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua
* Update item details (quantity, notes) * Update item details (quantity, notes)
*/ */
export const updateItem = (householdId, storeId, itemName, quantity, notes) => export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
api.put(`/households/${householdId}/stores/${storeId}/list/item`, { api.put(`/households/${householdId}/locations/${storeId}/list/item`, {
item_name: itemName, item_name: itemName,
quantity, quantity,
notes notes
@ -114,7 +114,7 @@ export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
* Mark item as bought or unbought * Mark item as bought or unbought
*/ */
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) => export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, { api.patch(`/households/${householdId}/locations/${storeId}/list/item`, {
item_name: itemName, item_name: itemName,
bought, bought,
quantity_bought: quantityBought quantity_bought: quantityBought
@ -124,7 +124,7 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null
* Delete item from list * Delete item from list
*/ */
export const deleteItem = (householdId, storeId, itemName) => export const deleteItem = (householdId, storeId, itemName) =>
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, { api.delete(`/households/${householdId}/locations/${storeId}/list/item`, {
data: { item_name: itemName } data: { item_name: itemName }
}); });
@ -132,7 +132,7 @@ export const deleteItem = (householdId, storeId, itemName) =>
* Get suggestions based on query * Get suggestions based on query
*/ */
export const getSuggestions = (householdId, storeId, query) => export const getSuggestions = (householdId, storeId, query) =>
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, { api.get(`/households/${householdId}/locations/${storeId}/list/suggestions`, {
params: { query } params: { query }
}); });
@ -140,7 +140,7 @@ export const getSuggestions = (householdId, storeId, query) =>
* Get recently bought items * Get recently bought items
*/ */
export const getRecentlyBought = (householdId, storeId) => export const getRecentlyBought = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list/recent`); api.get(`/households/${householdId}/locations/${storeId}/list/recent`);
/** /**
* Update item image * Update item image
@ -158,7 +158,7 @@ export const updateItemImage = (
formData.append("quantity", quantity); formData.append("quantity", quantity);
formData.append("image", imageFile); formData.append("image", imageFile);
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { return api.post(`/households/${householdId}/locations/${storeId}/list/update-image`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },

View File

@ -1,48 +1,55 @@
import api from "./axios"; import api from "./axios";
/** // Legacy global store catalog for the system-admin page.
* Get all stores in the system
*/
export const getAllStores = () => api.get("/stores"); export const getAllStores = () => api.get("/stores");
export const createStore = (name, default_zones) =>
/** api.post("/stores", { name, default_zones });
* Get stores linked to a household export const updateStore = (storeId, name, default_zones) =>
*/ api.patch(`/stores/${storeId}`, { name, default_zones });
export const getHouseholdStores = (householdId) =>
api.get(`/stores/household/${householdId}`);
/**
* Add a store to a household
*/
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
api.post(`/stores/household/${householdId}`, { storeId: storeId, isDefault: isDefault });
/**
* Remove a store from a household
*/
export const removeStoreFromHousehold = (householdId, storeId) =>
api.delete(`/stores/household/${householdId}/${storeId}`);
/**
* Set a store as default for a household
*/
export const setDefaultStore = (householdId, storeId) =>
api.patch(`/stores/household/${householdId}/${storeId}/default`);
/**
* Create a new store (system admin only)
*/
export const createStore = (name, location) =>
api.post("/stores", { name, location });
/**
* Update store details (system admin only)
*/
export const updateStore = (storeId, name, location) =>
api.patch(`/stores/${storeId}`, { name, location });
/**
* Delete a store (system admin only)
*/
export const deleteStore = (storeId) => export const deleteStore = (storeId) =>
api.delete(`/stores/${storeId}`); api.delete(`/stores/${storeId}`);
// Household-owned store locations used by the grocery flow.
export const getHouseholdStores = (householdId) =>
api.get(`/households/${householdId}/stores`);
export const createHouseholdStore = (householdId, payload) =>
api.post(`/households/${householdId}/stores`, payload);
export const updateHouseholdStore = (householdId, householdStoreId, payload) =>
api.patch(`/households/${householdId}/stores/${householdStoreId}`, payload);
export const deleteHouseholdStore = (householdId, householdStoreId) =>
api.delete(`/households/${householdId}/stores/${householdStoreId}`);
export const addLocationToStore = (householdId, householdStoreId, payload) =>
api.post(`/households/${householdId}/stores/${householdStoreId}/locations`, payload);
export const updateLocation = (householdId, locationId, payload) =>
api.patch(`/households/${householdId}/locations/${locationId}`, payload);
export const removeLocation = (householdId, locationId) =>
api.delete(`/households/${householdId}/locations/${locationId}`);
export const setDefaultLocation = (householdId, locationId) =>
api.patch(`/households/${householdId}/locations/${locationId}/default`);
export const getLocationZones = (householdId, locationId) =>
api.get(`/households/${householdId}/locations/${locationId}/zones`);
export const createLocationZone = (householdId, locationId, payload) =>
api.post(`/households/${householdId}/locations/${locationId}/zones`, payload);
export const updateLocationZone = (householdId, locationId, zoneId, payload) =>
api.patch(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`, payload);
export const deleteLocationZone = (householdId, locationId, zoneId) =>
api.delete(`/households/${householdId}/locations/${locationId}/zones/${zoneId}`);
// Compatibility aliases for older callers.
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
api.post(`/stores/household/${householdId}`, { storeId, isDefault });
export const removeStoreFromHousehold = (householdId, storeId) =>
api.delete(`/stores/household/${householdId}/${storeId}`);
export const setDefaultStore = (householdId, storeId) =>
api.patch(`/stores/household/${householdId}/${storeId}/default`);

View File

@ -0,0 +1,37 @@
export default function ListSearchInput({ value, onChange, resultCount, totalCount }) {
const hasSearch = value.trim().length > 0;
return (
<div className="glist-search">
<label className="glist-search-label" htmlFor="grocery-list-search">
Search list
</label>
<div className="glist-search-row">
<input
id="grocery-list-search"
className="glist-search-input"
type="search"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Search list"
autoComplete="off"
/>
{hasSearch && (
<button
className="glist-search-clear"
type="button"
onClick={() => onChange("")}
aria-label="Clear list search"
>
Clear
</button>
)}
</div>
{hasSearch && (
<p className="glist-search-meta">
{resultCount} of {totalCount} item{totalCount === 1 ? "" : "s"}
</p>
)}
</div>
);
}

View File

@ -1,11 +0,0 @@
export default function SortDropdown({ value, onChange }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className="glist-sort">
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
<option value="zone">By Zone</option>
</select>
);
}

View File

@ -2,7 +2,7 @@
export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as ErrorMessage } from './ErrorMessage.jsx';
export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx';
export { default as FormInput } from './FormInput.jsx'; export { default as FormInput } from './FormInput.jsx';
export { default as SortDropdown } from './SortDropdown.jsx'; export { default as ListSearchInput } from './ListSearchInput.jsx';
export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx'; export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
export { default as UserRoleCard } from './UserRoleCard.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx';

View File

@ -21,11 +21,15 @@ export default function ClassificationSection({
onItemTypeChange, onItemTypeChange,
onItemGroupChange, onItemGroupChange,
onZoneChange, onZoneChange,
zones = null,
title = "Item Classification (Optional)", title = "Item Classification (Optional)",
fieldClass = "classification-field", fieldClass = "classification-field",
selectClass = "classification-select" selectClass = "classification-select"
}) { }) {
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
const zoneOptions = Array.isArray(zones) && zones.length > 0
? zones.map((candidate) => candidate.name || candidate).filter(Boolean)
: getZoneValues();
const handleTypeChange = (e) => { const handleTypeChange = (e) => {
const newType = e.target.value; const newType = e.target.value;
@ -35,7 +39,7 @@ export default function ClassificationSection({
return ( return (
<div className="classification-section"> <div className="classification-section">
<h3 className="classification-title">{title}</h3> {title && <h3 className="classification-title">{title}</h3>}
<div className={fieldClass}> <div className={fieldClass}>
<label>Item Type</label> <label>Item Type</label>
@ -79,7 +83,7 @@ export default function ClassificationSection({
className={selectClass} className={selectClass}
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{getZoneValues().map((z) => ( {zoneOptions.map((z) => (
<option key={z} value={z}> <option key={z} value={z}>
{z} {z}
</option> </option>

View File

@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css";
* @param {Function} props.onImageChange - Callback when image is selected (file) * @param {Function} props.onImageChange - Callback when image is selected (file)
* @param {Function} props.onImageRemove - Callback to remove image * @param {Function} props.onImageRemove - Callback to remove image
* @param {string} props.title - Section title (optional) * @param {string} props.title - Section title (optional)
* @param {string} props.cameraLabel - Camera button label (optional)
* @param {string} props.galleryLabel - Gallery button label (optional)
*/ */
export default function ImageUploadSection({ export default function ImageUploadSection({
imagePreview, imagePreview,
onImageChange, onImageChange,
onImageRemove, onImageRemove,
title = "Item Image (Optional)" title = "Item Image (Optional)",
cameraLabel = "Use Camera",
galleryLabel = "Choose from Gallery"
}) { }) {
const cameraInputRef = useRef(null); const cameraInputRef = useRef(null);
const galleryInputRef = useRef(null); const galleryInputRef = useRef(null);
@ -51,7 +55,7 @@ export default function ImageUploadSection({
return ( return (
<div className="image-upload-section"> <div className="image-upload-section">
<h3 className="image-upload-title">{title}</h3> {title && <h3 className="image-upload-title">{title}</h3>}
{sizeError && ( {sizeError && (
<div className="image-upload-error"> <div className="image-upload-error">
{sizeError} {sizeError}
@ -60,10 +64,20 @@ export default function ImageUploadSection({
<div className="image-upload-content"> <div className="image-upload-content">
{!imagePreview ? ( {!imagePreview ? (
<div className="image-upload-options"> <div className="image-upload-options">
<button onClick={handleCameraClick} className="image-upload-btn camera" type="button"> <button
onClick={handleCameraClick}
className="image-upload-btn camera"
type="button"
aria-label={cameraLabel}
>
📷 Use Camera 📷 Use Camera
</button> </button>
<button onClick={handleGalleryClick} className="image-upload-btn gallery" type="button"> <button
onClick={handleGalleryClick}
className="image-upload-btn gallery"
type="button"
aria-label={galleryLabel}
>
🖼 Choose from Gallery 🖼 Choose from Gallery
</button> </button>
</div> </div>

View File

@ -70,6 +70,7 @@ export default function ManageHousehold() {
const [pendingDecisionId, setPendingDecisionId] = useState(null); const [pendingDecisionId, setPendingDecisionId] = useState(null);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [pendingRoleChange, setPendingRoleChange] = useState(null); const [pendingRoleChange, setPendingRoleChange] = useState(null);
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
const isManager = ["owner", "admin"].includes(activeHousehold?.role); const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isOwner = activeHousehold?.role === "owner"; const isOwner = activeHousehold?.role === "owner";
@ -307,12 +308,19 @@ export default function ManageHousehold() {
setPendingRoleChange({ memberId, nextRole, memberName }); setPendingRoleChange({ memberId, nextRole, memberName });
}; };
const handleRemoveMember = async (memberId, username) => { const handleRemoveMember = (memberId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return; setPendingMemberRemoval({ memberId, username });
};
const handleConfirmRemoveMember = async () => {
if (!pendingMemberRemoval) return;
const { memberId, username } = pendingMemberRemoval;
try { try {
await removeMember(activeHousehold.id, memberId); await removeMember(activeHousehold.id, memberId);
await loadMembers(); await loadMembers();
setPendingMemberRemoval(null);
toast.success("Removed member", `Removed member ${username}`); toast.success("Removed member", `Removed member ${username}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to remove member"); const message = getApiErrorMessage(error, "Failed to remove member");
@ -360,9 +368,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Household</p> <p className="manage-section-eyebrow">Household</p>
<h2>Identity</h2> <h2>Identity</h2>
<p className="section-description">
Keep the household name crisp and easy to recognize across invites and shared lists.
</p>
</div> </div>
</div> </div>
{editingName ? ( {editingName ? (
@ -408,9 +413,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Entry Rules</p> <p className="manage-section-eyebrow">Entry Rules</p>
<h2>Invite Links</h2> <h2>Invite Links</h2>
<p className="section-description">
Decide how new people can enter, review manual approvals, then create invite links for the flow you want.
</p>
</div> </div>
</div> </div>
{inviteError && <p className="section-error">{inviteError}</p>} {inviteError && <p className="section-error">{inviteError}</p>}
@ -547,9 +549,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">People</p> <p className="manage-section-eyebrow">People</p>
<h2>Members ({members.length})</h2> <h2>Members ({members.length})</h2>
<p className="section-description">
Role badges and compact actions make it easier to see who runs the household and who just shops.
</p>
</div> </div>
</div> </div>
{loading ? ( {loading ? (
@ -563,16 +562,12 @@ export default function ManageHousehold() {
return ( return (
<div key={member.id} className="member-card"> <div key={member.id} className="member-card">
<div className="member-main"> <div className="member-main">
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
<div className="member-info"> <div className="member-info">
<div className="member-topline">
<span className={`member-role member-role-${member.role}`}> <span className={`member-role member-role-${member.role}`}>
{roleMeta.icon} {roleMeta.label} {roleMeta.icon} {roleMeta.label}
</span> </span>
{isSelf && <span className="member-self-pill"> You</span>}
</div>
<span className="member-name">{member.username}</span> <span className="member-name">{member.username}</span>
<span className="member-meta">ID #{member.id}</span> {isSelf && <span className="member-self-pill">You</span>}
</div> </div>
</div> </div>
{isManager && !isSelf && member.role !== "owner" && ( {isManager && !isSelf && member.role !== "owner" && (
@ -616,11 +611,6 @@ export default function ManageHousehold() {
<div> <div>
<p className="manage-section-eyebrow">Final Actions</p> <p className="manage-section-eyebrow">Final Actions</p>
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<p className="section-description">
{isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p>
</div> </div>
{isMemberOnly ? ( {isMemberOnly ? (
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger"> <button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
@ -664,6 +654,15 @@ export default function ManageHousehold() {
onClose={() => setPendingRoleChange(null)} onClose={() => setPendingRoleChange(null)}
onConfirm={handleConfirmRoleChange} onConfirm={handleConfirmRoleChange}
/> />
<ConfirmSlideModal
isOpen={Boolean(pendingMemberRemoval)}
title={`Remove ${pendingMemberRemoval?.username || "this member"}?`}
description="Slide to confirm. They will lose access to this household."
confirmLabel="Remove Member"
onClose={() => setPendingMemberRemoval(null)}
onConfirm={handleConfirmRemoveMember}
/>
</div> </div>
); );
} }

View File

@ -1,9 +1,13 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { import {
addStoreToHousehold, addLocationToStore,
getAllStores, createHouseholdStore,
removeStoreFromHousehold, createLocationZone,
setDefaultStore deleteLocationZone,
getLocationZones,
removeLocation,
setDefaultLocation,
updateLocationZone,
} from "../../api/stores"; } from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager"; import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
import "../../styles/components/manage/StoreAvailableItemsManager.css"; import "../../styles/components/manage/StoreAvailableItemsManager.css";
export default function ManageStores() { function groupLocationsByStore(locations) {
const { activeHousehold } = useContext(HouseholdContext); const grouped = new Map();
const { stores: householdStores, refreshStores } = useContext(StoreContext);
for (const location of locations) {
const key = location.household_store_id;
if (!grouped.has(key)) {
grouped.set(key, {
household_store_id: location.household_store_id,
name: location.name,
locations: [],
});
}
grouped.get(key).locations.push(location);
}
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
const toast = useActionToast(); const toast = useActionToast();
const [allStores, setAllStores] = useState([]); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(true); const [zones, setZones] = useState([]);
const [showAddStore, setShowAddStore] = useState(false); const [loading, setLoading] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role); const loadZones = async () => {
if (!householdId || !location?.id) return;
useEffect(() => {
loadAllStores();
}, []);
const loadAllStores = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getAllStores(); const response = await getLocationZones(householdId, location.id);
setAllStores(response.data); setZones(response.data?.zones || []);
} catch (error) { } catch (error) {
console.error("Failed to load stores:", error); const message = getApiErrorMessage(error, "Failed to load zones");
toast.error("Load zones failed", `Load zones failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAddStore = async (storeId) => { useEffect(() => {
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; if (isOpen) {
loadZones();
}
}, [isOpen, householdId, location?.id]);
const handleCreateZone = async () => {
const name = newZoneName.trim();
if (!name) return;
try { try {
console.log("Adding store with ID:", storeId); const nextSortOrder =
await addStoreToHousehold(activeHousehold.id, storeId, false); zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
await refreshStores(); await createLocationZone(householdId, location.id, {
toast.success("Added store", `Added store ${storeName}`); name,
setShowAddStore(false); sort_order: nextSortOrder,
});
setNewZoneName("");
await loadZones();
await refreshActiveZones();
toast.success("Added zone", `Added zone ${name}`);
} catch (error) { } catch (error) {
console.error("Failed to add store:", error); const message = getApiErrorMessage(error, "Failed to add zone");
const message = getApiErrorMessage(error, "Failed to add store"); toast.error("Add zone failed", `Add zone failed: ${message}`);
toast.error("Add store failed", `Add store failed: ${message}`);
} }
}; };
const handleRemoveStore = async (storeId, storeName) => { const handleMoveZone = async (zone, direction) => {
if (!confirm(`Remove ${storeName} from this household?`)) return; 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 { try {
await removeStoreFromHousehold(activeHousehold.id, storeId); await Promise.all([
await refreshStores(); updateLocationZone(householdId, location.id, zone.id, {
toast.success("Removed store", `Removed store ${storeName}`); sort_order: other.sort_order,
}),
updateLocationZone(householdId, location.id, other.id, {
sort_order: zone.sort_order,
}),
]);
await loadZones();
await refreshActiveZones();
} catch (error) { } catch (error) {
console.error("Failed to remove store:", error); const message = getApiErrorMessage(error, "Failed to reorder zones");
const message = getApiErrorMessage(error, "Failed to remove store"); toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
toast.error("Remove store failed", `Remove store failed: ${message}`);
} }
}; };
const handleSetDefault = async (storeId) => { const handleDeleteZone = async (zone) => {
const storeName = if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`; return;
}
try { try {
await setDefaultStore(activeHousehold.id, storeId); await deleteLocationZone(householdId, location.id, zone.id);
await refreshStores(); await loadZones();
toast.success("Updated default store", `Default store set to ${storeName}`); await refreshActiveZones();
toast.success("Removed zone", `Removed zone ${zone.name}`);
} catch (error) { } catch (error) {
console.error("Failed to set default store:", error); const message = getApiErrorMessage(error, "Failed to remove zone");
const message = getApiErrorMessage(error, "Failed to set default store"); toast.error("Remove zone failed", `Remove zone failed: ${message}`);
toast.error("Set default store failed", `Set default store failed: ${message}`);
} }
}; };
const availableStores = allStores.filter(
store => !householdStores.some(hs => hs.id === store.id)
);
return ( return (
<div className="manage-stores"> <div className="store-zones-panel">
{/* Current Stores Section */}
<section className="manage-section">
<h2>Your Stores ({householdStores.length})</h2>
<p className="manage-stores-help">
Use each store card's Manage Items button to edit or delete the household/store item list.
</p>
{!isAdmin && (
<p className="manage-stores-note">
Only household owners and admins can manage store item catalogs.
</p>
)}
{householdStores.length === 0 ? (
<p className="empty-message">No stores added yet.</p>
) : (
<div className="stores-list">
{householdStores.map((store) => (
<div key={store.id} className="store-card">
<div className="store-info">
<h3>{store.name}</h3>
{store.location && <p className="store-location">{store.location}</p>}
</div>
{isAdmin && (
<div className="store-actions">
{!store.is_default && (
<button <button
onClick={() => handleSetDefault(store.id)} type="button"
className="btn-secondary btn-small" className="btn-secondary btn-small"
onClick={() => setIsOpen((current) => !current)}
> >
Set as Default {isOpen ? "Hide Zones" : "Manage Zones"}
</button> </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 <button
onClick={() => handleRemoveStore(store.id, store.name)} 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" className="btn-danger btn-small"
disabled={householdStores.length === 1} onClick={() => handleDeleteZone(zone)}
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
> >
Remove Remove
</button> </button>
</div> </div>
) : null}
</div>
))}
</div>
)} )}
</div>
) : null}
</div>
);
}
export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext);
const {
activeStore,
stores: householdStores,
refreshStores,
refreshZones,
} = useContext(StoreContext);
const toast = useActionToast();
const [createForm, setCreateForm] = useState({
name: "",
location_name: "",
address: "",
});
const [locationDrafts, setLocationDrafts] = useState({});
const [saving, setSaving] = useState(false);
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
const groupedStores = useMemo(
() => groupLocationsByStore(householdStores),
[householdStores]
);
const refreshAfterStoreChange = async () => {
await refreshStores();
await refreshZones();
};
const handleCreateStore = async (event) => {
event.preventDefault();
if (!createForm.name.trim()) 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,
});
setCreateForm({ name: "", location_name: "", address: "" });
await refreshAfterStoreChange();
toast.success("Created store", `Created store ${createForm.name.trim()}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
} finally {
setSaving(false);
}
};
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>
<p className="manage-stores-help">
Stores and locations are private to this household. Each location has its own zones,
item defaults, and shopping order.
</p>
{householdStores.length === 0 ? (
<p className="empty-message">No store locations added yet.</p>
) : (
<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>
<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}
</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 <StoreAvailableItemsManager
householdId={activeHousehold.id} householdId={activeHousehold.id}
store={store} store={location}
isAdmin={isAdmin} isAdmin={isAdmin}
/> />
</div> </div>
))} ))}
</div> </div>
)}
</section>
{/* Add Store Section */} {isAdmin ? (
{isAdmin && ( <div className="add-location-panel">
<section className="manage-section"> <input
<h2>Add Store</h2> value={locationDrafts[storeGroup.household_store_id]?.name || ""}
{!showAddStore ? ( onChange={(event) =>
<button onClick={() => setShowAddStore(true)} className="btn-primary"> setLocationDrafts((current) => ({
+ Add Store ...current,
</button> [storeGroup.household_store_id]: {
) : ( ...(current[storeGroup.household_store_id] || {}),
<div className="add-store-panel"> name: event.target.value,
<button onClick={() => setShowAddStore(false)} className="btn-secondary"> },
Cancel }))
</button> }
{loading ? ( placeholder="Location name"
<p>Loading stores...</p> />
) : availableStores.length === 0 ? ( <input
<p className="empty-message">All available stores have been added.</p> value={locationDrafts[storeGroup.household_store_id]?.address || ""}
) : ( onChange={(event) =>
<div className="available-stores"> setLocationDrafts((current) => ({
{availableStores.map((store) => ( ...current,
<div key={store.id} className="available-store-card"> [storeGroup.household_store_id]: {
<div className="store-info"> ...(current[storeGroup.household_store_id] || {}),
<h3>{store.name}</h3> address: event.target.value,
{store.location && <p className="store-location">{store.location}</p>} },
</div> }))
}
placeholder="Address or notes"
/>
<button <button
onClick={() => handleAddStore(store.id)} type="button"
className="btn-primary btn-small" className="btn-primary btn-small"
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
> >
Add Add Location
</button> </button>
</div> </div>
) : null}
</div>
))} ))}
</div> </div>
)} )}
</div>
)}
</section> </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 ? (
<p className="manage-stores-note">
Household members can manage item defaults. Only owners and admins can manage stores,
locations, zones, and item deletion.
</p>
) : null}
</div> </div>
); );
} }

View File

@ -1,9 +1,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
createAvailableItem,
deleteAvailableItem, deleteAvailableItem,
getAvailableItems, getAvailableItems,
updateAvailableItem, updateAvailableItem,
} from "../../api/availableItems"; } from "../../api/availableItems";
import { getLocationZones } from "../../api/stores";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal"; import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
const toast = useActionToast(); const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true); const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState(""); const [catalogMessage, setCatalogMessage] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
}, [householdId, query, store?.id, toast]); }, [householdId, query, store?.id, toast]);
const loadZones = useCallback(async () => {
if (!householdId || !store?.id) {
setZones([]);
return;
}
try {
const response = await getLocationZones(householdId, store.id);
setZones(response.data?.zones || []);
} catch (error) {
console.error("Failed to load location zones:", error);
setZones([]);
}
}, [householdId, store?.id]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
} }
loadItems(query); loadItems(query);
}, [isOpen, query, loadItems]); loadZones();
}, [isOpen, query, loadItems, loadZones]);
const closeManager = () => { const closeManager = () => {
setIsOpen(false); setIsOpen(false);
@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
try { try {
if (editorItem?.item_id) {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload); await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`); toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.display_name || store.name}`);
} else {
const response = await createAvailableItem(householdId, store.id, payload);
toast.success(
"Created store item",
`Created ${response.data?.item?.item_name || payload.itemName} for ${store.display_name || store.name}`
);
}
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
await loadItems(query); await loadItems(query);
@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
try { try {
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id); await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`); toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
setPendingDeleteItem(null); setPendingDeleteItem(null);
await loadItems(query); await loadItems(query);
} catch (error) { } catch (error) {
@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
} }
}; };
if (!isAdmin) {
return null;
}
return ( return (
<> <>
<button <button
@ -123,8 +146,8 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}> <div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header"> <div className="store-items-modal-header">
<div> <div>
<h3>{store.name} Items</h3> <h3>{store.display_name || store.name} Items</h3>
<p>Manage the household/store items used for suggestions and store defaults.</p> <p>Manage location-specific items used for suggestions and defaults.</p>
</div> </div>
<button <button
type="button" type="button"
@ -150,6 +173,17 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
placeholder="Search household/store items" placeholder="Search household/store items"
disabled={!catalogReady} disabled={!catalogReady}
/> />
<button
type="button"
className="btn-primary btn-small"
disabled={!catalogReady}
onClick={() => {
setEditorItem(null);
setShowEditor(true);
}}
>
Add Item
</button>
</div> </div>
<div className="store-items-modal-body"> <div className="store-items-modal-body">
@ -209,6 +243,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
> >
Edit Settings Edit Settings
</button> </button>
{isAdmin ? (
<button <button
type="button" type="button"
className="btn-danger btn-small" className="btn-danger btn-small"
@ -216,6 +251,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
> >
Delete Item Delete Item
</button> </button>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
<AvailableItemEditorModal <AvailableItemEditorModal
isOpen={showEditor} isOpen={showEditor}
item={editorItem} item={editorItem}
zones={zones}
onCancel={() => { onCancel={() => {
setShowEditor(false); setShowEditor(false);
setEditorItem(null); setEditorItem(null);
@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"} title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
description={ description={
pendingDeleteItem pendingDeleteItem
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.` ? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
: "" : ""
} }
confirmLabel="Delete Item" confirmLabel="Delete Item"

View File

@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import ImageUploadSection from "../forms/ImageUploadSection"; import ImageUploadSection from "../forms/ImageUploadSection";
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { export default function AddItemWithDetailsModal({ itemName, zones = [], onConfirm, onCancel }) {
const toast = useActionToast(); const toast = useActionToast();
const [selectedImage, setSelectedImage] = useState(null); const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
onConfirm(selectedImage, classification); onConfirm(selectedImage, classification);
}; };
const handleSkip = () => {
onSkip();
};
return ( return (
<div className="add-item-details-overlay" onClick={onCancel}> <div className="add-item-details-overlay" onClick={onCancel}>
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}> <div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2> <div className="add-item-details-item-name">
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p> {itemName}
</div>
{/* Image Section */} {/* Image Section */}
<div className="add-item-details-section"> <div className="add-item-details-section">
@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
imagePreview={imagePreview} imagePreview={imagePreview}
onImageChange={handleImageChange} onImageChange={handleImageChange}
onImageRemove={handleImageRemove} onImageRemove={handleImageRemove}
title={null}
cameraLabel="Use Image"
galleryLabel="Choose Photo"
/> />
</div> </div>
@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
onItemTypeChange={handleItemTypeChange} onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup} onItemGroupChange={setItemGroup}
onZoneChange={setZone} onZoneChange={setZone}
zones={zones}
title={null}
fieldClass="add-item-details-field" fieldClass="add-item-details-field"
selectClass="add-item-details-select" selectClass="add-item-details-select"
/> />
@ -85,9 +87,6 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
<button onClick={onCancel} className="add-item-details-btn cancel"> <button onClick={onCancel} className="add-item-details-btn cancel">
Cancel Cancel
</button> </button>
<button onClick={handleSkip} className="add-item-details-btn skip">
Skip All
</button>
<button onClick={handleConfirm} className="add-item-details-btn confirm"> <button onClick={handleConfirm} className="add-item-details-btn confirm">
Add Item Add Item
</button> </button>

View File

@ -13,7 +13,7 @@ function buildPreview(item) {
return `data:${mimeType};base64,${item.item_image}`; return `data:${mimeType};base64,${item.item_image}`;
} }
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) { export default function AvailableItemEditorModal({ isOpen, item = null, zones = [], onCancel, onSave }) {
const toast = useActionToast(); const toast = useActionToast();
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState(""); const [itemType, setItemType] = useState("");
@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
onItemTypeChange={handleItemTypeChange} onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup} onItemGroupChange={setItemGroup}
onZoneChange={setZone} onZoneChange={setZone}
zones={zones}
fieldClass="available-item-editor-field" fieldClass="available-item-editor-field"
selectClass="available-item-editor-select" selectClass="available-item-editor-select"
title="Store Classification (Optional)" title="Store Classification (Optional)"

View File

@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast";
import "../../styles/components/EditItemModal.css"; import "../../styles/components/EditItemModal.css";
import AddImageModal from "./AddImageModal"; import AddImageModal from "./AddImageModal";
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { export default function EditItemModal({ item, zones = [], onSave, onCancel, onImageUpdate }) {
const toast = useActionToast(); const toast = useActionToast();
const [itemName, setItemName] = useState(item.item_name || ""); const [itemName, setItemName] = useState(item.item_name || "");
const [quantity, setQuantity] = useState(item.quantity || 1); const [quantity, setQuantity] = useState(item.quantity || 1);
@ -89,6 +89,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
}; };
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : []; const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
const zoneOptions = Array.isArray(zones) && zones.length > 0
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
: getZoneValues();
return ( return (
<div className="edit-modal-overlay" onClick={onCancel}> <div className="edit-modal-overlay" onClick={onCancel}>
@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
className="edit-modal-select" className="edit-modal-select"
> >
<option value="">-- Select Zone --</option> <option value="">-- Select Zone --</option>
{getZoneValues().map((candidateZone) => ( {zoneOptions.map((candidateZone) => (
<option key={candidateZone} value={candidateZone}> <option key={candidateZone} value={candidateZone}>
{candidateZone} {candidateZone}
</option> </option>

View File

@ -25,7 +25,7 @@ export default function StoreTabs() {
onClick={() => setActiveStore(store)} onClick={() => setActiveStore(store)}
disabled={loading} disabled={loading}
> >
<span className="store-name">{store.name}</span> <span className="store-name">{store.display_name || store.name}</span>
</button> </button>
))} ))}
</div> </div>

View File

@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
import { isTransientApiError } from '../api/offlineCache';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId'; const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
@ -43,9 +44,15 @@ export const HouseholdProvider = ({ children }) => {
} }
} catch (err) { } catch (err) {
console.error('[HouseholdContext] Failed to load households:', err); console.error('[HouseholdContext] Failed to load households:', err);
setError(err.response?.data?.message || 'Failed to load households'); setError(
err.response?.data?.error?.message ||
err.response?.data?.message ||
'Failed to load households'
);
if (!isTransientApiError(err)) {
setHouseholds([]); setHouseholds([]);
clearActiveHousehold(); clearActiveHousehold();
}
} finally { } finally {
setLoading(false); setLoading(false);
setHasLoaded(true); setHasLoaded(true);

View File

@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = {
compactView: false, compactView: false,
// List Display // List Display
defaultSortMode: "zone",
showRecentlyBought: true, showRecentlyBought: true,
recentlyBoughtCount: 10, recentlyBoughtCount: 10,
recentlyBoughtCollapsed: false, recentlyBoughtCollapsed: false,
@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = {
debugMode: false, debugMode: false,
}; };
const SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS);
function normalizeSettings(savedSettings = {}) {
return SETTINGS_KEYS.reduce((normalized, key) => {
normalized[key] = Object.prototype.hasOwnProperty.call(savedSettings, key)
? savedSettings[key]
: DEFAULT_SETTINGS[key];
return normalized;
}, {});
}
export const SettingsContext = createContext({ export const SettingsContext = createContext({
settings: DEFAULT_SETTINGS, settings: DEFAULT_SETTINGS,
@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => {
if (savedSettings) { if (savedSettings) {
try { try {
const parsed = JSON.parse(savedSettings); const parsed = JSON.parse(savedSettings);
setSettings({ ...DEFAULT_SETTINGS, ...parsed }); const normalized = normalizeSettings(parsed);
setSettings(normalized);
localStorage.setItem(storageKey, JSON.stringify(normalized));
} catch (error) { } catch (error) {
console.error("Failed to parse settings:", error); console.error("Failed to parse settings:", error);
setSettings(DEFAULT_SETTINGS); setSettings(DEFAULT_SETTINGS);
@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => {
const updateSettings = (newSettings) => { const updateSettings = (newSettings) => {
if (!username) return; if (!username) return;
const updated = { ...settings, ...newSettings }; const updated = normalizeSettings({ ...settings, ...newSettings });
setSettings(updated); setSettings(updated);
const storageKey = `user_preferences_${username}`; const storageKey = `user_preferences_${username}`;

View File

@ -1,15 +1,19 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { getHouseholdStores } from '../api/stores'; import { isTransientApiError } from '../api/offlineCache';
import { getHouseholdStores, getLocationZones } from '../api/stores';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
import { HouseholdContext } from './HouseholdContext'; import { HouseholdContext } from './HouseholdContext';
export const StoreContext = createContext({ export const StoreContext = createContext({
stores: [], stores: [],
activeStore: null, activeStore: null,
zones: [],
loading: false, loading: false,
zonesLoading: false,
error: null, error: null,
setActiveStore: () => { }, setActiveStore: () => { },
refreshStores: () => { }, refreshStores: () => { },
refreshZones: () => { },
}); });
export const StoreProvider = ({ children }) => { export const StoreProvider = ({ children }) => {
@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
const [stores, setStores] = useState([]); const [stores, setStores] = useState([]);
const [activeStore, setActiveStoreState] = useState(null); const [activeStore, setActiveStoreState] = useState(null);
const [zones, setZones] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [zonesLoading, setZonesLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Load stores when household changes // Load stores when household changes
@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => {
// Clear state when logged out or no household // Clear state when logged out or no household
setStores([]); setStores([]);
setActiveStoreState(null); setActiveStoreState(null);
setZones([]);
} }
}, [token, activeHousehold?.id]); }, [token, activeHousehold?.id]);
@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => {
const savedStoreId = localStorage.getItem(storageKey); const savedStoreId = localStorage.getItem(storageKey);
if (savedStoreId) { if (savedStoreId) {
const store = stores.find(s => s.id === parseInt(savedStoreId)); const store = stores.find(s => String(s.id) === String(savedStoreId));
if (store) { if (store) {
console.log('[StoreContext] Found saved store:', store); console.log('[StoreContext] Found saved store:', store);
setActiveStoreState(store); setActiveStoreState(store);
@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => {
localStorage.setItem(storageKey, defaultStore.id); localStorage.setItem(storageKey, defaultStore.id);
}, [stores, activeHousehold]); }, [stores, activeHousehold]);
useEffect(() => {
if (token && activeHousehold?.id && activeStore?.id) {
loadZones();
} else {
setZones([]);
}
}, [token, activeHousehold?.id, activeStore?.id]);
const loadStores = async () => { const loadStores = async () => {
if (!token || !activeHousehold) return; if (!token || !activeHousehold) return;
@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => {
setStores(response.data); setStores(response.data);
} catch (err) { } catch (err) {
console.error('[StoreContext] Failed to load stores:', err); console.error('[StoreContext] Failed to load stores:', err);
setError(err.response?.data?.message || 'Failed to load stores'); setError(
err.response?.data?.error?.message ||
err.response?.data?.message ||
'Failed to load stores'
);
if (!isTransientApiError(err)) {
setStores([]); setStores([]);
setActiveStoreState(null);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => {
setActiveStoreState(store); setActiveStoreState(store);
if (store && activeHousehold) { if (store && activeHousehold) {
const storageKey = `activeStoreId_${activeHousehold.id}`; const storageKey = `activeStoreId_${activeHousehold.id}`;
localStorage.setItem(storageKey, store.id); localStorage.setItem(storageKey, String(store.id));
}
};
const loadZones = async () => {
if (!token || !activeHousehold?.id || !activeStore?.id) return;
setZonesLoading(true);
try {
const response = await getLocationZones(activeHousehold.id, activeStore.id);
setZones(response.data?.zones || []);
} catch (err) {
console.error('[StoreContext] Failed to load zones:', err);
if (!isTransientApiError(err)) {
setZones([]);
}
} finally {
setZonesLoading(false);
} }
}; };
const value = { const value = {
stores, stores,
activeStore, activeStore,
zones,
loading, loading,
zonesLoading,
error, error,
setActiveStore, setActiveStore,
refreshStores: loadStores, refreshStores: loadStores,
refreshZones: loadZones,
}; };
return ( return (

View File

@ -11,7 +11,7 @@ import {
updateItemWithClassification updateItemWithClassification
} from "../api/list"; } from "../api/list";
import { getHouseholdMembers } from "../api/households"; import { getHouseholdMembers } from "../api/households";
import SortDropdown from "../components/common/SortDropdown"; import ListSearchInput from "../components/common/ListSearchInput";
import AddItemForm from "../components/forms/AddItemForm"; import AddItemForm from "../components/forms/AddItemForm";
import NoHouseholdState from "../components/household/NoHouseholdState"; import NoHouseholdState from "../components/household/NoHouseholdState";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
@ -33,21 +33,16 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/GroceryList.css"; import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity"; import { findSimilarItems } from "../utils/stringSimilarity";
function sortItemsForMode(items, sortMode) { function sortItemsByZone(items) {
const sorted = [...items]; const sorted = [...items];
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
if (sortMode === "zone") {
sorted.sort((a, b) => { sorted.sort((a, b) => {
if (!a.zone && b.zone) return 1; if (!a.zone && b.zone) return 1;
if (a.zone && !b.zone) return -1; if (a.zone && !b.zone) return -1;
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name); if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
const aZoneIndex = ZONE_FLOW.indexOf(a.zone); const aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.zone);
const bZoneIndex = ZONE_FLOW.indexOf(b.zone); const bZoneIndex = Number.isInteger(b.zone_sort_order) ? b.zone_sort_order : ZONE_FLOW.indexOf(b.zone);
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
@ -62,11 +57,30 @@ function sortItemsForMode(items, sortMode) {
return a.item_name.localeCompare(b.item_name); return a.item_name.localeCompare(b.item_name);
}); });
}
return sorted; return sorted;
} }
function getSearchableItemText(item) {
return [
item.item_name,
item.item_type,
item.item_group,
item.zone,
...(Array.isArray(item.added_by_users) ? item.added_by_users : []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
}
function filterItemsForSearch(items, query) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return items;
return items.filter((item) => getSearchableItemText(item).includes(normalizedQuery));
}
function getNextModalItem(sortedItems, currentIndex, excludedItemId) { function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId); const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
export default function GroceryList() { export default function GroceryList() {
const pageTitle = "Grocery List";
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { const {
activeHousehold, activeHousehold,
@ -87,7 +100,7 @@ export default function GroceryList() {
loading: householdLoading, loading: householdLoading,
hasLoaded: householdsLoaded hasLoaded: householdsLoaded
} = useContext(HouseholdContext); } = useContext(HouseholdContext);
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const toast = useActionToast(); const toast = useActionToast();
const { enqueueImageUpload } = useUploadQueue(); const { enqueueImageUpload } = useUploadQueue();
@ -103,7 +116,7 @@ export default function GroceryList() {
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [householdMembers, setHouseholdMembers] = useState([]); const [householdMembers, setHouseholdMembers] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [sortMode, setSortMode] = useState(settings.defaultSortMode); const [listSearchQuery, setListSearchQuery] = useState("");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [buttonText, setButtonText] = useState("Add Item"); const [buttonText, setButtonText] = useState("Add Item");
@ -238,10 +251,13 @@ export default function GroceryList() {
})); }));
}; };
// === Sorted Items Computation === // === Visible Items Computation ===
const normalizedListSearchQuery = listSearchQuery.trim().toLowerCase();
const isListSearchActive = normalizedListSearchQuery.length > 0;
const sortedItems = useMemo(() => { const sortedItems = useMemo(() => {
return sortItemsForMode(items, sortMode); return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery));
}, [items, sortMode]); }, [items, normalizedListSearchQuery]);
const visibleRecentlyBoughtItems = useMemo( const visibleRecentlyBoughtItems = useMemo(
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount), () => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
@ -538,42 +554,6 @@ export default function GroceryList() {
} }
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
await addItem(
activeHousehold.id,
activeStore.id,
pendingItem.itemName,
pendingItem.quantity,
null,
null,
pendingItem.addedForUserId || null
);
// Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
const newItem = itemResponse.data;
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
}
} catch (error) {
console.error("Failed to add item:", error);
const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`);
}
}, [activeHousehold?.id, activeStore?.id, pendingItem, toast]);
const handleAddDetailsCancel = useCallback(() => { const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
@ -614,7 +594,9 @@ export default function GroceryList() {
setItems(nextItems); setItems(nextItems);
const nextSortedItems = sortItemsForMode(nextItems, sortMode); const nextSortedItems = sortItemsByZone(
filterItemsForSearch(nextItems, normalizedListSearchQuery)
);
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id); const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
setBuyModalState( setBuyModalState(
@ -633,7 +615,7 @@ export default function GroceryList() {
const message = getApiErrorMessage(error, "Failed to mark item as bought"); const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]); }, [activeHousehold?.id, activeStore?.id, buyModalState, items, normalizedListSearchQuery, sortedItems, toast]);
const openActiveBuyModal = useCallback((item) => { const openActiveBuyModal = useCallback((item) => {
setBuyModalState({ setBuyModalState({
@ -772,7 +754,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}> <p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
Loading households... Loading households...
</p> </p>
@ -785,7 +766,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<NoHouseholdState /> <NoHouseholdState />
</div> </div>
</div> </div>
@ -796,7 +776,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> <p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading stores... Loading stores...
</p> </p>
@ -809,7 +788,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<div className="glist-empty-state"> <div className="glist-empty-state">
<h2 className="glist-empty-title">No stores found</h2> <h2 className="glist-empty-title">No stores found</h2>
<p className="glist-empty-text"> <p className="glist-empty-text">
@ -837,7 +815,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<StoreTabs /> <StoreTabs />
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}> <p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
Loading stores... Loading stores...
@ -851,7 +828,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<StoreTabs /> <StoreTabs />
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p> <p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
</div> </div>
@ -863,8 +839,6 @@ export default function GroceryList() {
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">{pageTitle}</h1>
<StoreTabs /> <StoreTabs />
{canEditList && ( {canEditList && (
@ -878,13 +852,24 @@ export default function GroceryList() {
/> />
)} )}
<SortDropdown value={sortMode} onChange={setSortMode} /> <ListSearchInput
value={listSearchQuery}
onChange={setListSearchQuery}
resultCount={sortedItems.length}
totalCount={items.length}
/>
{sortMode === "zone" ? ( {sortedItems.length === 0 ? (
<p className="glist-empty-search">
{isListSearchActive
? `No list items match "${listSearchQuery.trim()}".`
: "No items in this store yet."}
</p>
) : (
(() => { (() => {
const grouped = groupItemsByZone(sortedItems); const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => { return Object.keys(grouped).map(zone => {
const isCollapsed = collapsedZones[zone]; const isCollapsed = isListSearchActive ? false : collapsedZones[zone];
const itemCount = grouped[zone].length; const itemCount = grouped[zone].length;
return ( return (
<div key={zone} className="glist-classification-group"> <div key={zone} className="glist-classification-group">
@ -923,24 +908,6 @@ export default function GroceryList() {
); );
}); });
})() })()
) : (
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
{sortedItems.map((item) => (
<GroceryListItem
key={item.id}
item={item}
compact={settings.compactView}
onClick={canEditList ? openActiveBuyModal : null}
onOpenBuyModal={openActiveBuyModal}
onImageAdded={
canEditList ? handleImageAdded : null
}
onLongPress={
canEditList ? handleLongPress : null
}
/>
))}
</ul>
)} )}
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
@ -993,8 +960,8 @@ export default function GroceryList() {
{showAddDetailsModal && pendingItem && ( {showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal <AddItemWithDetailsModal
itemName={pendingItem.itemName} itemName={pendingItem.itemName}
zones={zones}
onConfirm={handleAddWithDetails} onConfirm={handleAddWithDetails}
onSkip={handleAddDetailsSkip}
onCancel={handleAddDetailsCancel} onCancel={handleAddDetailsCancel}
/> />
)} )}
@ -1012,6 +979,7 @@ export default function GroceryList() {
{showEditModal && editingItem && ( {showEditModal && editingItem && (
<EditItemModal <EditItemModal
item={editingItem} item={editingItem}
zones={zones}
onSave={handleEditSave} onSave={handleEditSave}
onCancel={handleEditCancel} onCancel={handleEditCancel}
onImageUpdate={handleImageAdded} onImageUpdate={handleImageAdded}

View File

@ -137,12 +137,6 @@ export default function Settings() {
updateSettings({ [key]: parseInt(value, 10) }); updateSettings({ [key]: parseInt(value, 10) });
}; };
const handleSelectChange = (key, value) => {
updateSettings({ [key]: value });
};
const handleReset = () => { const handleReset = () => {
if (window.confirm("Reset all settings to defaults?")) { if (window.confirm("Reset all settings to defaults?")) {
resetSettings(); resetSettings();
@ -252,24 +246,6 @@ export default function Settings() {
<div className="settings-section"> <div className="settings-section">
<h2 className="text-xl font-semibold mb-4">List Display</h2> <h2 className="text-xl font-semibold mb-4">List Display</h2>
<div className="settings-group">
<label className="settings-label">Default Sort Mode</label>
<select
value={settings.defaultSortMode}
onChange={(e) => handleSelectChange("defaultSortMode", e.target.value)}
className="form-select mt-2"
>
<option value="zone">By Zone</option>
<option value="az">A Z</option>
<option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option>
</select>
<p className="settings-description">
Your preferred sorting method when opening the list
</p>
</div>
<div className="settings-group"> <div className="settings-group">
<label className="settings-label"> <label className="settings-label">
<input <input

View File

@ -15,14 +15,28 @@
.add-item-details-modal { .add-item-details-modal {
background: var(--modal-bg); background: var(--modal-bg);
border-radius: var(--border-radius-xl); border-radius: var(--border-radius-xl);
padding: var(--spacing-xl); padding: var(--spacing-lg);
max-width: 500px; max-width: 520px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-xl);
} }
.add-item-details-item-name {
margin: 0 0 var(--spacing-md);
padding: 0.65rem 0.85rem;
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
text-align: center;
overflow-wrap: anywhere;
}
.add-item-details-title { .add-item-details-title {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
margin: 0 0 var(--spacing-xs) 0; margin: 0 0 var(--spacing-xs) 0;
@ -38,13 +52,15 @@
} }
.add-item-details-section { .add-item-details-section {
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-xl); padding-bottom: var(--spacing-md);
border-bottom: var(--border-width-thin) solid var(--color-border-light); border-bottom: var(--border-width-thin) solid var(--color-border-light);
} }
.add-item-details-section:last-of-type { .add-item-details-section:last-of-type {
border-bottom: none; border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
} }
.add-item-details-section-title { .add-item-details-section-title {
@ -55,6 +71,68 @@
} }
/* Image Upload Section */ /* Image Upload Section */
.add-item-details-modal .image-upload-section {
margin: 0;
}
.add-item-details-modal .image-upload-content {
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
padding: var(--spacing-sm);
}
.add-item-details-modal .image-upload-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-sm);
}
.add-item-details-modal .image-upload-btn {
min-width: 0;
min-height: 44px;
padding: 0.7rem 0.75rem;
border-radius: var(--button-border-radius);
border: var(--border-width-thin) solid transparent;
font-size: 0;
font-weight: var(--button-font-weight);
line-height: 1;
white-space: nowrap;
}
.add-item-details-modal .image-upload-btn::after {
content: attr(aria-label);
font-size: var(--font-size-sm);
}
.add-item-details-modal .image-upload-btn.camera {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
color: var(--color-text-inverse);
}
.add-item-details-modal .image-upload-btn.camera:hover {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: var(--color-text-inverse);
}
.add-item-details-modal .image-upload-btn.gallery {
background: var(--button-secondary-bg);
border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
}
.add-item-details-modal .image-upload-btn.gallery:hover {
background: var(--button-secondary-hover-bg);
border-color: var(--button-secondary-border-hover);
color: var(--button-secondary-text);
}
.add-item-details-modal .image-upload-preview {
max-width: 100%;
}
.add-item-details-image-content { .add-item-details-image-content {
min-height: 120px; min-height: 120px;
} }
@ -119,22 +197,33 @@
} }
/* Classification Section */ /* Classification Section */
.add-item-details-modal .classification-section {
margin: 0;
}
.add-item-details-field { .add-item-details-field {
margin-bottom: var(--spacing-md); display: grid;
grid-template-columns: 6.75rem minmax(0, 1fr);
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
} }
.add-item-details-field label { .add-item-details-field label {
display: block; display: block;
margin-bottom: var(--spacing-sm); margin: 0;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
line-height: var(--line-height-tight);
white-space: nowrap;
} }
.add-item-details-select { .add-item-details-select {
width: 100%; width: 100%;
padding: var(--input-padding-y) var(--input-padding-x); min-height: 2.5rem;
font-size: var(--font-size-base); padding: 0.55rem 0.75rem;
font-size: var(--font-size-sm);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius); border-radius: var(--input-border-radius);
box-sizing: border-box; box-sizing: border-box;
@ -153,7 +242,7 @@
.add-item-details-actions { .add-item-details-actions {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);
margin-top: var(--spacing-lg); margin-top: var(--spacing-md);
padding-top: var(--spacing-md); padding-top: var(--spacing-md);
border-top: var(--border-width-thin) solid var(--color-border-light); border-top: var(--border-width-thin) solid var(--color-border-light);
} }
@ -162,38 +251,36 @@
flex: 1; flex: 1;
padding: var(--button-padding-y) var(--button-padding-x); padding: var(--button-padding-y) var(--button-padding-x);
font-size: var(--font-size-base); font-size: var(--font-size-base);
border: none; border: var(--border-width-thin) solid transparent;
border-radius: var(--button-border-radius); border-radius: var(--button-border-radius);
cursor: pointer; cursor: pointer;
font-weight: var(--button-font-weight); font-weight: var(--button-font-weight);
transition: var(--transition-base); transition: var(--transition-base);
min-height: 44px;
} }
.add-item-details-btn.cancel { .add-item-details-btn.cancel {
background: var(--color-secondary); background: var(--button-secondary-bg);
color: var(--color-text-inverse); border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
} }
.add-item-details-btn.cancel:hover { .add-item-details-btn.cancel:hover {
background: var(--color-secondary-hover); background: var(--button-secondary-hover-bg);
} border-color: var(--button-secondary-border-hover);
color: var(--button-secondary-text);
.add-item-details-btn.skip {
background: var(--color-warning);
color: var(--color-text-primary);
}
.add-item-details-btn.skip:hover {
background: var(--color-warning-hover);
} }
.add-item-details-btn.confirm { .add-item-details-btn.confirm {
background: var(--color-primary); background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
color: var(--color-text-inverse); color: var(--color-text-inverse);
} }
.add-item-details-btn.confirm:hover { .add-item-details-btn.confirm:hover {
background: var(--color-primary-hover); background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
color: var(--color-text-inverse);
} }
/* Mobile responsiveness */ /* Mobile responsiveness */
@ -225,6 +312,10 @@
min-height: 44px; min-height: 44px;
} }
.add-item-details-field {
grid-template-columns: 6rem minmax(0, 1fr);
}
.add-item-details-actions { .add-item-details-actions {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;

View File

@ -383,8 +383,8 @@ body.dark-mode .invite-status-badge.is-used {
.member-card { .member-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.9rem; gap: 0.7rem;
padding: 0.95rem 1rem; padding: 0.85rem 1rem;
background: rgba(255, 255, 255, 1); background: rgba(255, 255, 255, 1);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
@ -408,74 +408,48 @@ body.dark-mode .member-card:hover {
background: rgba(20, 32, 48, 0.98); background: rgba(20, 32, 48, 0.98);
} }
.member-avatar {
width: 2.6rem;
height: 2.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--primary-light);
font-size: 1.15rem;
}
.member-main { .member-main {
display: grid; min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: flex-start;
} }
.member-info { .member-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.member-topline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
flex-wrap: wrap; min-width: 0;
max-width: 100%;
white-space: nowrap;
} }
.member-name { .member-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
} }
.member-meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.member-role { .member-role {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
font-size: 0.78rem; flex: 0 0 auto;
padding: 0.24rem 0.55rem; font-size: 0.88rem;
border-radius: var(--border-radius-full);
width: fit-content;
text-transform: capitalize; text-transform: capitalize;
font-weight: 700; font-weight: 700;
} }
.member-role-owner { .member-role-owner {
background: rgba(245, 158, 11, 0.18);
color: #b45309; color: #b45309;
} }
.member-role-admin { .member-role-admin {
background: rgba(30, 144, 255, 0.16);
color: var(--primary-dark); color: var(--primary-dark);
} }
.member-role-member, .member-role-member,
.member-role-viewer { .member-role-viewer {
background: rgba(139, 92, 246, 0.12);
color: #6d28d9; color: #6d28d9;
} }
@ -483,7 +457,8 @@ body.dark-mode .member-card:hover {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.24rem 0.5rem; flex: 0 0 auto;
padding: 0.18rem 0.45rem;
border-radius: var(--border-radius-full); border-radius: var(--border-radius-full);
background: rgba(245, 158, 11, 0.16); background: rgba(245, 158, 11, 0.16);
color: #a16207; color: #a16207;
@ -497,7 +472,7 @@ body.dark-mode .member-card:hover {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding-top: 0.75rem; padding-top: 0.65rem;
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent); border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
} }

View File

@ -93,6 +93,94 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.store-location-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.store-location-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 {
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--input-bg);
color: var(--text-primary);
}
.store-zones-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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-zone-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.store-zone-row {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
}
.store-zone-order {
color: var(--text-secondary);
font-size: 0.85rem;
text-align: right;
}
.store-zone-name {
min-width: 0;
color: var(--text-primary);
}
.store-zone-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Add Store Panel */ /* Add Store Panel */
.add-store-panel { .add-store-panel {
display: flex; display: flex;
@ -172,4 +260,23 @@
.available-store-card button { .available-store-card button {
width: 100%; width: 100%;
} }
.store-location-row,
.add-location-panel,
.store-zone-create-row,
.store-zone-row {
grid-template-columns: 1fr;
}
.store-zone-order {
text-align: left;
}
.store-zone-actions {
justify-content: stretch;
}
.store-zone-actions button {
flex: 1;
}
} }

View File

@ -14,13 +14,6 @@
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* Title */
.glist-title {
text-align: center;
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-sm);
}
.glist-section-title { .glist-section-title {
text-align: center; text-align: center;
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
@ -359,10 +352,29 @@
font-size: 0.65em; font-size: 0.65em;
} }
/* Sorting dropdown */ /* List search */
.glist-sort { .glist-search {
width: 100%; width: 100%;
margin: var(--spacing-xs) 0; margin: var(--spacing-xs) 0 var(--spacing-sm);
}
.glist-search-label {
display: block;
margin-bottom: 4px;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.glist-search-row {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.glist-search-input {
width: 100%;
min-width: 0;
padding: var(--spacing-sm); padding: var(--spacing-sm);
font-size: var(--font-size-base); font-size: var(--font-size-base);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
@ -371,6 +383,48 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.glist-search-input::placeholder {
color: var(--color-text-muted);
}
.glist-search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.glist-search-clear {
flex: 0 0 auto;
min-height: 40px;
padding: 0 var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-medium);
border-radius: var(--border-radius-sm);
background: var(--color-bg-hover);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: 700;
cursor: pointer;
}
.glist-search-clear:hover,
.glist-search-clear:focus-visible {
border-color: var(--color-primary);
color: var(--color-primary);
}
.glist-search-meta {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.glist-empty-search {
margin: var(--spacing-md) 0;
color: var(--color-text-secondary);
text-align: center;
font-size: var(--font-size-sm);
}
/* Image upload */ /* Image upload */
.glist-image-upload { .glist-image-upload {
margin: 0.5em 0; margin: 0.5em 0;