From 59182cab2d97dd53f5be48874766bd2f81d8f326 Mon Sep 17 00:00:00 2001 From: Graeme B Date: Sun, 8 Mar 2026 00:44:50 -0800 Subject: [PATCH 1/2] minecraft --- backend/models/user.py | 18 ++ backend/routes/auth.py | 60 +++++- .../ProducerDashboard/ProducerDashboard.css | 177 ++++++++++++++++++ .../ProducerDashboard/ProducerDashboard.jsx | 105 +++++++++++ .../RetailerDashboard/RetailerDashboard.css | 158 ++++++++++++++++ .../RetailerDashboard/RetailerDashboard.jsx | 102 ++++++++++ frontend/src/pages/Dashboard.css | 8 +- frontend/src/pages/Dashboard.jsx | 30 ++- frontend/src/pages/Onboard.jsx | 141 +++++++------- frontend/src/utils/api.js | 26 ++- 10 files changed, 731 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/ProducerDashboard/ProducerDashboard.css create mode 100644 frontend/src/components/ProducerDashboard/ProducerDashboard.jsx create mode 100644 frontend/src/components/RetailerDashboard/RetailerDashboard.css create mode 100644 frontend/src/components/RetailerDashboard/RetailerDashboard.jsx diff --git a/backend/models/user.py b/backend/models/user.py index a95eb01..196b506 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -55,8 +55,19 @@ class Producer(User): company_description = db.Column("description", db.Text) images = db.Column(db.LargeBinary) + inventory = db.relationship("Inventory", backref="producer_user", uselist=False, foreign_keys="Inventory.producer_id") + __mapper_args__: ClassVar[dict] = {"polymorphic_identity": "producer"} + def to_dict(self): + return { + **super().to_dict(), + "company_name": self.company_name, + "primary_address": self.primary_address, + "company_description": self.company_description, + "inventory_id": self.inventory.id if self.inventory else None, + } + class Retailer(User): __tablename__: ClassVar[str] = "retailers" @@ -67,6 +78,13 @@ class Retailer(User): __mapper_args__: ClassVar[dict] = {"polymorphic_identity": "retailer"} + def to_dict(self): + return { + **super().to_dict(), + "company_name": self.company_name, + "store_address": self.store_address, + } + class Consumer(User): __tablename__: ClassVar[str] = "consumers" diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 4e61450..00ea186 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,10 +1,13 @@ import logging import os -from flask import Blueprint, jsonify, redirect, session, url_for +from flask import Blueprint, jsonify, redirect, request, session, url_for +from sqlalchemy import text from config.oauth import oauth -from models.user import User +from database import db +from models.inventory import Inventory +from models.user import Producer, Retailer, User logger = logging.getLogger(__name__) @@ -36,12 +39,7 @@ def callback(): picture=user_info.get("picture"), ) - session["user"] = { - "id": user.id, - "email": user.email, - "picture": user.picture, - "user_type": user.user_type, - } + session["user"] = {**user.to_dict(), "picture": user.picture} session["authenticated"] = True frontend_url = os.environ.get("CORS_ORIGINS", "http://localhost:5173") @@ -60,6 +58,52 @@ def logout(): return jsonify({"success": True}) +@auth_bp.route("/onboard", methods=["POST"]) +def onboard(): + if not session.get("authenticated"): + return jsonify({"error": "Not authenticated"}), 401 + + data = request.get_json() + user_type = data.get("user_type", "").lower() + company_name = data.get("company_name", "").strip() + address = data.get("address", "").strip() + description = data.get("description", "").strip() + + if user_type not in ("producer", "retailer"): + return jsonify({"error": "user_type must be producer or retailer"}), 400 + if not company_name: + return jsonify({"error": "company_name is required"}), 400 + if not address: + return jsonify({"error": "address is required"}), 400 + + user_id = session["user"]["id"] + + # Remove the existing consumer row and update the base user type + db.session.execute(text("DELETE FROM consumers WHERE id = :id"), {"id": user_id}) + db.session.execute(text("UPDATE users SET user_type = :ut WHERE id = :id"), {"ut": user_type, "id": user_id}) + + if user_type == "producer": + db.session.execute( + text("INSERT INTO producers (id, company_name, primary_address, description) VALUES (:id, :cn, :addr, :desc)"), + {"id": user_id, "cn": company_name, "addr": address, "desc": description or None}, + ) + db.session.flush() + db.session.add(Inventory(producer_id=user_id)) + else: + db.session.execute( + text("INSERT INTO retailers (id, company_name, store_address) VALUES (:id, :cn, :addr)"), + {"id": user_id, "cn": company_name, "addr": address}, + ) + + db.session.commit() + + user = db.session.get(User, user_id) + db.session.refresh(user) + session["user"] = {**user.to_dict(), "picture": user.picture} + + return jsonify({"user": session["user"]}), 200 + + @auth_bp.route("/user") def get_user(): if session.get("authenticated") and "user" in session: diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.css b/frontend/src/components/ProducerDashboard/ProducerDashboard.css new file mode 100644 index 0000000..574c2e9 --- /dev/null +++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.css @@ -0,0 +1,177 @@ +.producer-dashboard { + padding: 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; +} + +.dashboard-header h1 { + font-family: 'Playfair Display', serif; + color: #3e2f1c; + margin: 0 0 0.25rem; +} + +.dashboard-subtitle { + color: #7a5c3e; + font-size: 0.9rem; + margin: 0; +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #fff; + border: 1px solid #d4c4a8; + border-radius: 8px; + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.stat-card.warn { + border-color: #c1694f; +} + +.stat-number { + font-size: 2rem; + font-weight: 700; + color: #3e2f1c; + font-family: 'Playfair Display', serif; +} + +.stat-card.warn .stat-number { + color: #c1694f; +} + +.stat-label { + font-size: 0.85rem; + color: #7a5c3e; +} + +.dashboard-section { + background: #fff; + border: 1px solid #d4c4a8; + border-radius: 8px; + padding: 1.5rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.section-header h2 { + font-family: 'Playfair Display', serif; + color: #3e2f1c; + margin: 0; + font-size: 1.2rem; +} + +.stock-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.stock-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1rem; + background: #faf6ef; + border-radius: 6px; + border: 1px solid #e8dfd0; +} + +.stock-card-main { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.stock-name { + font-weight: 600; + color: #3e2f1c; +} + +.stock-sku { + font-size: 0.8rem; + color: #7a5c3e; +} + +.stock-card-meta { + display: flex; + gap: 1.5rem; + align-items: center; + font-size: 0.9rem; + color: #7a5c3e; +} + +.stock-qty { + font-weight: 600; + color: #4a7c59; +} + +.stock-qty.low { + color: #c1694f; +} + +.stock-price { + color: #3e2f1c; +} + +.empty-state { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.btn-primary { + background: #4a7c59; + color: #fff; + border: none; + padding: 0.6rem 1.25rem; + border-radius: 6px; + font-family: 'Nunito', sans-serif; + font-size: 0.95rem; + cursor: pointer; +} + +.btn-primary:hover { + background: #3a6347; +} + +.btn-link { + background: none; + border: none; + color: #4a7c59; + cursor: pointer; + font-size: 0.9rem; + padding: 0; + font-family: 'Nunito', sans-serif; +} + +.dashboard-loading, +.dashboard-error { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.dashboard-error { + color: #c1694f; +} diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx new file mode 100644 index 0000000..8766727 --- /dev/null +++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/useAuth'; +import { inventoryAPI } from '../../utils/api'; +import './ProducerDashboard.css'; + +export default function ProducerDashboard() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [stocks, setStocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!user?.inventory_id) return; + inventoryAPI.getStocks(user.inventory_id) + .then(data => setStocks(data.stocks || [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, [user]); + + const totalItems = stocks.length; + const lowStock = stocks.filter(s => s.quantity < 10); + const expiringItems = stocks.filter(s => { + if (!s.expiration_date) return false; + const daysUntil = (new Date(s.expiration_date) - new Date()) / (1000 * 60 * 60 * 24); + return daysUntil <= 30; + }); + + return ( +
+
+
+

