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/backend/models/user.py b/backend/models/user.py
index 4d26853..6de7417 100644
--- a/backend/models/user.py
+++ b/backend/models/user.py
@@ -2,7 +2,6 @@
from typing import ClassVar
from database import db
-from models.catalog import Item
class User(db.Model):
@@ -49,7 +48,7 @@ def to_dict(self):
class Producer(User):
- __tablename__ = "producers"
+ __tablename__: ClassVar[str] = "producers"
id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
company_name = db.Column(db.String(255), nullable=False)
primary_address = db.Column(db.Text, nullable=False)
@@ -58,28 +57,20 @@ class Producer(User):
lat = db.Column(db.Float, nullable=True)
lng = db.Column(db.Float, nullable=True)
- __mapper_args__ = {"polymorphic_identity": "producer"}
+ inventory = db.relationship("Inventory", backref="producer_user", uselist=False, foreign_keys="Inventory.producer_id")
- def to_dict(self):
- data = super().to_dict()
- data["company_name"] = self.company_name
- data["primary_address"] = self.primary_address
- data["lat"] = self.lat
- data["lng"] = self.lng
-
- items = Item.query.filter_by(producer_id=self.id).all()
-
- inventory_data = []
- for item in items:
- base_price = min([float(p.price_per_unit) for p in item.prices]) if item.prices else 0
-
- inventory_data.append(
- {"id": item.id, "name": item.name, "description": item.description, "base_price": base_price}
- )
+ __mapper_args__: ClassVar[dict] = {"polymorphic_identity": "producer"}
- data["inventory"] = inventory_data
-
- return data
+ def to_dict(self):
+ return {
+ **super().to_dict(),
+ "company_name": self.company_name,
+ "primary_address": self.primary_address,
+ "company_description": self.company_description,
+ "lat": self.lat,
+ "lng": self.lng,
+ "inventory_id": self.inventory.id if self.inventory else None,
+ }
class Retailer(User):
@@ -92,10 +83,11 @@ class Retailer(User):
__mapper_args__: ClassVar[dict] = {"polymorphic_identity": "retailer"}
def to_dict(self):
- data = super().to_dict()
- data["company_name"] = self.company_name
- data["store_address"] = self.store_address
- return data
+ return {
+ **super().to_dict(),
+ "company_name": self.company_name,
+ "store_address": self.store_address,
+ }
class Consumer(User):
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/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
- {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 (
- )
+ );
}
diff --git a/frontend/src/components/Inventory/StockRow.jsx b/frontend/src/components/Inventory/StockRow.jsx
new file mode 100644
index 0000000..21b7ab7
--- /dev/null
+++ b/frontend/src/components/Inventory/StockRow.jsx
@@ -0,0 +1,32 @@
+import { Pencil } from 'lucide-react';
+import './Inventory.css';
+
+export default function StockRow({ stock, onEdit }) {
+ const lowestPrice = stock.item.prices.length > 0
+ ? Math.min(...stock.item.prices.map(p => p.price_per_unit))
+ : null;
+
+ return (
+
+
{stock.item.name}
+
{stock.item.sku || ''}
+
+
+ {stock.quantity}
+ {stock.item.unit_type}
+
+
+
+ {lowestPrice !== null ? `$${lowestPrice.toFixed(2)}` : ''}
+
+
+
+ {stock.expiration_date || ''}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.css b/frontend/src/components/ProducerDashboard/ProducerDashboard.css
new file mode 100644
index 0000000..1a0b7fa
--- /dev/null
+++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.css
@@ -0,0 +1,216 @@
+.producer-dashboard {
+ padding: 2rem;
+ max-width: 98vw;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.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.2rem;
+}
+
+.stock-card {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.3rem 0.7rem;
+ background: #faf6ef;
+ border-radius: 5px;
+ border-left: 3px solid #4a7c59;
+ min-height: 2.2rem;
+}
+
+.stock-card:hover {
+ border-left-color: #7a5c3e;
+}
+
+.stock-card-main {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.stock-name {
+ font-weight: 600;
+ font-size: 0.88rem;
+ color: #3e2f1c;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stock-sku {
+ font-size: 0.75rem;
+ color: #7a5c3e;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.stock-card-meta {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ font-size: 0.82rem;
+ color: #7a5c3e;
+ flex-shrink: 0;
+}
+
+.stock-qty {
+ font-weight: 600;
+ color: #4a7c59;
+ white-space: nowrap;
+}
+
+.stock-qty.low {
+ color: #c1694f;
+}
+
+.stock-expiry {
+ white-space: nowrap;
+}
+
+.stock-price {
+ color: #3e2f1c;
+ white-space: nowrap;
+}
+
+.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;
+}
+
+.modal-back-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.3em;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--farm-text-muted, #7a5c3e);
+ font-size: 0.9rem;
+ font-family: var(--brand-sans);
+ padding: 0;
+ margin-bottom: 0.8em;
+}
+
+.modal-back-btn:hover {
+ color: var(--text-color, #3e2f1c);
+}
diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx
new file mode 100644
index 0000000..abcd559
--- /dev/null
+++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx
@@ -0,0 +1,158 @@
+import { useEffect, useState } from 'react';
+import { X, ArrowLeft } from 'lucide-react';
+import { useAuth } from '../../contexts/useAuth';
+import { inventoryAPI } from '../../utils/api';
+import Inventory from '../Inventory/Inventory';
+import NewItem from '../Inventory/NewItem';
+import StockRow from '../Inventory/StockRow';
+import '../Inventory/Inventory.css';
+import './ProducerDashboard.css';
+
+export default function ProducerDashboard() {
+ const { user } = useAuth();
+ const [stocks, setStocks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [modal, setModal] = useState(false);
+ const [modalView, setModalView] = useState('inventory');
+ const [editingStock, setEditingStock] = 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 => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {editingStock && (
+
setEditingStock(null)}>
+
e.stopPropagation()}>
+
+
{editingStock.item.name}
+ {
+ setStocks(prev => prev.map(s => s.stock_id === id ? { ...s, ...updates } : s));
+ setEditingStock(null);
+ }}
+ onDelete={(id) => {
+ setStocks(prev => prev.filter(s => s.stock_id !== id));
+ setEditingStock(null);
+ }}
+ />
+
+
+ )}
+
+ {modal && (
+
{ setModal(false); setModalView('inventory'); }}>
+
e.stopPropagation()}>
+
+ {modalView === 'inventory' && (
+
setModalView('new-item')}
+ onStockChange={(id, updates) =>
+ setStocks(prev => prev.map(s => s.stock_id === id ? { ...s, ...updates } : s))
+ }
+ />
+ )}
+ {modalView === 'new-item' && (
+ <>
+
+ New Item
+ {
+ setStocks(prev => [...prev, stock]);
+ setModalView('inventory');
+ }}
+ />
+ >
+ )}
+
+
+ )}
+
+ );
+}
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 985815f..d6ffbdb 100644
--- a/frontend/src/pages/Dashboard.css
+++ b/frontend/src/pages/Dashboard.css
@@ -10,9 +10,7 @@
flex: 1;
display: flex;
flex-direction: column;
- align-items: center;
+ align-items: stretch;
justify-content: flex-start;
- padding: 2rem;
width: 100%;
}
-
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 2fb8049..a422bf8 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,18 +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";
+import "./Dashboard.css";
function Dashboard() {
- const { user } = useAuth();
+ const { isProducer, isRetailer } = useAuth();
+
+ const renderDashboard = () => {
+ if (isProducer) return ;
+ if (isRetailer) return ;
+ return (
+
+ );
+ };
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 &&
-
+
setDescription(e.target.value)}
+ />
+
+ {error &&
{error}
}
+
+
+
+
);
}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index e2c1701..a361d27 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -24,10 +24,28 @@ export const api = {
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}`),
};