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

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)} /> -