Welcome back, {user?.company_name}

+

{user?.primary_address}

+
+ +
+ +
+
+ {totalItems} + Stock Lines +
+
+ {lowStock.length} + Low Stock (<10) +
+
+ {expiringItems.length} + Expiring (30d) +
+
+ + {loading &&

Loading inventory...

} + {error &&

{error}

} + + {!loading && !error && ( +
+
+

Current Stock

+ +
+ + {stocks.length === 0 ? ( +
+

No stock yet.

+ +
+ ) : ( +
+ {stocks.map(s => ( +
+
+ {s.item.name} + {s.item.sku} +
+
+ + {s.quantity} {s.item.unit_type} + + {s.expiration_date && ( + Exp: {s.expiration_date} + )} + + {s.item.prices.length > 0 + ? `from $${Math.min(...s.item.prices.map(p => p.price_per_unit))} / ${s.item.unit_type}` + : 'No pricing set'} + +
+
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.css b/frontend/src/components/RetailerDashboard/RetailerDashboard.css new file mode 100644 index 0000000..b89650c --- /dev/null +++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.css @@ -0,0 +1,158 @@ +.retailer-dashboard { + padding: 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 2rem; +} + +.dashboard-header h1 { + font-family: 'Playfair Display', serif; + color: #3e2f1c; + margin: 0 0 0.25rem; +} + +.dashboard-subtitle { + color: #7a5c3e; + font-size: 0.9rem; + margin: 0; +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: #fff; + border: 1px solid #d4c4a8; + border-radius: 8px; + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.stat-card.warn .stat-number { color: #c1694f; } + +.stat-number { + font-size: 2rem; + font-weight: 700; + color: #3e2f1c; + font-family: 'Playfair Display', serif; +} + +.stat-label { + font-size: 0.85rem; + color: #7a5c3e; +} + +.dashboard-section { + background: #fff; + border: 1px solid #d4c4a8; + border-radius: 8px; + padding: 1.5rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.section-header h2 { + font-family: 'Playfair Display', serif; + color: #3e2f1c; + margin: 0; + font-size: 1.2rem; +} + +.order-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.order-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1rem; + background: #faf6ef; + border-radius: 6px; + border: 1px solid #e8dfd0; + border-left: 4px solid #d4c4a8; +} + +.order-card-main { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.order-id { + font-weight: 600; + color: #3e2f1c; +} + +.order-total { + font-size: 0.85rem; + color: #7a5c3e; +} + +.order-card-meta { + display: flex; + gap: 1rem; + align-items: center; +} + +.order-status { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 10px; + border-radius: 3px; + border: 1px solid; +} + +.order-date { + font-size: 0.85rem; + color: #7a5c3e; +} + +.empty-state { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.btn-primary { + background: #4a7c59; + color: #fff; + border: none; + padding: 0.6rem 1.25rem; + border-radius: 6px; + font-family: 'Nunito', sans-serif; + font-size: 0.95rem; + cursor: pointer; +} + +.btn-primary:hover { background: #3a6347; } + +.dashboard-loading, +.dashboard-error { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.dashboard-error { color: #c1694f; } diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx new file mode 100644 index 0000000..cb41497 --- /dev/null +++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/useAuth'; +import { ordersAPI } from '../../utils/api'; +import './RetailerDashboard.css'; + +const STATUS_COLORS = { + pending: { bg: '#fff3e0', text: '#c1694f', border: '#c1694f' }, + shipped: { bg: '#e8f5e9', text: '#4a7c59', border: '#4a7c59' }, + completed: { bg: '#e8f5e9', text: '#4a7c59', border: '#4a7c59' }, + cancelled: { bg: '#fdecea', text: '#b00020', border: '#b00020' }, +}; + +export default function RetailerDashboard() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + ordersAPI.getAll() + .then(data => setOrders(data.orders || [])) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const pending = orders.filter(o => o.status === 'pending'); + const completed = orders.filter(o => o.status === 'completed'); + + return ( +
+
+
+

Welcome back, {user?.company_name}

+

{user?.store_address}

+
+ +
+ +
+
+ {orders.length} + Total Orders +
+
+ {pending.length} + Pending +
+
+ {completed.length} + Completed +
+
+ + {loading &&

Loading orders...

} + {error &&

{error}

} + + {!loading && !error && ( +
+
+

Orders

+
+ + {orders.length === 0 ? ( +
+

No orders yet.

+ +
+ ) : ( +
+ {orders.map(o => { + const colors = STATUS_COLORS[o.status] || STATUS_COLORS.pending; + return ( +
+
+ Order #{o.id} + ${o.total_amount?.toFixed(2)} +
+
+ + {o.status} + + {o.order_date ? new Date(o.order_date).toLocaleDateString() : ''} +
+
+ ); + })} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css index 2c6a6b1..d1e8a7d 100644 --- a/frontend/src/pages/Dashboard.css +++ b/frontend/src/pages/Dashboard.css @@ -4,8 +4,8 @@ min-height: 100vh; } -.dashboard-bottom-left { - position: fixed; - bottom: 24px; - left: 24px; +.dashboard-content { + padding-top: 1rem; + background: #faf6ef; + min-height: calc(100vh - 60px); } diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 1e926df..a64a1df 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,15 +1,31 @@ -import Orders from "../components/Orders/Orders.jsx"; -import "./Dashboard.css"; import Header from "../components/Header/Header.jsx"; +import ProducerDashboard from "../components/ProducerDashboard/ProducerDashboard.jsx"; +import RetailerDashboard from "../components/RetailerDashboard/RetailerDashboard.jsx"; +import { useAuth } from "../contexts/useAuth.jsx"; +import "./Dashboard.css"; function Dashboard({ account, setAccount }) { + const { isProducer, isRetailer } = useAuth(); + + const renderDashboard = () => { + if (isProducer) return ; + if (isRetailer) return ; + return ( +
+

Finish setting up your account

+

You haven't set up a business profile yet.

+ + Set up your business → + +
+ ); + }; + return (
-
-
-
-
- +
+
+ {renderDashboard()}
); diff --git a/frontend/src/pages/Onboard.jsx b/frontend/src/pages/Onboard.jsx index eceaaa2..d63a0bd 100644 --- a/frontend/src/pages/Onboard.jsx +++ b/frontend/src/pages/Onboard.jsx @@ -1,100 +1,99 @@ import { useState } from 'react'; import { useNavigate } from "react-router-dom"; import Header from "../components/Header/Header.jsx"; - +import { useAuth } from '../contexts/useAuth.jsx'; +import { authAPI } from '../utils/api.js'; import './Onboard.css'; -import Inventory from '../components/Inventory/Inventory.jsx'; export default function Onboard() { const navigate = useNavigate(); - const [visible, setVisible] = useState(true); + const { checkAuthStatus } = useAuth(); const [name, setName] = useState(""); const [type, setType] = useState(""); const [address, setAddress] = useState(""); const [description, setDescription] = useState(""); - const [link, setLink] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); - const submitBasicOnboard = (e) => { + const submitBasicOnboard = async (e) => { e.preventDefault(); - console.log(name, type, address, description, link); - //put in database pls - if (type === "Producer") { - setVisible(false); - } else if (type === "Retailer") { - navigate("/dashboard"); + setError(""); + setSubmitting(true); + + try { + await authAPI.onboard({ + user_type: type.toLowerCase(), + company_name: name, + address, + description, + }); + await checkAuthStatus(); + navigate(type === "Producer" ? "/inventory" : "/dashboard", { replace: true }); + } catch (err) { + setError(err.message); + } finally { + setSubmitting(false); } - } + }; return (
- {visible && -
-
-
-

Set Up Your Business

- setName(e.target.value)} - required - /> +
+ +

Set Up Your Business

- setAddress(e.target.value)} - required - /> + setName(e.target.value)} + required + /> -
- - -
+ setAddress(e.target.value)} + required + /> +
+ +
- } - {!visible && -
- + Retailer +
- } -
+ setDescription(e.target.value)} + /> + + {error &&

{error}

} + + + +
); } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e12e1f0..ba6e765 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -17,10 +17,28 @@ async function apiCall(endpoint, options = {}) { export const authAPI = { checkAuth: () => apiCall('/auth/user'), + login: () => { window.location.href = `${API_BASE}/auth/login`; }, + logout: () => apiCall('/auth/logout', { method: 'POST' }), + onboard: (data) => apiCall('/auth/onboard', { method: 'POST', body: JSON.stringify(data) }), +}; + +export const inventoryAPI = { + getInventory: (id) => apiCall(`/inventory/${id}`), + getStocks: (inventoryId) => apiCall(`/inventory/${inventoryId}/stocks`), + getItems: (inventoryId) => apiCall(`/inventory/${inventoryId}/items`), + createStock: (data) => apiCall('/inventory/stocks', { method: 'POST', body: JSON.stringify(data) }), + updateStock: (stockId, data) => apiCall(`/inventory/stocks/${stockId}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteStock: (stockId) => apiCall(`/inventory/stocks/${stockId}`, { method: 'DELETE' }), +}; - login: () => { - window.location.href = `${API_BASE}/auth/login`; - }, +export const ordersAPI = { + getAll: () => apiCall('/orders'), + getOne: (id) => apiCall(`/orders/${id}`), + create: (data) => apiCall('/orders', { method: 'POST', body: JSON.stringify(data) }), + updateStatus: (id, status) => apiCall(`/orders/${id}`, { method: 'PUT', body: JSON.stringify({ status }) }), +}; - logout: () => apiCall('/auth/logout', { method: 'POST' }), +export const usersAPI = { + getAll: (userType = null) => apiCall(`/users${userType ? `?user_type=${userType}` : ''}`), + getOne: (id) => apiCall(`/users/${id}`), }; From 465696932ef6b0ec872d584493a6bffad3fdf8ee Mon Sep 17 00:00:00 2001 From: Graeme B Date: Sun, 8 Mar 2026 03:16:10 -0700 Subject: [PATCH 2/2] feat: lots of stuff --- backend/api.py | 12 +- backend/models/inventory.py | 2 + frontend/src/App.jsx | 10 +- .../src/components/Inventory/Inventory.css | 390 ++++++++++++++++-- .../src/components/Inventory/Inventory.jsx | 48 +-- frontend/src/components/Inventory/NewItem.jsx | 227 ++++++++-- .../src/components/Inventory/StockRow.jsx | 32 ++ .../ProducerDashboard/ProducerDashboard.css | 63 ++- .../ProducerDashboard/ProducerDashboard.jsx | 107 +++-- frontend/src/pages/Dashboard.css | 3 +- 10 files changed, 747 insertions(+), 147 deletions(-) create mode 100644 frontend/src/components/Inventory/StockRow.jsx diff --git a/backend/api.py b/backend/api.py index 8009461..d41a252 100644 --- a/backend/api.py +++ b/backend/api.py @@ -26,7 +26,17 @@ def create_app(): app.config["SESSION_TYPE"] = "filesystem" Session(app) - CORS(app, origins=os.environ.get("CORS_ORIGINS", "http://localhost:5173"), supports_credentials=True) + cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:5173") + CORS(app, origins=cors_origins, supports_credentials=True) + + @app.after_request + def add_cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = cors_origins + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + return response + init_oauth(app) app.register_blueprint(auth_bp) diff --git a/backend/models/inventory.py b/backend/models/inventory.py index 364ffc6..350ff71 100644 --- a/backend/models/inventory.py +++ b/backend/models/inventory.py @@ -31,3 +31,5 @@ class Stock(db.Model): batch_number = db.Column(db.String(100)) expiration_date = db.Column(db.Date) origin_date = db.Column(db.DateTime, default=datetime.utcnow) + + item = db.relationship("Item", backref="stocks") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f2adca4..90f5fd0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,9 +8,10 @@ import Dashboard from "./pages/Dashboard.jsx"; import SignUp from "./pages/SignUp.jsx"; import SignIn from "./pages/SignIn.jsx"; import MapView from "./pages/MapView.jsx"; -import Onboard from "./pages/Onboard.jsx" +import Onboard from "./pages/Onboard.jsx"; import Clippy from "./components/Clippy/Clippy.jsx"; import AuthHandler from "./components/AuthHandler.jsx"; +import ProducerDashboard from "./components/ProducerDashboard/ProducerDashboard.jsx"; function App() { @@ -22,7 +23,7 @@ function App() { } /> + } /> @@ -33,6 +34,11 @@ function App() { } /> + + + + } /> } /> diff --git a/frontend/src/components/Inventory/Inventory.css b/frontend/src/components/Inventory/Inventory.css index f98422f..26668b5 100644 --- a/frontend/src/components/Inventory/Inventory.css +++ b/frontend/src/components/Inventory/Inventory.css @@ -5,7 +5,8 @@ left: 0; width: 100%; height: 100%; - background-color: rgba(62, 47, 28, 0.4); + background-color: rgba(62, 47, 28, 0.45); + backdrop-filter: blur(4px); display: flex; justify-content: center; align-items: center; @@ -18,9 +19,31 @@ border-radius: var(--radius-lg); padding: 2em; width: 100%; - max-width: 400px; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; font-family: var(--brand-sans); box-shadow: 0 6px 0 var(--farm-shadow); + position: relative; +} + +.modal-close-btn { + position: absolute; + top: 1em; + right: 1em; + background: none; + border: none; + cursor: pointer; + color: var(--farm-text-muted, #7a5c3e); + display: flex; + align-items: center; + padding: 0.25rem; + border-radius: 4px; +} + +.modal-close-btn:hover { + color: var(--text-color, #3e2f1c); + background: rgba(0,0,0,0.05); } .modal-content h2 { @@ -35,15 +58,12 @@ display: flex; flex-direction: column; align-items: flex-start; - border: var(--border-thick) solid var(--farm-border); - border-radius: var(--radius-lg); background-color: var(--farm-card); padding: 2em; font-family: var(--brand-sans); gap: 1.2em; width: 100%; - max-width: 480px; - box-shadow: 0 3px 0 var(--farm-border-light); + box-sizing: border-box; } .inventory-wrapper h2 { @@ -91,84 +111,229 @@ background-color: var(--farm-green-hover); } -/* ── Item cards ── */ +/* ── Item rows ── */ .inventory-list { display: flex; flex-direction: column; - gap: 0.8em; + gap: 0.25em; width: 100%; } -.inventory-card { +.inventory-empty { + margin: 0; + color: var(--farm-text-muted); + font-size: 0.9rem; +} + +/* name | sku | qty+unit | price | expiry | edit */ +.inventory-row { display: grid; - grid-template-columns: 1fr auto; - gap: 0.2em 1em; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) 7rem 6rem 8rem 2.5rem; align-items: center; - padding: 0.8em 1em; - border: 1px solid var(--farm-border-light); - border-left: 4px solid var(--farm-green); + gap: 0; + padding: 0.3em 0.7em; + border-left: 3px solid var(--farm-green); border-radius: var(--radius-sm); - text-align: left; - transition: border-color 0.2s ease-in-out; - background-color: var(--farm-card); + background-color: var(--farm-bg, #faf6ef); + min-height: 2.2em; + width: 100%; + box-sizing: border-box; } -.inventory-card:hover { - border-color: var(--farm-brown); +.inventory-row:hover { border-left-color: var(--farm-brown); } -.inventory-card .item-name { +.row-name { font-weight: 600; + font-size: 0.88rem; color: var(--text-color); - margin: 0; - justify-self: start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 0.5em; } -.inventory-card .item-stats { +.row-sku { + font-size: 0.75rem; + color: var(--farm-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 0.5em; +} + +.row-qty { display: flex; - gap: 0.8em; align-items: center; - margin: 0; - font-size: 0.9rem; + gap: 0.3em; +} + +.row-qty input { + width: 4em; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 0.15em 0.3em; + font-size: 0.88rem; + font-family: var(--brand-sans); + color: var(--text-color); + background: transparent; + text-align: right; + outline: none; + transition: border-color 0.15s, background 0.15s; +} + +.row-qty input:hover, +.row-qty input:focus { + border-color: var(--farm-border-light); + background: var(--farm-card); +} + +.row-qty input.low-stock { + color: #c1694f; + font-weight: 700; +} + +.row-unit { + font-size: 0.78rem; color: var(--farm-text-muted); - justify-self: end; + white-space: nowrap; } -.inventory-card .item-description { - grid-column: 1 / -1; - margin: 0; - font-size: 0.85rem; +.row-price { + font-size: 0.82rem; + color: var(--farm-text-muted); + white-space: nowrap; +} + +.row-date-display { + font-size: 0.78rem; + color: var(--farm-text-muted); + white-space: nowrap; +} + +.row-date { + width: 100%; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 0.15em 0.3em; + font-size: 0.78rem; + font-family: var(--brand-sans); color: var(--farm-text-muted); - text-align: left; + background: transparent; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s, background 0.15s; +} + +.row-date:hover, +.row-date:focus { + border-color: var(--farm-border-light); + background: var(--farm-card); + color: var(--text-color); +} + +.row-status { + display: flex; + align-items: center; + justify-content: center; + color: var(--farm-green); +} + +.row-edit-btn { + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinning { + animation: spin 0.7s linear infinite; +} + +.field-readonly { + opacity: 0.55; + cursor: default; + background-color: var(--farm-bg, #faf6ef) !important; +} + +.row-edit-btn { + background: none; + border: none; + cursor: pointer; + color: var(--farm-text-muted); + display: flex; + align-items: center; + padding: 0.2em; + border-radius: 4px; + flex-shrink: 0; + transition: color 0.15s, background 0.15s; +} + +.row-edit-btn:hover { + color: var(--text-color, #3e2f1c); + background: rgba(0,0,0,0.05); } /* ── NewItem form ── */ .new-item-form { + width: 100%; +} + +.new-item-form form { display: flex; flex-direction: column; - gap: 0.6em; + gap: 0.55em; width: 100%; } -.new-item-form input[type="text"], -.new-item-form textarea { +.new-item-form input, +.new-item-form textarea, +.new-item-form select { width: 100%; border: var(--border-thick) solid var(--farm-border-light); border-radius: var(--radius-sm); padding: 0.5em 0.8em; - font-size: 0.95rem; + font-size: 0.9rem; font-family: var(--brand-sans); color: var(--text-color); box-sizing: border-box; outline: none; transition: border-color 0.2s ease-in-out; - resize: vertical; background-color: var(--farm-card); + appearance: none; + -webkit-appearance: none; } -.new-item-form input[type="text"]:focus, -.new-item-form textarea:focus { +.new-item-form textarea { + resize: vertical; + min-height: 3.5em; +} + +.new-item-form input:focus, +.new-item-form textarea:focus, +.select-wrapper { + position: relative; + width: 100%; +} + +.select-wrapper select { + padding-right: 2.2em; +} + +.select-chevron { + position: absolute; + right: 0.7em; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--farm-text-muted, #7a5c3e); +} + +.new-item-form select:focus { border-color: var(--farm-green); } @@ -176,7 +341,7 @@ background-color: var(--farm-green); color: white; border: none; - padding: 0.55em 1.4em; + padding: 0.6em 1.4em; border-radius: var(--radius-btn); cursor: pointer; font-size: 0.9rem; @@ -186,12 +351,155 @@ align-self: flex-end; text-transform: uppercase; letter-spacing: 0.04em; + margin-top: 0.4em; } -.new-item-form button[type="submit"]:hover { +.new-item-form button[type="submit"]:hover:not(:disabled) { background-color: var(--farm-green-hover); } +.new-item-form button[type="submit"]:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.form-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6em; + margin-top: 0.4em; +} + +.form-actions button[type="submit"] { + margin-top: 0; +} + +.btn-delete { + background: none; + border: var(--border-thick) solid #c1694f; + color: #c1694f; + padding: 0.6em 1.2em; + border-radius: var(--radius-btn); + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + font-family: var(--brand-sans); + text-transform: uppercase; + letter-spacing: 0.04em; + transition: background 0.2s, color 0.2s; +} + +.btn-delete:hover:not(:disabled) { + background: #c1694f; + color: white; +} + +.btn-delete:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-divider { + border: none; + border-top: 1px solid var(--farm-border-light); + margin: 0.2em 0; +} + +.form-section-label { + margin: 0; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--farm-text-muted); +} + +.form-error { + margin: 0; + font-size: 0.85rem; + color: #c1694f; +} + +/* ── Stock section: two columns ── */ +.stock-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.55em; +} + +.stock-fields input[type="date"] { + grid-column: 1 / -1; +} + +/* ── Price tiers ── */ +.price-tiers { + display: flex; + flex-direction: column; + gap: 0.4em; +} + +.price-tier { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + gap: 0.4em; + align-items: center; +} + +.price-tier input { + min-width: 0; +} + +.remove-tier-btn { + background: none; + border: none; + cursor: pointer; + color: var(--farm-text-muted); + font-size: 0.85rem; + padding: 0.3em; + line-height: 1; + border-radius: 4px; + flex-shrink: 0; +} + +.remove-tier-btn:hover { + color: #c1694f; + background: rgba(193, 105, 79, 0.08); +} + +.add-tier-btn { + background: none; + border: 1px dashed var(--farm-border-light); + border-radius: var(--radius-sm); + color: var(--farm-text-muted); + cursor: pointer; + font-size: 0.82rem; + font-family: var(--brand-sans); + padding: 0.35em 0.8em; + align-self: flex-start; + transition: border-color 0.2s, color 0.2s; +} + +.add-tier-btn:hover { + border-color: var(--farm-green); + color: var(--farm-green); +} + +.price-tier-header { + display: grid; + grid-template-columns: 1fr 1fr 1fr auto; + gap: 0.4em; +} + +.price-tier-header span { + font-size: 0.72rem; + color: var(--farm-text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0 0.2em; +} + /* ── Continue button ── */ .continue-btn { background-color: var(--farm-green); diff --git a/frontend/src/components/Inventory/Inventory.jsx b/frontend/src/components/Inventory/Inventory.jsx index 7b032a4..1a64543 100644 --- a/frontend/src/components/Inventory/Inventory.jsx +++ b/frontend/src/components/Inventory/Inventory.jsx @@ -1,52 +1,22 @@ -import { useState } from 'react'; -import { useNavigate } from "react-router-dom"; -import NewItem from './NewItem'; +import StockRow from './StockRow'; import './Inventory.css'; -export default function Inventory() { - const navigate = useNavigate(); - const [inventory, setInventory] = useState([ - { name: "item", quantity: 1, price: 3, description: "i am an item" }]); - const [showModal, setShowModal] = useState(false); - - const handleAddInv = () => { - setShowModal(true); - } - - const handleNewItem = (item) => { - setInventory([...inventory, item]); // should be a db call to validate - setShowModal(false); - } +export default function Inventory({ stocks = [], onAddItem, onStockChange }) { return (

Inventory

Add Item:

- +
- {inventory.map((item, index) => ( -
-

{item.name}

-
- Qty: {item.quantity} - ${item.price} -
-

{item.description}

-
+ {stocks.length === 0 && ( +

No items yet. Add your first item above.

+ )} + {stocks.map(s => ( + ))}
- - - - {showModal && ( -
setShowModal(false)}> -
e.stopPropagation()}> -

New Item

- -
-
- )}
- ) + ); } diff --git a/frontend/src/components/Inventory/NewItem.jsx b/frontend/src/components/Inventory/NewItem.jsx index d3fd192..cc904e8 100644 --- a/frontend/src/components/Inventory/NewItem.jsx +++ b/frontend/src/components/Inventory/NewItem.jsx @@ -1,34 +1,215 @@ import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { inventoryAPI } from '../../utils/api'; -export default function NewItem({ onAdd }) { - const [name, setName] = useState(""); - const [amount, setAmount] = useState(""); - const [price, setPrice] = useState(""); - const [details, setDetails] = useState(""); +const UNIT_TYPES = ['unit', 'kg', 'g', 'lb', 'oz', 'L', 'mL', 'box', 'case', 'dozen', 'pallet']; - const handleSubmit = (e) => { +const emptyPrice = () => ({ min_quantity: '', max_quantity: '', price_per_unit: '' }); + +// Used for both creating (no stockId) and editing (stockId provided). +// When editing, item fields are read-only — only stock fields save to the DB. +export default function NewItem({ onAdd, onDelete, inventoryId, producerId, stockId, initialValues }) { + const isEdit = !!stockId; + const item = initialValues?.item ?? {}; + const prices = initialValues?.prices ?? []; + + const [name, setName] = useState(item.name ?? ''); + const [unitType, setUnitType] = useState(item.unit_type ?? 'unit'); + const [description, setDescription] = useState(item.description ?? ''); + const [sku, setSku] = useState(item.sku ?? ''); + const [quantity, setQuantity] = useState(initialValues?.quantity ?? ''); + const [batchNumber, setBatchNumber] = useState(initialValues?.batch_number ?? ''); + const [expirationDate, setExpirationDate] = useState(initialValues?.expiration_date ?? ''); + const [priceTiers, setPriceTiers] = useState( + prices.length > 0 + ? prices.map(p => ({ min_quantity: p.min_quantity, max_quantity: p.max_quantity ?? '', price_per_unit: p.price_per_unit })) + : [emptyPrice()] + ); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [deleting, setDeleting] = useState(false); + + const updatePrice = (index, field, value) => { + setPriceTiers(prev => prev.map((p, i) => i === index ? { ...p, [field]: value } : p)); + }; + + const handleSubmit = async (e) => { e.preventDefault(); - onAdd({ - name: name, - quantity: parseInt(amount) || 0, - price: parseInt(price) || 0, - description: details - }); //make db call - } + setError(null); + setSubmitting(true); + + try { + if (isEdit) { + await inventoryAPI.updateStock(stockId, { + quantity: parseFloat(quantity), + batch_number: batchNumber || null, + expiration_date: expirationDate || null, + }); + onAdd(stockId, { + quantity: parseFloat(quantity), + batch_number: batchNumber || null, + expiration_date: expirationDate || null, + }); + } else { + const payload = { + inventory_id: inventoryId, + quantity: parseFloat(quantity), + batch_number: batchNumber || undefined, + expiration_date: expirationDate || undefined, + item: { + name, + unit_type: unitType, + producer_id: producerId, + description: description || undefined, + sku: sku || undefined, + }, + prices: priceTiers.map(p => ({ + min_quantity: parseFloat(p.min_quantity), + max_quantity: p.max_quantity !== '' ? parseFloat(p.max_quantity) : undefined, + price_per_unit: parseFloat(p.price_per_unit), + })), + }; + const result = await inventoryAPI.createStock(payload); + onAdd(result.stock); + } + } catch (err) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; return (
- setName(e.target.value)} /> - setAmount(e.target.value)} /> - setPrice(e.target.value)} /> -