feat: add custom store location UI
This commit is contained in:
parent
d1c4fcdfe6
commit
f45473cbff
@ -9,7 +9,7 @@ function appendClassification(formData, classification) {
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@ -21,7 +21,7 @@ export const createAvailableItem = (householdId, storeId, payload) => {
|
||||
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: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
@ -41,7 +41,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
|
||||
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: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
@ -49,7 +49,7 @@ export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
|
||||
};
|
||||
|
||||
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) =>
|
||||
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`);
|
||||
api.post(`/households/${householdId}/locations/${storeId}/available-items/import-current`);
|
||||
|
||||
@ -4,13 +4,13 @@ import api from "./axios";
|
||||
* Get grocery list for household and store
|
||||
*/
|
||||
export const getList = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
||||
api.get(`/households/${householdId}/locations/${storeId}/list`);
|
||||
|
||||
/**
|
||||
* Get specific item by name
|
||||
*/
|
||||
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 }
|
||||
});
|
||||
|
||||
@ -39,7 +39,7 @@ export const addItem = (
|
||||
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: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
@ -50,7 +50,7 @@ export const addItem = (
|
||||
* Get item classification
|
||||
*/
|
||||
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 }
|
||||
});
|
||||
|
||||
@ -58,7 +58,7 @@ export const getClassification = (householdId, storeId, itemName) =>
|
||||
* Set item 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,
|
||||
classification
|
||||
});
|
||||
@ -104,7 +104,7 @@ export const updateItemWithClassification = (householdId, storeId, itemName, qua
|
||||
* Update item details (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,
|
||||
quantity,
|
||||
notes
|
||||
@ -114,7 +114,7 @@ export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
||||
* Mark item as bought or unbought
|
||||
*/
|
||||
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,
|
||||
bought,
|
||||
quantity_bought: quantityBought
|
||||
@ -124,7 +124,7 @@ export const markBought = (householdId, storeId, itemName, quantityBought = null
|
||||
* Delete item from list
|
||||
*/
|
||||
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 }
|
||||
});
|
||||
|
||||
@ -132,7 +132,7 @@ export const deleteItem = (householdId, storeId, itemName) =>
|
||||
* Get suggestions based on 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 }
|
||||
});
|
||||
|
||||
@ -140,7 +140,7 @@ export const getSuggestions = (householdId, storeId, query) =>
|
||||
* Get recently bought items
|
||||
*/
|
||||
export const getRecentlyBought = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
||||
api.get(`/households/${householdId}/locations/${storeId}/list/recent`);
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
@ -158,7 +158,7 @@ export const updateItemImage = (
|
||||
formData.append("quantity", quantity);
|
||||
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: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
|
||||
@ -1,48 +1,55 @@
|
||||
import api from "./axios";
|
||||
|
||||
/**
|
||||
* Get all stores in the system
|
||||
*/
|
||||
// Legacy global store catalog for the system-admin page.
|
||||
export const getAllStores = () => api.get("/stores");
|
||||
|
||||
/**
|
||||
* Get stores linked to a household
|
||||
*/
|
||||
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 createStore = (name, default_zones) =>
|
||||
api.post("/stores", { name, default_zones });
|
||||
export const updateStore = (storeId, name, default_zones) =>
|
||||
api.patch(`/stores/${storeId}`, { name, default_zones });
|
||||
export const deleteStore = (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`);
|
||||
|
||||
37
frontend/src/components/common/ListSearchInput.jsx
Normal file
37
frontend/src/components/common/ListSearchInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
export { default as ErrorMessage } from './ErrorMessage.jsx';
|
||||
export { default as FloatingActionButton } from './FloatingActionButton.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 UserRoleCard } from './UserRoleCard.jsx';
|
||||
|
||||
|
||||
@ -21,11 +21,15 @@ export default function ClassificationSection({
|
||||
onItemTypeChange,
|
||||
onItemGroupChange,
|
||||
onZoneChange,
|
||||
zones = null,
|
||||
title = "Item Classification (Optional)",
|
||||
fieldClass = "classification-field",
|
||||
selectClass = "classification-select"
|
||||
}) {
|
||||
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 newType = e.target.value;
|
||||
@ -35,7 +39,7 @@ export default function ClassificationSection({
|
||||
|
||||
return (
|
||||
<div className="classification-section">
|
||||
<h3 className="classification-title">{title}</h3>
|
||||
{title && <h3 className="classification-title">{title}</h3>}
|
||||
|
||||
<div className={fieldClass}>
|
||||
<label>Item Type</label>
|
||||
@ -79,7 +83,7 @@ export default function ClassificationSection({
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{getZoneValues().map((z) => (
|
||||
{zoneOptions.map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z}
|
||||
</option>
|
||||
|
||||
@ -9,12 +9,16 @@ import "../../styles/components/ImageUploadSection.css";
|
||||
* @param {Function} props.onImageChange - Callback when image is selected (file)
|
||||
* @param {Function} props.onImageRemove - Callback to remove image
|
||||
* @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({
|
||||
imagePreview,
|
||||
onImageChange,
|
||||
onImageRemove,
|
||||
title = "Item Image (Optional)"
|
||||
title = "Item Image (Optional)",
|
||||
cameraLabel = "Use Camera",
|
||||
galleryLabel = "Choose from Gallery"
|
||||
}) {
|
||||
const cameraInputRef = useRef(null);
|
||||
const galleryInputRef = useRef(null);
|
||||
@ -51,7 +55,7 @@ export default function ImageUploadSection({
|
||||
|
||||
return (
|
||||
<div className="image-upload-section">
|
||||
<h3 className="image-upload-title">{title}</h3>
|
||||
{title && <h3 className="image-upload-title">{title}</h3>}
|
||||
{sizeError && (
|
||||
<div className="image-upload-error">
|
||||
{sizeError}
|
||||
@ -60,10 +64,20 @@ export default function ImageUploadSection({
|
||||
<div className="image-upload-content">
|
||||
{!imagePreview ? (
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -70,6 +70,7 @@ export default function ManageHousehold() {
|
||||
const [pendingDecisionId, setPendingDecisionId] = useState(null);
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
||||
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
||||
|
||||
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
const isOwner = activeHousehold?.role === "owner";
|
||||
@ -307,12 +308,19 @@ export default function ManageHousehold() {
|
||||
setPendingRoleChange({ memberId, nextRole, memberName });
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId, username) => {
|
||||
if (!confirm(`Remove ${username} from this household?`)) return;
|
||||
const handleRemoveMember = (memberId, username) => {
|
||||
setPendingMemberRemoval({ memberId, username });
|
||||
};
|
||||
|
||||
const handleConfirmRemoveMember = async () => {
|
||||
if (!pendingMemberRemoval) return;
|
||||
|
||||
const { memberId, username } = pendingMemberRemoval;
|
||||
|
||||
try {
|
||||
await removeMember(activeHousehold.id, memberId);
|
||||
await loadMembers();
|
||||
setPendingMemberRemoval(null);
|
||||
toast.success("Removed member", `Removed member ${username}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to remove member");
|
||||
@ -360,9 +368,6 @@ export default function ManageHousehold() {
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Household</p>
|
||||
<h2>Identity</h2>
|
||||
<p className="section-description">
|
||||
Keep the household name crisp and easy to recognize across invites and shared lists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{editingName ? (
|
||||
@ -408,9 +413,6 @@ export default function ManageHousehold() {
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
||||
<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>
|
||||
{inviteError && <p className="section-error">{inviteError}</p>}
|
||||
@ -547,9 +549,6 @@ export default function ManageHousehold() {
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">People</p>
|
||||
<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>
|
||||
{loading ? (
|
||||
@ -563,16 +562,12 @@ export default function ManageHousehold() {
|
||||
return (
|
||||
<div key={member.id} className="member-card">
|
||||
<div className="member-main">
|
||||
<div className="member-avatar" aria-hidden="true">{roleMeta.icon}</div>
|
||||
<div className="member-info">
|
||||
<div className="member-topline">
|
||||
<span className={`member-role member-role-${member.role}`}>
|
||||
{roleMeta.icon} {roleMeta.label}
|
||||
</span>
|
||||
{isSelf && <span className="member-self-pill">✨ You</span>}
|
||||
</div>
|
||||
<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>
|
||||
{isManager && !isSelf && member.role !== "owner" && (
|
||||
@ -616,11 +611,6 @@ export default function ManageHousehold() {
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Final Actions</p>
|
||||
<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>
|
||||
{isMemberOnly ? (
|
||||
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
||||
@ -664,6 +654,15 @@ export default function ManageHousehold() {
|
||||
onClose={() => setPendingRoleChange(null)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
addStoreToHousehold,
|
||||
getAllStores,
|
||||
removeStoreFromHousehold,
|
||||
setDefaultStore
|
||||
addLocationToStore,
|
||||
createHouseholdStore,
|
||||
createLocationZone,
|
||||
deleteLocationZone,
|
||||
getLocationZones,
|
||||
removeLocation,
|
||||
setDefaultLocation,
|
||||
updateLocationZone,
|
||||
} from "../../api/stores";
|
||||
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
@ -13,172 +17,427 @@ import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/manage/ManageStores.css";
|
||||
import "../../styles/components/manage/StoreAvailableItemsManager.css";
|
||||
|
||||
export default function ManageStores() {
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
||||
function groupLocationsByStore(locations) {
|
||||
const grouped = new Map();
|
||||
|
||||
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 [allStores, setAllStores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddStore, setShowAddStore] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newZoneName, setNewZoneName] = useState("");
|
||||
|
||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllStores();
|
||||
}, []);
|
||||
|
||||
const loadAllStores = async () => {
|
||||
const loadZones = async () => {
|
||||
if (!householdId || !location?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getAllStores();
|
||||
setAllStores(response.data);
|
||||
const response = await getLocationZones(householdId, location.id);
|
||||
setZones(response.data?.zones || []);
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStore = async (storeId) => {
|
||||
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadZones();
|
||||
}
|
||||
}, [isOpen, householdId, location?.id]);
|
||||
|
||||
const handleCreateZone = async () => {
|
||||
const name = newZoneName.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
console.log("Adding store with ID:", storeId);
|
||||
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
||||
await refreshStores();
|
||||
toast.success("Added store", `Added store ${storeName}`);
|
||||
setShowAddStore(false);
|
||||
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) {
|
||||
console.error("Failed to add store:", error);
|
||||
const message = getApiErrorMessage(error, "Failed to add store");
|
||||
toast.error("Add store failed", `Add store failed: ${message}`);
|
||||
const message = getApiErrorMessage(error, "Failed to add zone");
|
||||
toast.error("Add zone failed", `Add zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStore = async (storeId, storeName) => {
|
||||
if (!confirm(`Remove ${storeName} from this household?`)) return;
|
||||
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 removeStoreFromHousehold(activeHousehold.id, storeId);
|
||||
await refreshStores();
|
||||
toast.success("Removed store", `Removed store ${storeName}`);
|
||||
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) {
|
||||
console.error("Failed to remove store:", error);
|
||||
const message = getApiErrorMessage(error, "Failed to remove store");
|
||||
toast.error("Remove store failed", `Remove store failed: ${message}`);
|
||||
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (storeId) => {
|
||||
const storeName =
|
||||
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||
const handleDeleteZone = async (zone) => {
|
||||
if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setDefaultStore(activeHousehold.id, storeId);
|
||||
await refreshStores();
|
||||
toast.success("Updated default store", `Default store set to ${storeName}`);
|
||||
await deleteLocationZone(householdId, location.id, zone.id);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
toast.success("Removed zone", `Removed zone ${zone.name}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to set default store:", error);
|
||||
const message = getApiErrorMessage(error, "Failed to set default store");
|
||||
toast.error("Set default store failed", `Set default store failed: ${message}`);
|
||||
const message = getApiErrorMessage(error, "Failed to remove zone");
|
||||
toast.error("Remove zone failed", `Remove zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const availableStores = allStores.filter(
|
||||
store => !householdStores.some(hs => hs.id === store.id)
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Current Stores Section */}
|
||||
<section className="manage-section">
|
||||
<h2>Your Stores ({householdStores.length})</h2>
|
||||
<h2>Store Locations ({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.
|
||||
Stores and locations are private to this household. Each location has its own zones,
|
||||
item defaults, and shopping order.
|
||||
</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>
|
||||
<p className="empty-message">No store locations added yet.</p>
|
||||
) : (
|
||||
<div className="stores-list">
|
||||
{householdStores.map((store) => (
|
||||
<div key={store.id} className="store-card">
|
||||
{groupedStores.map((storeGroup) => (
|
||||
<div key={storeGroup.household_store_id} className="store-card">
|
||||
<div className="store-info">
|
||||
<h3>{store.name}</h3>
|
||||
{store.location && <p className="store-location">{store.location}</p>}
|
||||
<h3>{storeGroup.name}</h3>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="store-actions">
|
||||
{!store.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(store.id)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
Set as Default
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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
|
||||
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
|
||||
onClick={() => handleRemoveStore(store.id, store.name)}
|
||||
className="btn-danger btn-small"
|
||||
disabled={householdStores.length === 1}
|
||||
title={householdStores.length === 1 ? "Cannot remove last store" : ""}
|
||||
type="button"
|
||||
className="btn-primary btn-small"
|
||||
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
|
||||
>
|
||||
Remove
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<StoreAvailableItemsManager
|
||||
householdId={activeHousehold.id}
|
||||
store={store}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Add Store Section */}
|
||||
{isAdmin && (
|
||||
{isAdmin ? (
|
||||
<section className="manage-section">
|
||||
<h2>Add Store</h2>
|
||||
{!showAddStore ? (
|
||||
<button onClick={() => setShowAddStore(true)} className="btn-primary">
|
||||
+ Add Store
|
||||
<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>
|
||||
) : (
|
||||
<div className="add-store-panel">
|
||||
<button onClick={() => setShowAddStore(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
{loading ? (
|
||||
<p>Loading stores...</p>
|
||||
) : availableStores.length === 0 ? (
|
||||
<p className="empty-message">All available stores have been added.</p>
|
||||
) : (
|
||||
<div className="available-stores">
|
||||
{availableStores.map((store) => (
|
||||
<div key={store.id} className="available-store-card">
|
||||
<div className="store-info">
|
||||
<h3>{store.name}</h3>
|
||||
{store.location && <p className="store-location">{store.location}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddStore(store.id)}
|
||||
className="btn-primary btn-small"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createAvailableItem,
|
||||
deleteAvailableItem,
|
||||
getAvailableItems,
|
||||
updateAvailableItem,
|
||||
} from "../../api/availableItems";
|
||||
import { getLocationZones } from "../../api/stores";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
||||
@ -22,6 +24,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [items, setItems] = useState([]);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [catalogReady, setCatalogReady] = useState(true);
|
||||
const [catalogMessage, setCatalogMessage] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
@ -53,13 +56,29 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadItems(query);
|
||||
}, [isOpen, query, loadItems]);
|
||||
loadZones();
|
||||
}, [isOpen, query, loadItems, loadZones]);
|
||||
|
||||
const closeManager = () => {
|
||||
setIsOpen(false);
|
||||
@ -76,8 +95,16 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
||||
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`);
|
||||
if (editorItem?.item_id) {
|
||||
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
||||
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);
|
||||
setEditorItem(null);
|
||||
await loadItems(query);
|
||||
@ -95,7 +122,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
|
||||
try {
|
||||
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);
|
||||
await loadItems(query);
|
||||
} catch (error) {
|
||||
@ -104,10 +131,6 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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-header">
|
||||
<div>
|
||||
<h3>{store.name} Items</h3>
|
||||
<p>Manage the household/store items used for suggestions and store defaults.</p>
|
||||
<h3>{store.display_name || store.name} Items</h3>
|
||||
<p>Manage location-specific items used for suggestions and defaults.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -150,6 +173,17 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
placeholder="Search household/store items"
|
||||
disabled={!catalogReady}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary btn-small"
|
||||
disabled={!catalogReady}
|
||||
onClick={() => {
|
||||
setEditorItem(null);
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
@ -209,13 +243,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
>
|
||||
Edit Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => setPendingDeleteItem(item)}
|
||||
>
|
||||
Delete Item
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => setPendingDeleteItem(item)}
|
||||
>
|
||||
Delete Item
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -232,6 +268,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
<AvailableItemEditorModal
|
||||
isOpen={showEditor}
|
||||
item={editorItem}
|
||||
zones={zones}
|
||||
onCancel={() => {
|
||||
setShowEditor(false);
|
||||
setEditorItem(null);
|
||||
@ -244,7 +281,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
||||
description={
|
||||
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"
|
||||
|
||||
@ -4,7 +4,7 @@ import ClassificationSection from "../forms/ClassificationSection";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
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 [selectedImage, setSelectedImage] = useState(null);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
@ -47,15 +47,12 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
||||
onConfirm(selectedImage, classification);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="add-item-details-overlay" onClick={onCancel}>
|
||||
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
||||
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
||||
<div className="add-item-details-item-name">
|
||||
{itemName}
|
||||
</div>
|
||||
|
||||
{/* Image Section */}
|
||||
<div className="add-item-details-section">
|
||||
@ -63,6 +60,9 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
||||
imagePreview={imagePreview}
|
||||
onImageChange={handleImageChange}
|
||||
onImageRemove={handleImageRemove}
|
||||
title={null}
|
||||
cameraLabel="Use Image"
|
||||
galleryLabel="Choose Photo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -75,6 +75,8 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
zones={zones}
|
||||
title={null}
|
||||
fieldClass="add-item-details-field"
|
||||
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">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSkip} className="add-item-details-btn skip">
|
||||
Skip All
|
||||
</button>
|
||||
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
||||
Add Item
|
||||
</button>
|
||||
|
||||
@ -13,7 +13,7 @@ function buildPreview(item) {
|
||||
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 [itemName, setItemName] = useState("");
|
||||
const [itemType, setItemType] = useState("");
|
||||
@ -136,6 +136,7 @@ export default function AvailableItemEditorModal({ isOpen, item = null, onCancel
|
||||
onItemTypeChange={handleItemTypeChange}
|
||||
onItemGroupChange={setItemGroup}
|
||||
onZoneChange={setZone}
|
||||
zones={zones}
|
||||
fieldClass="available-item-editor-field"
|
||||
selectClass="available-item-editor-select"
|
||||
title="Store Classification (Optional)"
|
||||
|
||||
@ -4,7 +4,7 @@ import useActionToast from "../../hooks/useActionToast";
|
||||
import "../../styles/components/EditItemModal.css";
|
||||
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 [itemName, setItemName] = useState(item.item_name || "");
|
||||
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 zoneOptions = Array.isArray(zones) && zones.length > 0
|
||||
? zones.map((candidateZone) => candidateZone.name || candidateZone).filter(Boolean)
|
||||
: getZoneValues();
|
||||
|
||||
return (
|
||||
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||
@ -172,7 +175,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
||||
className="edit-modal-select"
|
||||
>
|
||||
<option value="">-- Select Zone --</option>
|
||||
{getZoneValues().map((candidateZone) => (
|
||||
{zoneOptions.map((candidateZone) => (
|
||||
<option key={candidateZone} value={candidateZone}>
|
||||
{candidateZone}
|
||||
</option>
|
||||
|
||||
@ -25,7 +25,7 @@ export default function StoreTabs() {
|
||||
onClick={() => setActiveStore(store)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="store-name">{store.name}</span>
|
||||
<span className="store-name">{store.display_name || store.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
||||
import { isTransientApiError } from '../api/offlineCache';
|
||||
import { AuthContext } from './AuthContext';
|
||||
|
||||
const ACTIVE_HOUSEHOLD_STORAGE_KEY = 'activeHouseholdId';
|
||||
@ -43,9 +44,15 @@ export const HouseholdProvider = ({ children }) => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[HouseholdContext] Failed to load households:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load households');
|
||||
setHouseholds([]);
|
||||
clearActiveHousehold();
|
||||
setError(
|
||||
err.response?.data?.error?.message ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to load households'
|
||||
);
|
||||
if (!isTransientApiError(err)) {
|
||||
setHouseholds([]);
|
||||
clearActiveHousehold();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
|
||||
@ -8,7 +8,6 @@ const DEFAULT_SETTINGS = {
|
||||
compactView: false,
|
||||
|
||||
// List Display
|
||||
defaultSortMode: "zone",
|
||||
showRecentlyBought: true,
|
||||
recentlyBoughtCount: 10,
|
||||
recentlyBoughtCollapsed: false,
|
||||
@ -22,6 +21,17 @@ const DEFAULT_SETTINGS = {
|
||||
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({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
@ -48,7 +58,9 @@ export const SettingsProvider = ({ children }) => {
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
||||
const normalized = normalizeSettings(parsed);
|
||||
setSettings(normalized);
|
||||
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse settings:", error);
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
@ -88,7 +100,7 @@ export const SettingsProvider = ({ children }) => {
|
||||
const updateSettings = (newSettings) => {
|
||||
if (!username) return;
|
||||
|
||||
const updated = { ...settings, ...newSettings };
|
||||
const updated = normalizeSettings({ ...settings, ...newSettings });
|
||||
setSettings(updated);
|
||||
|
||||
const storageKey = `user_preferences_${username}`;
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
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 { HouseholdContext } from './HouseholdContext';
|
||||
|
||||
export const StoreContext = createContext({
|
||||
stores: [],
|
||||
activeStore: null,
|
||||
zones: [],
|
||||
loading: false,
|
||||
zonesLoading: false,
|
||||
error: null,
|
||||
setActiveStore: () => { },
|
||||
refreshStores: () => { },
|
||||
refreshZones: () => { },
|
||||
});
|
||||
|
||||
export const StoreProvider = ({ children }) => {
|
||||
@ -17,7 +21,9 @@ export const StoreProvider = ({ children }) => {
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const [stores, setStores] = useState([]);
|
||||
const [activeStore, setActiveStoreState] = useState(null);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [zonesLoading, setZonesLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load stores when household changes
|
||||
@ -28,6 +34,7 @@ export const StoreProvider = ({ children }) => {
|
||||
// Clear state when logged out or no household
|
||||
setStores([]);
|
||||
setActiveStoreState(null);
|
||||
setZones([]);
|
||||
}
|
||||
}, [token, activeHousehold?.id]);
|
||||
|
||||
@ -40,7 +47,7 @@ export const StoreProvider = ({ children }) => {
|
||||
const savedStoreId = localStorage.getItem(storageKey);
|
||||
|
||||
if (savedStoreId) {
|
||||
const store = stores.find(s => s.id === parseInt(savedStoreId));
|
||||
const store = stores.find(s => String(s.id) === String(savedStoreId));
|
||||
if (store) {
|
||||
console.log('[StoreContext] Found saved store:', store);
|
||||
setActiveStoreState(store);
|
||||
@ -55,6 +62,14 @@ export const StoreProvider = ({ children }) => {
|
||||
localStorage.setItem(storageKey, defaultStore.id);
|
||||
}, [stores, activeHousehold]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && activeHousehold?.id && activeStore?.id) {
|
||||
loadZones();
|
||||
} else {
|
||||
setZones([]);
|
||||
}
|
||||
}, [token, activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
const loadStores = async () => {
|
||||
if (!token || !activeHousehold) return;
|
||||
|
||||
@ -67,8 +82,15 @@ export const StoreProvider = ({ children }) => {
|
||||
setStores(response.data);
|
||||
} catch (err) {
|
||||
console.error('[StoreContext] Failed to load stores:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load stores');
|
||||
setStores([]);
|
||||
setError(
|
||||
err.response?.data?.error?.message ||
|
||||
err.response?.data?.message ||
|
||||
'Failed to load stores'
|
||||
);
|
||||
if (!isTransientApiError(err)) {
|
||||
setStores([]);
|
||||
setActiveStoreState(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -78,17 +100,37 @@ export const StoreProvider = ({ children }) => {
|
||||
setActiveStoreState(store);
|
||||
if (store && activeHousehold) {
|
||||
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 = {
|
||||
stores,
|
||||
activeStore,
|
||||
zones,
|
||||
loading,
|
||||
zonesLoading,
|
||||
error,
|
||||
setActiveStore,
|
||||
refreshStores: loadStores,
|
||||
refreshZones: loadZones,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
updateItemWithClassification
|
||||
} from "../api/list";
|
||||
import { getHouseholdMembers } from "../api/households";
|
||||
import SortDropdown from "../components/common/SortDropdown";
|
||||
import ListSearchInput from "../components/common/ListSearchInput";
|
||||
import AddItemForm from "../components/forms/AddItemForm";
|
||||
import NoHouseholdState from "../components/household/NoHouseholdState";
|
||||
import GroceryListItem from "../components/items/GroceryListItem";
|
||||
@ -33,40 +33,54 @@ import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
function sortItemsForMode(items, sortMode) {
|
||||
function sortItemsByZone(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) => {
|
||||
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);
|
||||
sorted.sort((a, b) => {
|
||||
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);
|
||||
|
||||
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
||||
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
||||
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
||||
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||
const aZoneIndex = Number.isInteger(a.zone_sort_order) ? a.zone_sort_order : ZONE_FLOW.indexOf(a.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 bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||
|
||||
const zoneCompare = aIndex - bIndex;
|
||||
if (zoneCompare !== 0) return zoneCompare;
|
||||
const zoneCompare = aIndex - bIndex;
|
||||
if (zoneCompare !== 0) return zoneCompare;
|
||||
|
||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||
if (typeCompare !== 0) return typeCompare;
|
||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||
if (typeCompare !== 0) return typeCompare;
|
||||
|
||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||
if (groupCompare !== 0) return groupCompare;
|
||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||
if (groupCompare !== 0) return groupCompare;
|
||||
|
||||
return a.item_name.localeCompare(b.item_name);
|
||||
});
|
||||
}
|
||||
return a.item_name.localeCompare(b.item_name);
|
||||
});
|
||||
|
||||
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) {
|
||||
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
|
||||
|
||||
@ -79,7 +93,6 @@ function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
||||
|
||||
|
||||
export default function GroceryList() {
|
||||
const pageTitle = "Grocery List";
|
||||
const { userId } = useContext(AuthContext);
|
||||
const {
|
||||
activeHousehold,
|
||||
@ -87,7 +100,7 @@ export default function GroceryList() {
|
||||
loading: householdLoading,
|
||||
hasLoaded: householdsLoaded
|
||||
} = useContext(HouseholdContext);
|
||||
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
||||
const { activeStore, stores, zones, loading: storeLoading } = useContext(StoreContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
const toast = useActionToast();
|
||||
const { enqueueImageUpload } = useUploadQueue();
|
||||
@ -103,7 +116,7 @@ export default function GroceryList() {
|
||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
const [householdMembers, setHouseholdMembers] = useState([]);
|
||||
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
|
||||
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
|
||||
const [listSearchQuery, setListSearchQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
return sortItemsForMode(items, sortMode);
|
||||
}, [items, sortMode]);
|
||||
return sortItemsByZone(filterItemsForSearch(items, normalizedListSearchQuery));
|
||||
}, [items, normalizedListSearchQuery]);
|
||||
|
||||
const visibleRecentlyBoughtItems = useMemo(
|
||||
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
||||
@ -538,42 +554,6 @@ export default function GroceryList() {
|
||||
}
|
||||
}, [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(() => {
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
@ -614,7 +594,9 @@ export default function GroceryList() {
|
||||
|
||||
setItems(nextItems);
|
||||
|
||||
const nextSortedItems = sortItemsForMode(nextItems, sortMode);
|
||||
const nextSortedItems = sortItemsByZone(
|
||||
filterItemsForSearch(nextItems, normalizedListSearchQuery)
|
||||
);
|
||||
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
|
||||
|
||||
setBuyModalState(
|
||||
@ -633,7 +615,7 @@ export default function GroceryList() {
|
||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||
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) => {
|
||||
setBuyModalState({
|
||||
@ -772,7 +754,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<p style={{ textAlign: "center", marginTop: "2rem", color: "var(--text-secondary)" }}>
|
||||
Loading households...
|
||||
</p>
|
||||
@ -785,7 +766,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<NoHouseholdState />
|
||||
</div>
|
||||
</div>
|
||||
@ -796,7 +776,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||
Loading stores...
|
||||
</p>
|
||||
@ -809,7 +788,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<div className="glist-empty-state">
|
||||
<h2 className="glist-empty-title">No stores found</h2>
|
||||
<p className="glist-empty-text">
|
||||
@ -837,7 +815,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<StoreTabs />
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||
Loading stores...
|
||||
@ -851,7 +828,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
<StoreTabs />
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
||||
</div>
|
||||
@ -863,8 +839,6 @@ export default function GroceryList() {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">{pageTitle}</h1>
|
||||
|
||||
<StoreTabs />
|
||||
|
||||
{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);
|
||||
return Object.keys(grouped).map(zone => {
|
||||
const isCollapsed = collapsedZones[zone];
|
||||
const isCollapsed = isListSearchActive ? false : collapsedZones[zone];
|
||||
const itemCount = grouped[zone].length;
|
||||
return (
|
||||
<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 && (
|
||||
@ -993,8 +960,8 @@ export default function GroceryList() {
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
zones={zones}
|
||||
onConfirm={handleAddWithDetails}
|
||||
onSkip={handleAddDetailsSkip}
|
||||
onCancel={handleAddDetailsCancel}
|
||||
/>
|
||||
)}
|
||||
@ -1012,6 +979,7 @@ export default function GroceryList() {
|
||||
{showEditModal && editingItem && (
|
||||
<EditItemModal
|
||||
item={editingItem}
|
||||
zones={zones}
|
||||
onSave={handleEditSave}
|
||||
onCancel={handleEditCancel}
|
||||
onImageUpdate={handleImageAdded}
|
||||
|
||||
@ -137,12 +137,6 @@ export default function Settings() {
|
||||
updateSettings({ [key]: parseInt(value, 10) });
|
||||
};
|
||||
|
||||
|
||||
const handleSelectChange = (key, value) => {
|
||||
updateSettings({ [key]: value });
|
||||
};
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm("Reset all settings to defaults?")) {
|
||||
resetSettings();
|
||||
@ -252,24 +246,6 @@ export default function Settings() {
|
||||
<div className="settings-section">
|
||||
<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">
|
||||
<label className="settings-label">
|
||||
<input
|
||||
|
||||
@ -15,14 +15,28 @@
|
||||
.add-item-details-modal {
|
||||
background: var(--modal-bg);
|
||||
border-radius: var(--border-radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
max-width: 500px;
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
@ -38,13 +52,15 @@
|
||||
}
|
||||
|
||||
.add-item-details-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding-bottom: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.add-item-details-section:last-of-type {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.add-item-details-section-title {
|
||||
@ -55,6 +71,68 @@
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
min-height: 120px;
|
||||
}
|
||||
@ -119,22 +197,33 @@
|
||||
}
|
||||
|
||||
/* Classification Section */
|
||||
.add-item-details-modal .classification-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
margin: 0;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-tight);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-item-details-select {
|
||||
width: 100%;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: var(--font-size-base);
|
||||
min-height: 2.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: var(--font-size-sm);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
box-sizing: border-box;
|
||||
@ -153,7 +242,7 @@
|
||||
.add-item-details-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: var(--border-width-thin) solid var(--color-border-light);
|
||||
}
|
||||
@ -162,38 +251,36 @@
|
||||
flex: 1;
|
||||
padding: var(--button-padding-y) var(--button-padding-x);
|
||||
font-size: var(--font-size-base);
|
||||
border: none;
|
||||
border: var(--border-width-thin) solid transparent;
|
||||
border-radius: var(--button-border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--transition-base);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.add-item-details-btn.cancel {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--button-secondary-bg);
|
||||
border-color: var(--button-secondary-border);
|
||||
color: var(--button-secondary-text);
|
||||
}
|
||||
|
||||
.add-item-details-btn.cancel:hover {
|
||||
background: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.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);
|
||||
background: var(--button-secondary-hover-bg);
|
||||
border-color: var(--button-secondary-border-hover);
|
||||
color: var(--button-secondary-text);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.add-item-details-btn.confirm:hover {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@ -225,6 +312,10 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.add-item-details-field {
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.add-item-details-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@ -383,8 +383,8 @@ body.dark-mode .invite-status-badge.is-used {
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
padding: 0.95rem 1rem;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
@ -408,74 +408,48 @@ body.dark-mode .member-card:hover {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.member-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.24rem 0.55rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
width: fit-content;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.88rem;
|
||||
text-transform: capitalize;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-role-owner {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.member-role-admin {
|
||||
background: rgba(30, 144, 255, 0.16);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.member-role-member,
|
||||
.member-role-viewer {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
@ -483,7 +457,8 @@ body.dark-mode .member-card:hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.24rem 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
color: #a16207;
|
||||
@ -497,7 +472,7 @@ body.dark-mode .member-card:hover {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -93,6 +93,94 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@ -172,4 +260,23 @@
|
||||
.available-store-card button {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,13 +14,6 @@
|
||||
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 {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xl);
|
||||
@ -359,10 +352,29 @@
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
/* Sorting dropdown */
|
||||
.glist-sort {
|
||||
/* List search */
|
||||
.glist-search {
|
||||
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);
|
||||
font-size: var(--font-size-base);
|
||||
border-radius: var(--border-radius-sm);
|
||||
@ -371,6 +383,48 @@
|
||||
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 */
|
||||
.glist-image-upload {
|
||||
margin: 0.5em 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user