feature-custom-store-locations #4
@ -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`);
|
||||||
|
|||||||
@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
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 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';
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user