polished implementation of new artchitecture
This commit is contained in:
parent
213134c4a5
commit
31eda793ab
@ -115,19 +115,15 @@ exports.refreshInviteCode = async (req, res) => {
|
||||
exports.joinHousehold = async (req, res) => {
|
||||
try {
|
||||
const { inviteCode } = req.params;
|
||||
|
||||
if (!inviteCode) {
|
||||
return res.status(400).json({ error: "Invite code is required" });
|
||||
}
|
||||
if (!inviteCode) return res.status(400).json({ error: "Invite code is required" });
|
||||
|
||||
const result = await householdModel.joinHousehold(
|
||||
inviteCode.toUpperCase(),
|
||||
req.user.id
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: "Invalid or expired invite code" });
|
||||
}
|
||||
if (!result) return res.status(404).json({ error: "Invalid or expired invite code" });
|
||||
|
||||
|
||||
if (result.alreadyMember) {
|
||||
return res.status(200).json({
|
||||
|
||||
@ -7,7 +7,6 @@ exports.test = async (req, res) => {
|
||||
};
|
||||
|
||||
exports.getAllUsers = async (req, res) => {
|
||||
console.log(req);
|
||||
const users = await User.getAllUsers();
|
||||
res.json(users);
|
||||
};
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
const pool = require("../db/pool");
|
||||
|
||||
exports.ROLES = {
|
||||
VIEWER: "viewer",
|
||||
EDITOR: "editor",
|
||||
ADMIN: "admin",
|
||||
SYSTEM_ADMIN: "system_admin",
|
||||
USER: "user",
|
||||
}
|
||||
|
||||
exports.findByUsername = async (username) => {
|
||||
|
||||
@ -4,8 +4,9 @@ const requireRole = require("../middleware/rbac");
|
||||
const usersController = require("../controllers/users.controller");
|
||||
const { ROLES } = require("../models/user.model");
|
||||
|
||||
router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers);
|
||||
router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole);
|
||||
router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser);
|
||||
// router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers);
|
||||
router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers);
|
||||
router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole);
|
||||
router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -48,7 +48,7 @@ function App() {
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RoleGuard allowed={[ROLES.ADMIN]}>
|
||||
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
|
||||
<AdminPanel />
|
||||
</RoleGuard>
|
||||
}
|
||||
|
||||
285
frontend/src/components/admin/StoreManagement.jsx
Normal file
285
frontend/src/components/admin/StoreManagement.jsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
|
||||
import "../../styles/components/admin/StoreManagement.css";
|
||||
|
||||
export default function StoreManagement() {
|
||||
const [stores, setStores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingStore, setEditingStore] = useState(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
zones: []
|
||||
});
|
||||
const [newZone, setNewZone] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadStores();
|
||||
}, []);
|
||||
|
||||
const loadStores = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getAllStores();
|
||||
setStores(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stores:", error);
|
||||
alert("Failed to load stores");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
||||
await createStore(formData.name, zonesJson);
|
||||
await loadStores();
|
||||
setShowCreateForm(false);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
} catch (error) {
|
||||
console.error("Failed to create store:", error);
|
||||
alert(error.response?.data?.error || "Failed to create store");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editingStore || !formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
||||
await updateStore(editingStore.id, formData.name, zonesJson);
|
||||
await loadStores();
|
||||
setEditingStore(null);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
} catch (error) {
|
||||
console.error("Failed to update store:", error);
|
||||
alert(error.response?.data?.error || "Failed to update store");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (storeId, storeName) => {
|
||||
if (!confirm(`Delete store "${storeName}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
await deleteStore(storeId);
|
||||
await loadStores();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete store:", error);
|
||||
alert(error.response?.data?.error || "Failed to delete store");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (store) => {
|
||||
console.log('Starting edit for store:', store);
|
||||
setEditingStore(store);
|
||||
let zones = [];
|
||||
if (store.default_zones) {
|
||||
try {
|
||||
let parsed = typeof store.default_zones === 'string'
|
||||
? JSON.parse(store.default_zones)
|
||||
: store.default_zones;
|
||||
|
||||
// Handle both formats: direct array or object with zones property
|
||||
if (Array.isArray(parsed)) {
|
||||
zones = parsed;
|
||||
} else if (parsed && Array.isArray(parsed.zones)) {
|
||||
zones = parsed.zones;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse zones:', e);
|
||||
zones = [];
|
||||
}
|
||||
}
|
||||
console.log('Parsed zones:', zones);
|
||||
setFormData({
|
||||
name: store.name,
|
||||
zones: zones
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingStore(null);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setShowCreateForm(true);
|
||||
setEditingStore(null);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
};
|
||||
|
||||
const addZone = () => {
|
||||
const zone = newZone.trim();
|
||||
if (!zone) return;
|
||||
if (formData.zones.includes(zone)) {
|
||||
alert("Zone already exists");
|
||||
return;
|
||||
}
|
||||
setFormData({ ...formData, zones: [...formData.zones, zone] });
|
||||
setNewZone("");
|
||||
};
|
||||
|
||||
const removeZone = (index) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
zones: formData.zones.filter((_, i) => i !== index)
|
||||
});
|
||||
};
|
||||
|
||||
const parseZones = (defaultZones) => {
|
||||
if (!defaultZones) return [];
|
||||
try {
|
||||
let parsed = typeof defaultZones === 'string' ? JSON.parse(defaultZones) : defaultZones;
|
||||
|
||||
// Handle both formats: direct array or object with zones property
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
} else if (parsed && Array.isArray(parsed.zones)) {
|
||||
return parsed.zones;
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-management">
|
||||
<div className="store-management-header">
|
||||
<h2>Store Management</h2>
|
||||
{!showCreateForm && !editingStore && (
|
||||
<button onClick={startCreate} className="btn-primary">
|
||||
+ Create Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(showCreateForm || editingStore) && (
|
||||
<div className="store-form-card">
|
||||
<h3>{editingStore ? "Edit Store" : "Create New Store"}</h3>
|
||||
<form onSubmit={editingStore ? handleUpdate : handleCreate}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="storeName">Store Name *</label>
|
||||
<input
|
||||
id="storeName"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Costco Richmond"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultZones">Default Zones</label>
|
||||
<div className="zone-input-container">
|
||||
<input
|
||||
id="zoneInput"
|
||||
type="text"
|
||||
value={newZone}
|
||||
onChange={(e) => setNewZone(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addZone())}
|
||||
placeholder="Enter zone name and press Enter or click Add"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addZone}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
Add Zone
|
||||
</button>
|
||||
</div>
|
||||
{formData.zones.length > 0 && (
|
||||
<div className="zones-list">
|
||||
{formData.zones.map((zone, index) => (
|
||||
<div key={index} className="zone-chip">
|
||||
<span>{zone}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeZone(index)}
|
||||
className="zone-remove"
|
||||
aria-label="Remove zone"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="form-hint">
|
||||
Add zones that will be used for organizing items in this store
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={editingStore ? cancelEdit : () => setShowCreateForm(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingStore ? "Update Store" : "Create Store"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stores List */}
|
||||
<div className="stores-grid">
|
||||
{loading ? (
|
||||
<p>Loading stores...</p>
|
||||
) : stores.length === 0 ? (
|
||||
<p className="empty-message">No stores found. Create one to get started.</p>
|
||||
) : (
|
||||
stores.map((store) => (
|
||||
<div key={store.id} className="store-admin-card">
|
||||
<div className="store-admin-info">
|
||||
<h3>{store.name}</h3>
|
||||
{store.default_zones && parseZones(store.default_zones).length > 0 && (
|
||||
<div className="store-zones-display">
|
||||
<p className="zones-label">Default Zones:</p>
|
||||
<div className="zones-list-display">
|
||||
{parseZones(store.default_zones).map((zone, idx) => (
|
||||
<span key={idx} className="zone-badge">{zone}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="store-meta">ID: {store.id}</p>
|
||||
</div>
|
||||
<div className="store-admin-actions">
|
||||
<button
|
||||
onClick={() => startEdit(store)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(store.id, store.name)}
|
||||
className="btn-danger btn-small"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { ROLES } from "../../constants/roles";
|
||||
|
||||
export default function UserRoleCard({ user, onRoleChange }) {
|
||||
console.log(user)
|
||||
|
||||
return (
|
||||
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
|
||||
<div className="user-info">
|
||||
|
||||
@ -15,7 +15,7 @@ export default function Navbar() {
|
||||
<Link to="/manage">Manage</Link>
|
||||
<Link to="/settings">Settings</Link>
|
||||
|
||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||
{role === "system_admin" && <Link to="/admin">Admin</Link>}
|
||||
</div>
|
||||
|
||||
<HouseholdSwitcher />
|
||||
|
||||
@ -4,7 +4,7 @@ import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import "../../styles/components/manage/CreateJoinHousehold.css";
|
||||
|
||||
export default function CreateJoinHousehold({ onClose }) {
|
||||
const { loadHouseholds } = useContext(HouseholdContext);
|
||||
const { refreshHouseholds } = useContext(HouseholdContext);
|
||||
const [mode, setMode] = useState("create"); // "create" or "join"
|
||||
const [householdName, setHouseholdName] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
@ -20,7 +20,7 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
|
||||
try {
|
||||
await createHousehold(householdName);
|
||||
await loadHouseholds();
|
||||
await refreshHouseholds();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create household:", err);
|
||||
@ -38,8 +38,9 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
console.log("Joining household with invite code:", inviteCode);
|
||||
await joinHousehold(inviteCode);
|
||||
await loadHouseholds();
|
||||
await refreshHouseholds();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to join household:", err);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
deleteHousehold,
|
||||
getHouseholdMembers,
|
||||
@ -116,7 +116,7 @@ export default function ManageHousehold() {
|
||||
return (
|
||||
<div className="manage-household">
|
||||
{/* Household Name Section */}
|
||||
<section className="manage-section">
|
||||
<section key="household-name" className="manage-section">
|
||||
<h2>Household Name</h2>
|
||||
{editingName ? (
|
||||
<div className="edit-name-form">
|
||||
@ -150,7 +150,7 @@ export default function ManageHousehold() {
|
||||
|
||||
{/* Invite Code Section */}
|
||||
{isAdmin && (
|
||||
<section className="manage-section">
|
||||
<section key="invite-code" className="manage-section">
|
||||
<h2>Invite Code</h2>
|
||||
<p className="section-description">
|
||||
Share this code with others to invite them to your household.
|
||||
@ -160,10 +160,10 @@ export default function ManageHousehold() {
|
||||
{showInviteCode ? "Hide Code" : "Show Code"}
|
||||
</button>
|
||||
{showInviteCode && (
|
||||
<>
|
||||
<React.Fragment key="invite-code-display">
|
||||
<code className="invite-code">{activeHousehold.invite_code}</code>
|
||||
<button onClick={copyInviteCode} className="btn-secondary">Copy</button>
|
||||
</>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<button onClick={handleRefreshInvite} className="btn-secondary">
|
||||
Generate New Code
|
||||
@ -173,33 +173,37 @@ export default function ManageHousehold() {
|
||||
)}
|
||||
|
||||
{/* Members Section */}
|
||||
<section className="manage-section">
|
||||
<section key="members" className="manage-section">
|
||||
<h2>Members ({members.length})</h2>
|
||||
{loading ? (
|
||||
<p>Loading members...</p>
|
||||
) : (
|
||||
<div className="members-list">
|
||||
{members.map((member) => (
|
||||
<div key={member.user_id} className="member-card">
|
||||
<div key={member.id} className="member-card">
|
||||
<div className="member-info">
|
||||
<span className="member-name">
|
||||
{member.username}
|
||||
{member.user_id === parseInt(userId) && " (You)"}
|
||||
</span>
|
||||
<span className={`member-role ${member.role}`}>
|
||||
<span className="member-role">
|
||||
{member.role}
|
||||
</span>
|
||||
<span className="member-name">
|
||||
{`
|
||||
${member.username}
|
||||
[${member.id}]
|
||||
${(member.id === parseInt(userId) ? " (You)" : "")}
|
||||
`}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{isAdmin && member.user_id !== parseInt(userId) && (
|
||||
{isAdmin && member.id !== parseInt(userId) && (
|
||||
<div className="member-actions">
|
||||
<button
|
||||
onClick={() => handleUpdateRole(member.user_id, member.role)}
|
||||
onClick={() => handleUpdateRole(member.id, member.role)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user_id, member.username)}
|
||||
onClick={() => handleRemoveMember(member.id, member.username)}
|
||||
className="btn-danger btn-small"
|
||||
>
|
||||
Remove
|
||||
@ -214,7 +218,7 @@ export default function ManageHousehold() {
|
||||
|
||||
{/* Danger Zone */}
|
||||
{isAdmin && (
|
||||
<section className="manage-section danger-zone">
|
||||
<section key="danger-zone" className="manage-section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<p className="section-description">
|
||||
Deleting a household is permanent and will delete all lists, items, and history.
|
||||
|
||||
@ -2,5 +2,7 @@ export const ROLES = {
|
||||
VIEWER: "viewer",
|
||||
EDITOR: "editor",
|
||||
ADMIN: "admin",
|
||||
SYSTEM_ADMIN: "system_admin",
|
||||
USER: "user",
|
||||
UP_TO_ADMIN: ["viewer", "editor", "admin"],
|
||||
};
|
||||
@ -1,14 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAllUsers, updateRole } from "../api/users";
|
||||
import StoreManagement from "../components/admin/StoreManagement";
|
||||
import UserRoleCard from "../components/common/UserRoleCard";
|
||||
import "../styles/UserRoleCard.css";
|
||||
import "../styles/pages/AdminPanel.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
|
||||
async function loadUsers() {
|
||||
const allUsers = await getAllUsers();
|
||||
console.log("Users found:", users);
|
||||
setUsers(allUsers.data);
|
||||
}
|
||||
|
||||
@ -23,10 +26,28 @@ export default function AdminPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4" style={{ minHeight: '100vh' }}>
|
||||
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
|
||||
<div className="mt-4">
|
||||
<div className="admin-panel-body">
|
||||
<div className="admin-panel-container">
|
||||
<h1 className="admin-panel-title">Admin Panel</h1>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "users" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("users")}
|
||||
>
|
||||
User Management
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === "stores" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("stores")}
|
||||
>
|
||||
Store Management
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-content">
|
||||
{activeTab === "users" && (
|
||||
<div className="users-section">
|
||||
{users.map((user) => (
|
||||
<UserRoleCard
|
||||
key={user.id}
|
||||
@ -35,6 +56,10 @@ export default function AdminPanel() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "stores" && <StoreManagement />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -30,11 +30,14 @@ import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
|
||||
export default function GroceryList() {
|
||||
const { role } = useContext(AuthContext);
|
||||
const { role: systemRole } = useContext(AuthContext);
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { activeStore } = useContext(StoreContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
|
||||
// Get household role for permissions
|
||||
const householdRole = activeHousehold?.role;
|
||||
|
||||
// === State === //
|
||||
const [items, setItems] = useState([]);
|
||||
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
||||
@ -421,7 +424,7 @@ export default function GroceryList() {
|
||||
|
||||
|
||||
const handleLongPress = useCallback(async (item) => {
|
||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||
if (!householdRole || householdRole === 'viewer') return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
@ -436,7 +439,7 @@ export default function GroceryList() {
|
||||
setEditingItem({ ...item, classification: null });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, role]);
|
||||
}, [activeHousehold?.id, activeStore?.id, householdRole]);
|
||||
|
||||
|
||||
// === Edit Modal Handlers ===
|
||||
@ -524,7 +527,7 @@ export default function GroceryList() {
|
||||
|
||||
<StoreTabs />
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||
{householdRole && householdRole !== 'viewer' && showAddForm && (
|
||||
<AddItemForm
|
||||
onAdd={handleAdd}
|
||||
onSuggest={handleSuggest}
|
||||
@ -564,13 +567,13 @@ export default function GroceryList() {
|
||||
allItems={sortedItems}
|
||||
compact={settings.compactView}
|
||||
onClick={(id, quantity) =>
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
||||
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
|
||||
}
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||||
}
|
||||
onLongPress={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@ -625,10 +628,10 @@ export default function GroceryList() {
|
||||
compact={settings.compactView}
|
||||
onClick={null}
|
||||
onImageAdded={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||||
}
|
||||
onLongPress={
|
||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@ -649,7 +652,7 @@ export default function GroceryList() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
|
||||
{householdRole && householdRole !== 'viewer' && (
|
||||
<FloatingActionButton
|
||||
isOpen={showAddForm}
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
|
||||
278
frontend/src/styles/components/admin/StoreManagement.css
Normal file
278
frontend/src/styles/components/admin/StoreManagement.css
Normal file
@ -0,0 +1,278 @@
|
||||
/* Store Management Component */
|
||||
.store-management {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.store-management-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.store-management-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Form Card */
|
||||
.store-form-card {
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.store-form-card h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Zone Input Section */
|
||||
.zone-input-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.zone-input-container input {
|
||||
flex: 1 1 300px;
|
||||
width: auto !important;
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.zone-input-container .btn-small {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Zones List (Chips in Form) */
|
||||
.zones-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.zone-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zone-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.zone-remove:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Store Zones Display (in cards) */
|
||||
.store-zones-display {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.zones-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.zones-list-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.zone-badge {
|
||||
display: inline-block;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Stores Grid */
|
||||
.stores-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.store-admin-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.store-admin-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.store-admin-info h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.store-zones {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.store-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.store-admin-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.store-admin-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 3rem;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.store-management {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.store-management-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.store-management-header button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stores-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-form-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,7 @@
|
||||
/* Household Name Section */
|
||||
.name-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
@ -66,6 +67,7 @@
|
||||
/* Invite Code Section */
|
||||
.invite-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@ -92,6 +94,7 @@
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem;
|
||||
@ -125,18 +128,10 @@
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.member-role.admin {
|
||||
background: var(--primary-light, rgba(0, 122, 255, 0.1));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.member-role.member {
|
||||
background: var(--text-secondary-light, rgba(128, 128, 128, 0.1));
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@ -1,9 +1,109 @@
|
||||
/* Admin Panel - uses utility classes */
|
||||
/* Responsive adjustments only */
|
||||
/* Admin Panel Layout */
|
||||
.admin-panel-body {
|
||||
min-height: 100vh;
|
||||
background: var(--background);
|
||||
padding: 2rem 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-panel-container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.admin-panel-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.admin-content {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Users Section */
|
||||
.users-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-panel-page {
|
||||
padding: var(--spacing-md) !important;
|
||||
.admin-panel-body {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.admin-panel-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-panel-body {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.admin-panel-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user