From 079b905299f675bc450570191eeea4b1d83fcd74 Mon Sep 17 00:00:00 2001 From: Graeme B Date: Sun, 8 Mar 2026 04:15:53 -0700 Subject: [PATCH] MEGA HUGE PUSH --- backend/models/user.py | 1 + backend/routes/b2b.py | 5 +- backend/routes/orders.py | 7 +- backend/services/b2b_service.py | 29 ++- backend/services/order_service.py | 17 +- backend/services/user_service.py | 1 + .../ProducerDashboard/ProducerDashboard.css | 230 ++++------------- .../ProducerDashboard/ProducerDashboard.jsx | 51 +++- .../RetailerDashboard/RetailerDashboard.css | 166 +++--------- .../RetailerDashboard/RetailerDashboard.jsx | 56 ++-- .../src/components/SearchPanel/Checkout.jsx | 145 +++++++---- .../components/SearchPanel/ProducerDetail.jsx | 242 ++++-------------- .../components/SearchPanel/SearchSidebar.jsx | 2 +- frontend/src/components/dashboard-shared.css | 148 +++++++++++ frontend/src/pages/MapView.jsx | 29 ++- frontend/src/utils/api.js | 10 +- 16 files changed, 527 insertions(+), 612 deletions(-) create mode 100644 frontend/src/components/dashboard-shared.css diff --git a/backend/models/user.py b/backend/models/user.py index fa3f0cc..706df73 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -79,6 +79,7 @@ def to_dict(self): ) data["inventory"] = inventory_data + data["inventory_id"] = self.inventory.id if self.inventory else None return data diff --git a/backend/routes/b2b.py b/backend/routes/b2b.py index 9a8185f..9abad81 100644 --- a/backend/routes/b2b.py +++ b/backend/routes/b2b.py @@ -71,8 +71,9 @@ def delete(self, gb_id): @b2b_ns.route("/offers") class OfferList(Resource): def get(self): - """List all direct B2B stock offers.""" - return {"offers": get_all_offers()}, 200 + """List direct B2B stock offers, optionally filtered by producer_id.""" + producer_id = request.args.get("producer_id", type=int) + return {"offers": get_all_offers(producer_id=producer_id)}, 200 def post(self): """Submit a new offer from a retailer to a producer.""" diff --git a/backend/routes/orders.py b/backend/routes/orders.py index e8727d7..2db6d0f 100644 --- a/backend/routes/orders.py +++ b/backend/routes/orders.py @@ -34,10 +34,9 @@ @orders_ns.route("") class OrderList(Resource): def get(self): - """ - List all inventory orders. - """ - return {"orders": get_all_orders()}, 200 + """List all inventory orders, optionally filtered by retailer_id.""" + retailer_id = request.args.get("retailer_id", type=int) + return {"orders": get_all_orders(retailer_id=retailer_id)}, 200 @orders_ns.expect(order_model) def post(self): diff --git a/backend/services/b2b_service.py b/backend/services/b2b_service.py index d6fb256..40130ab 100644 --- a/backend/services/b2b_service.py +++ b/backend/services/b2b_service.py @@ -1,5 +1,7 @@ from database import db from models.b2b import BuyStockOffer, GroupBuy +from models.catalog import Item +from models.user import Retailer def get_all_group_buys(): @@ -55,11 +57,28 @@ def delete_group_buy(gb_id): return True -def get_all_offers(): - offers = BuyStockOffer.query.all() - return [ - {"id": o.id, "retailer_id": o.retailer_id, "producer_id": o.producer_id, "status": o.status} for o in offers - ] +def _serialize_offer(o): + retailer = db.session.get(Retailer, o.retailer_id) + item = db.session.get(Item, o.item_id) + return { + "id": o.id, + "retailer_id": o.retailer_id, + "retailer_name": retailer.company_name if retailer else None, + "producer_id": o.producer_id, + "item_id": o.item_id, + "item_name": item.name if item else None, + "item_unit": item.unit_type if item else None, + "offered_price": float(o.offered_price), + "requested_quantity": float(o.requested_quantity), + "status": o.status, + } + + +def get_all_offers(producer_id=None): + q = BuyStockOffer.query + if producer_id is not None: + q = q.filter(BuyStockOffer.producer_id == producer_id) + return [_serialize_offer(o) for o in q.all()] def get_offer(offer_id): diff --git a/backend/services/order_service.py b/backend/services/order_service.py index fe6d7c7..ad3c14b 100644 --- a/backend/services/order_service.py +++ b/backend/services/order_service.py @@ -2,10 +2,21 @@ from models.orders import InventoryOrder, OrderItem -def get_all_orders(): - orders = InventoryOrder.query.all() +def get_all_orders(retailer_id=None): + q = InventoryOrder.query + if retailer_id is not None: + q = q.filter(InventoryOrder.retailer_id == retailer_id) + orders = q.order_by(InventoryOrder.order_date.desc()).all() return [ - {"id": o.id, "consumer_id": o.consumer_id, "total_amount": float(o.total_amount), "status": o.status} + { + "id": o.id, + "consumer_id": o.consumer_id, + "retailer_id": o.retailer_id, + "total_amount": float(o.total_amount), + "status": o.status, + "order_date": o.order_date.isoformat() if o.order_date else None, + "item_count": len(o.order_items), + } for o in orders ] diff --git a/backend/services/user_service.py b/backend/services/user_service.py index 560ee4c..b734b51 100644 --- a/backend/services/user_service.py +++ b/backend/services/user_service.py @@ -18,6 +18,7 @@ def get_all_users(user_type=None): """ if user_type == "producer": users = Producer.query.options(selectinload(Producer.items).selectinload(Item.prices)).all() + return [u.to_dict() for u in users] elif user_type == "retailer": users = Retailer.query.all() elif user_type == "consumer": diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.css b/frontend/src/components/ProducerDashboard/ProducerDashboard.css index 1a0b7fa..10b72cb 100644 --- a/frontend/src/components/ProducerDashboard/ProducerDashboard.css +++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.css @@ -1,216 +1,72 @@ -.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; -} +@import '../dashboard-shared.css'; +/* ── Stock list (uses .inventory-row from Inventory.css via StockRow) ── */ .stock-list { display: flex; flex-direction: column; gap: 0.2rem; + width: 100%; } -.stock-card { - display: flex; +/* ── Offer rows ── */ +.offer-row { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1.5fr) 6rem 8rem 7rem 10rem; 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; + padding: 0.3em 0.7em; + border-left: 3px solid #c4a882; + border-radius: 4px; + background-color: #faf6ef; + min-height: 2.2em; + font-size: 0.9rem; } -.stock-name { +.offer-row-header { font-weight: 600; - font-size: 0.88rem; - color: #3e2f1c; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 0.8rem; + color: #6b5744; + border-left-color: transparent; + background: transparent; + padding-bottom: 0.2em; } -.stock-sku { +.offer-status { 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; + text-transform: uppercase; + padding: 0.15em 0.5em; + border-radius: 999px; + display: inline-block; } -.stock-qty.low { - color: #c1694f; -} - -.stock-expiry { - white-space: nowrap; -} - -.stock-price { - color: #3e2f1c; - white-space: nowrap; -} +.offer-status--pending { background: #fff3cd; color: #856404; } +.offer-status--accepted { background: #d1e7dd; color: #0f5132; } +.offer-status--rejected { background: #f8d7da; color: #842029; } -.empty-state { - text-align: center; - padding: 2rem; - color: #7a5c3e; +.offer-actions { + display: flex; + gap: 0.4rem; } -.btn-primary { +.btn-accept { + padding: 0.2em 0.7em; background: #4a7c59; color: #fff; border: none; - padding: 0.6rem 1.25rem; - border-radius: 6px; - font-family: 'Nunito', sans-serif; - font-size: 0.95rem; + border-radius: 4px; cursor: pointer; + font-size: 0.8rem; } -.btn-primary:hover { - background: #3a6347; -} - -.btn-link { - background: none; - border: none; - color: #4a7c59; +.btn-reject { + padding: 0.2em 0.7em; + background: transparent; + color: #842029; + border: 1px solid #842029; + border-radius: 4px; 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; + font-size: 0.8rem; } -.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); -} +.btn-accept:hover { background: #3a6147; } +.btn-reject:hover { background: #f8d7da; } diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx index abcd559..ca998ff 100644 --- a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx +++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { X, ArrowLeft } from 'lucide-react'; import { useAuth } from '../../contexts/useAuth'; -import { inventoryAPI } from '../../utils/api'; +import { inventoryAPI, b2bAPI } from '../../utils/api'; import Inventory from '../Inventory/Inventory'; import NewItem from '../Inventory/NewItem'; import StockRow from '../Inventory/StockRow'; @@ -16,6 +16,7 @@ export default function ProducerDashboard() { const [modal, setModal] = useState(false); const [modalView, setModalView] = useState('inventory'); const [editingStock, setEditingStock] = useState(null); + const [offers, setOffers] = useState([]); useEffect(() => { if (!user?.inventory_id) return; @@ -25,6 +26,19 @@ export default function ProducerDashboard() { .finally(() => setLoading(false)); }, [user]); + useEffect(() => { + if (!user?.id) return; + b2bAPI.getOffers(user.id) + .then(data => setOffers(data.offers || [])) + .catch(() => {}); + }, [user]); + + const handleOfferAction = (offerId, status) => { + b2bAPI.updateOffer(offerId, status) + .then(() => setOffers(prev => prev.map(o => o.id === offerId ? { ...o, status } : o))) + .catch(() => {}); + }; + const totalItems = stocks.length; const lowStock = stocks.filter(s => s.quantity < 10); const expiringItems = stocks.filter(s => { @@ -90,6 +104,41 @@ export default function ProducerDashboard() { )} + {offers.length > 0 && ( +
+
+

Incoming Offers

+
+
+
+ Retailer + Item + Qty + Offered Price + Status + +
+ {offers.map(o => ( +
+ {o.retailer_name || `Retailer #${o.retailer_id}`} + {o.item_name || `Item #${o.item_id}`} + {o.requested_quantity} {o.item_unit} + ${parseFloat(o.offered_price).toFixed(2)} + {o.status} + + {o.status === 'pending' && ( + <> + + + + )} + +
+ ))} +
+
+ )} + {editingStock && (
setEditingStock(null)}>
e.stopPropagation()}> diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.css b/frontend/src/components/RetailerDashboard/RetailerDashboard.css index b89650c..b9feb6d 100644 --- a/frontend/src/components/RetailerDashboard/RetailerDashboard.css +++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.css @@ -1,158 +1,62 @@ -.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; -} +@import '../dashboard-shared.css'; +/* ── Order rows: order# | status | date | items | total ── */ .order-list { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.2rem; + width: 100%; } -.order-card { - display: flex; - justify-content: space-between; +/* order-id | status | date | item-count | total */ +.order-row { + display: grid; + grid-template-columns: minmax(0, 1.5fr) 7rem 8rem 5rem 7rem; align-items: center; - padding: 0.875rem 1rem; + padding: 0.3em 0.7em; + border-left: 3px solid #d4c4a8; + border-radius: 5px; background: #faf6ef; - border-radius: 6px; - border: 1px solid #e8dfd0; - border-left: 4px solid #d4c4a8; + min-height: 2.2em; + box-sizing: border-box; + width: 100%; } -.order-card-main { - display: flex; - flex-direction: column; - gap: 0.2rem; +.order-row:hover { + border-left-color: #7a5c3e; } -.order-id { +.order-row-id { font-weight: 600; + font-size: 0.88rem; color: #3e2f1c; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 0.5em; } -.order-total { - font-size: 0.85rem; - color: #7a5c3e; -} - -.order-card-meta { - display: flex; - gap: 1rem; - align-items: center; -} - -.order-status { - font-size: 0.75rem; +.order-status-badge { + display: inline-block; + font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; - padding: 3px 10px; + padding: 2px 8px; border-radius: 3px; border: 1px solid; + white-space: nowrap; } -.order-date { - font-size: 0.85rem; - color: #7a5c3e; -} - -.empty-state { - text-align: center; - padding: 2rem; +.order-row-date, +.order-row-items, +.order-row-total { + font-size: 0.82rem; color: #7a5c3e; + white-space: nowrap; } -.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; +.order-row-total { + font-weight: 600; + color: #3e2f1c; } - -.dashboard-error { color: #c1694f; } diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx index cb41497..0645102 100644 --- a/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx +++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx @@ -5,12 +5,34 @@ 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' }, + 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' }, }; +function OrderRow({ order }) { + const colors = STATUS_COLORS[order.status] || STATUS_COLORS.pending; + const date = order.order_date + ? new Date(order.order_date).toLocaleDateString() + : '—'; + + return ( +
+ Order #{order.id} + + {order.status} + + {date} + {order.item_count ?? 0} items + ${order.total_amount?.toFixed(2)} +
+ ); +} + export default function RetailerDashboard() { const { user } = useAuth(); const navigate = useNavigate(); @@ -19,13 +41,14 @@ export default function RetailerDashboard() { const [error, setError] = useState(null); useEffect(() => { - ordersAPI.getAll() + if (!user?.id) return; + ordersAPI.getAll(user.id) .then(data => setOrders(data.orders || [])) .catch(e => setError(e.message)) .finally(() => setLoading(false)); - }, []); + }, [user]); - const pending = orders.filter(o => o.status === 'pending'); + const pending = orders.filter(o => o.status === 'pending'); const completed = orders.filter(o => o.status === 'completed'); return ( @@ -55,7 +78,7 @@ export default function RetailerDashboard() {
- {loading &&

Loading orders...

} + {loading &&

Loading orders…

} {error &&

{error}

} {!loading && !error && ( @@ -73,26 +96,7 @@ export default function RetailerDashboard() { ) : (
- {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() : ''} -
-
- ); - })} + {orders.map(o => )}
)} diff --git a/frontend/src/components/SearchPanel/Checkout.jsx b/frontend/src/components/SearchPanel/Checkout.jsx index 680eef5..4e28ef6 100644 --- a/frontend/src/components/SearchPanel/Checkout.jsx +++ b/frontend/src/components/SearchPanel/Checkout.jsx @@ -1,29 +1,66 @@ -import React, { useState } from "react"; +import { useState } from "react"; +import { b2bAPI } from "../../utils/api"; -const Checkout = ({ product, producer, mode, onClose }) => { +const getUnitPrice = (tiers, qty) => { + const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity); + let price = sorted[0]?.price_per_unit ?? 0; + for (const tier of sorted) { + if (qty >= tier.min_quantity) price = tier.price_per_unit; + else break; + } + return Number(price); +}; + +const Checkout = ({ stock, producer, mode, user, onClose }) => { const [quantity, setQuantity] = useState(1); const [targetQuantity, setTargetQuantity] = useState(50); - const [step, setStep] = useState("FORM"); const [confirmed, setConfirmed] = useState(false); + const [step, setStep] = useState("FORM"); // FORM | SUBMITTING | SUCCESS | ERROR + const [errorMsg, setErrorMsg] = useState(null); - {/* TODO: integrate */} - const getUnitPrice = (qty) => { - if (qty >= 13) return Math.floor(product.base_price * 0.8); - if (qty >= 6) return Math.floor(product.base_price * 0.9); - return product.base_price; - }; + const tiers = stock?.item?.prices ?? []; + const unitPrice = getUnitPrice(tiers, quantity); + const totalPrice = (unitPrice * quantity).toFixed(2); - const currentUnitPrice = getUnitPrice(quantity); - const totalPrice = currentUnitPrice * quantity; + const handleConfirm = async () => { + setStep("SUBMITTING"); + setErrorMsg(null); + try { + if (mode === "DIRECT") { + await b2bAPI.createOffer({ + retailer_id: user?.id, + producer_id: producer?.id, + item_id: stock?.item?.id, + offered_price: unitPrice, + requested_quantity: quantity, + status: "pending", + }); + } else { + await b2bAPI.createGroupBuy({ + initiator_retailer_id: user?.id, + item_id: stock?.item?.id, + target_quantity: targetQuantity, + status: "open", + }); + } + setStep("SUCCESS"); + } catch (err) { + setErrorMsg(err.message); + setStep("ERROR"); + } + }; if (step === "SUCCESS") { return (
-

Order Confirmed

+

+ {mode === "DIRECT" ? "Offer Submitted" : "Group Buy Started"} +

- Your request for {quantity} units of {product.name} has been received. - {mode === "GROUP" ? " We'll notify you once the group goal is reached." : ""} + Your {mode === "DIRECT" ? "offer" : "group buy"} for {quantity} {stock?.item?.unit_type} of{" "} + {stock?.item?.name} from {producer?.name} has been received. + {mode === "GROUP" ? " Other retailers can now join the group to hit the target quantity." : ""}

@@ -31,6 +68,21 @@ const Checkout = ({ product, producer, mode, onClose }) => { ); } + if (step === "ERROR") { + return ( +
+
+

Something went wrong

+

{errorMsg}

+
+ + +
+
+
+ ); + } + return (
@@ -41,40 +93,54 @@ const Checkout = ({ product, producer, mode, onClose }) => {
+ {/* Item info */}
- {producer.name} -

{product.name}

+ {producer?.name} +

{stock?.item?.name}

+ {stock?.quantity} {stock?.item?.unit_type} available
+ {/* Price tiers summary */} + {tiers.length > 0 && ( +
+ {[...tiers].sort((a, b) => a.min_quantity - b.min_quantity).map((t, i) => ( + + {t.min_quantity}{t.max_quantity ? `–${t.max_quantity}` : "+"}: ${Number(t.price_per_unit).toFixed(2)} + + ))} +
+ )} + {mode === "GROUP" && (
setTargetQuantity(parseInt(e.target.value))} + onChange={(e) => setTargetQuantity(parseInt(e.target.value) || 1)} style={inputStyle} /> -

- Set the total units needed across all buyers to unlock the max discount tier (${Math.floor(product.base_price * 0.8)}). -

)}
- + setQuantity(parseInt(e.target.value) || 0)} + onChange={(e) => setQuantity(parseInt(e.target.value) || 1)} style={inputStyle} /> +

+ Unit price at this quantity: ${unitPrice.toFixed(2)} +

setConfirmed(e.target.checked)} style={{ margin: 0, flexShrink: 0 }} /> - +
@@ -83,33 +149,22 @@ const Checkout = ({ product, producer, mode, onClose }) => {
${totalPrice}
- + ); }; -const overlayStyle = { - position: "fixed", top: 0, left: 0, width: "100%", height: "100%", - backgroundColor: "rgba(62, 47, 28, 0.4)", backdropFilter: "blur(4px)", - display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 -}; -const modalStyle = { - background: "#fffdf7", padding: "40px", borderRadius: "8px", width: "450px", - boxShadow: "0 6px 0 rgba(122, 92, 62, 0.1)", border: "2px solid #c4a882" -}; +const overlayStyle = { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(62, 47, 28, 0.4)", backdropFilter: "blur(4px)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 }; +const modalStyle = { background: "#fffdf7", padding: "40px", borderRadius: "8px", width: "450px", boxShadow: "0 6px 0 rgba(122, 92, 62, 0.1)", border: "2px solid #c4a882" }; const labelStyle = { display: "block", marginBottom: "8px", fontWeight: 700, fontSize: "0.85rem", color: "#3e2f1c", textTransform: "uppercase", letterSpacing: "0.03em" }; const inputStyle = { width: "100%", padding: "12px", borderRadius: "4px", border: "2px solid #d4c4a8", fontSize: "1rem", boxSizing: "border-box", color: "#3e2f1c", backgroundColor: "#fffdf7" }; -const primaryBtnStyle = { - backgroundColor: "#4a7c59", color: "white", padding: "14px 28px", - borderRadius: "5px", border: "none", cursor: "pointer", fontWeight: 700, fontSize: "0.95rem", - textTransform: "uppercase", letterSpacing: "0.04em" -}; +const primaryBtnStyle = { backgroundColor: "#4a7c59", color: "white", padding: "14px 28px", borderRadius: "5px", border: "none", cursor: "pointer", fontWeight: 700, fontSize: "0.95rem", textTransform: "uppercase", letterSpacing: "0.04em" }; export default Checkout; diff --git a/frontend/src/components/SearchPanel/ProducerDetail.jsx b/frontend/src/components/SearchPanel/ProducerDetail.jsx index a3d9fae..ec4662c 100644 --- a/frontend/src/components/SearchPanel/ProducerDetail.jsx +++ b/frontend/src/components/SearchPanel/ProducerDetail.jsx @@ -1,139 +1,54 @@ import React, { useState } from "react"; import { X } from "lucide-react"; -const ProductItem = ({ product, onOrder }) => { +const ProductItem = ({ stock, onOrder }) => { const [isExpanded, setIsExpanded] = useState(false); - - { - /* TODO: integrate */ - } - const tiers = [ - { qty: "1-5", price: product.base_price }, - { qty: "6-12", price: Math.floor(product.base_price * 0.9) }, - { qty: "13+", price: Math.floor(product.base_price * 0.8) }, - ]; + const tiers = [...(stock.item.prices ?? [])].sort((a, b) => a.min_quantity - b.min_quantity); + const basePrice = tiers[0]?.price_per_unit ?? 0; return ( -
+
setIsExpanded(!isExpanded)} >
-
- {product.name} -
+
{stock.item.name}
- Starting at ${product.base_price} + from ${Number(basePrice).toFixed(2)} / {stock.item.unit_type} · {stock.quantity} in stock
- - {isExpanded ? "▲" : "▼"} - + {isExpanded ? "▲" : "▼"}
{isExpanded && (
- - - - - - - - - {tiers.map((t, i) => ( - - - + {tiers.length > 0 && ( +
QtyPrice
- {t.qty} units - - ${t.price} -
+ + + + + - ))} - -
Min qtyMax qtyPrice / {stock.item.unit_type}
+ + + {tiers.map((t, i) => ( + + {t.min_quantity} + {t.max_quantity ?? "+"} + ${Number(t.price_per_unit).toFixed(2)} + + ))} + + + )}
- -
@@ -143,68 +58,20 @@ const ProductItem = ({ product, onOrder }) => { ); }; -function ProducerDetail({ producer, onClose, onOrder }) { +function ProducerDetail({ producer, stocks = [], onClose, onOrder }) { const [isCloseHovered, setIsCloseHovered] = useState(false); return ( -
-
-

+
+
+

{producer.name}

-
-

- {producer.type} -

-

- 📍 {producer.address} -

+

{producer.type}

+

📍 {producer.address}

-

+

Available Products

- {producer.fullProducts.map((prod, idx) => ( + {stocks.length === 0 && ( +

No products listed.

+ )} + {stocks.map((stock) => ( onOrder(p, producer, mode)} + key={stock.stock_id} + stock={stock} + onOrder={(s, mode) => onOrder(s, producer, mode)} /> ))}
diff --git a/frontend/src/components/SearchPanel/SearchSidebar.jsx b/frontend/src/components/SearchPanel/SearchSidebar.jsx index 62fbf60..374f8df 100644 --- a/frontend/src/components/SearchPanel/SearchSidebar.jsx +++ b/frontend/src/components/SearchPanel/SearchSidebar.jsx @@ -114,7 +114,7 @@ function SearchSidebar({ searchQuery, setSearchQuery, producers, onSelect }) { marginTop: "10px", }} > - {dist.products.join(", ")} + {dist.products.length > 0 ? dist.products.join(", ") : dist.address}

)) diff --git a/frontend/src/components/dashboard-shared.css b/frontend/src/components/dashboard-shared.css new file mode 100644 index 0000000..25856ef --- /dev/null +++ b/frontend/src/components/dashboard-shared.css @@ -0,0 +1,148 @@ +/* ── Shared dashboard layout ── */ +.producer-dashboard, +.retailer-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; +} + +/* ── Stats ── */ +.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; +} + +/* ── Section ── */ +.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: 0.75rem; +} + +.section-header h2 { + font-family: 'Playfair Display', serif; + color: #3e2f1c; + margin: 0; + font-size: 1.2rem; +} + +/* ── Shared row list ── */ +.row-list { + display: flex; + flex-direction: column; + gap: 0.2rem; + width: 100%; +} + +/* ── Empty / loading / error ── */ +.empty-state { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.dashboard-loading, +.dashboard-error { + text-align: center; + padding: 2rem; + color: #7a5c3e; +} + +.dashboard-error { + color: #c1694f; +} + +/* ── Buttons ── */ +.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; + transition: background 0.2s; +} + +.btn-primary:hover { + background: #3a6347; +} + +.modal-back-btn { + display: flex; + align-items: center; + gap: 0.3em; + background: none; + border: none; + cursor: pointer; + color: #7a5c3e; + font-size: 0.9rem; + font-family: var(--brand-sans); + padding: 0; + margin-bottom: 0.8em; +} + +.modal-back-btn:hover { + color: #3e2f1c; +} diff --git a/frontend/src/pages/MapView.jsx b/frontend/src/pages/MapView.jsx index 1f334e0..299c8b3 100644 --- a/frontend/src/pages/MapView.jsx +++ b/frontend/src/pages/MapView.jsx @@ -4,13 +4,16 @@ import Header from "../components/Header/Header.jsx"; import SearchSidebar from "../components/SearchPanel/SearchSidebar.jsx"; import ProducerDetail from "../components/SearchPanel/ProducerDetail.jsx"; import Checkout from "../components/SearchPanel/Checkout.jsx"; -import { api } from "../utils/api.js"; +import { api, inventoryAPI } from "../utils/api.js"; +import { useAuth } from "../contexts/useAuth.jsx"; import { ChevronLeft, ChevronRight } from "lucide-react"; export default function MapView() { + const { user } = useAuth(); const [producers, setProducers] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [selectedProducer, setSelectedProducer] = useState(null); + const [producerStocks, setProducerStocks] = useState([]); const [checkoutInfo, setCheckoutInfo] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -30,8 +33,8 @@ export default function MapView() { lat: p.lat || 49.8625, lng: p.lng || -119.4625, address: p.primary_address || "Address not provided", - products: p.inventory ? p.inventory.map((item) => item.name) : [], - fullProducts: p.inventory || [], + inventory_id: p.inventory_id, + products: (p.inventory || []).map((item) => item.name), })); setProducers(mappedProducers); } catch (error) { @@ -43,6 +46,14 @@ export default function MapView() { fetchProducers(); }, []); + // Fetch stocks when a producer is selected + useEffect(() => { + if (!selectedProducer?.inventory_id) { setProducerStocks([]); return; } + inventoryAPI.getStocks(selectedProducer.inventory_id) + .then(data => setProducerStocks(data.stocks || [])) + .catch(() => setProducerStocks([])); + }, [selectedProducer]); + // Resizer logic const handleMouseDown = (e) => { e.preventDefault(); @@ -71,8 +82,8 @@ export default function MapView() { const searchLower = searchQuery.toLowerCase(); return ( p.name.toLowerCase().includes(searchLower) || - (p.products && - p.products.some((prod) => prod.toLowerCase().includes(searchLower))) + p.address.toLowerCase().includes(searchLower) || + p.products.some((prod) => prod.toLowerCase().includes(searchLower)) ); }); @@ -178,9 +189,10 @@ export default function MapView() { > setSelectedProducer(null)} - onOrder={(product, dist, mode) => - setCheckoutInfo({ product, producer: dist, mode }) + onOrder={(stock, dist, mode) => + setCheckoutInfo({ stock, producer: dist, mode }) } />

@@ -209,9 +221,10 @@ export default function MapView() { {checkoutInfo && (
setCheckoutInfo(null)} />
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a361d27..2505e0f 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -39,12 +39,20 @@ export const inventoryAPI = { }; export const ordersAPI = { - getAll: () => apiCall('/orders'), + getAll: (retailerId) => apiCall(`/orders${retailerId ? `?retailer_id=${retailerId}` : ''}`), 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 }) }), }; +export const b2bAPI = { + createOffer: (data) => apiCall('/b2b/offers', { method: 'POST', body: JSON.stringify(data) }), + createGroupBuy: (data) => apiCall('/b2b/group-buys', { method: 'POST', body: JSON.stringify(data) }), + getOffers: (producerId) => apiCall(`/b2b/offers${producerId ? `?producer_id=${producerId}` : ''}`), + updateOffer: (id, status) => apiCall(`/b2b/offers/${id}`, { method: 'PUT', body: JSON.stringify({ status }) }), + getGroupBuys: () => apiCall('/b2b/group-buys'), +}; + export const usersAPI = { getAll: (userType = null) => apiCall(`/users${userType ? `?user_type=${userType}` : ''}`), getOne: (id) => apiCall(`/users/${id}`),