From cb1399a25ed09e2596ef2a0d435267133f1a150d Mon Sep 17 00:00:00 2001 From: Juan Carlos Lazcano Date: Wed, 29 Jul 2026 14:08:35 -0400 Subject: [PATCH 1/4] Add Restocking tab with budget-driven demand recommendations Lets users set a budget and get demand-forecast-based restock recommendations (greedy by demand gap), submit them via a new POST /api/restocking endpoint, and see submitted orders with delivery lead time in a new Orders tab section. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M3DuWAWcHvYp4K22srxCwc --- client/src/api.js | 5 + client/src/locales/en.js | 29 ++- client/src/locales/ja.js | 29 ++- client/src/main.js | 2 + client/src/views/Orders.vue | 80 ++++++- client/src/views/Restocking.vue | 323 +++++++++++++++++++++++++++ server/data/demand_forecasts.json | 27 ++- server/main.py | 38 +++- tests/backend/test_misc_endpoints.py | 2 + tests/backend/test_restocking.py | 79 +++++++ 10 files changed, 595 insertions(+), 19 deletions(-) create mode 100644 client/src/views/Restocking.vue create mode 100644 tests/backend/test_restocking.py diff --git a/client/src/api.js b/client/src/api.js index 11cb9db70..086e29542 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -102,5 +102,10 @@ export const api = { async getPurchaseOrderByBacklogItem(backlogItemId) { const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`) return response.data + }, + + async submitRestockingOrder(items) { + const response = await axios.post(`${API_BASE_URL}/restocking`, { items }) + return response.data } } diff --git a/client/src/locales/en.js b/client/src/locales/en.js index 03a58fe6e..b10fbeb37 100644 --- a/client/src/locales/en.js +++ b/client/src/locales/en.js @@ -6,6 +6,7 @@ export default { orders: 'Orders', finance: 'Finance', demandForecast: 'Demand Forecast', + restocking: 'Restocking', companyName: 'Catalyst Components', subtitle: 'Inventory Management System' }, @@ -106,12 +107,14 @@ export default { title: 'Orders', description: 'View and manage customer orders', allOrders: 'All Orders', + submittedOrders: 'Submitted Orders', totalOrders: 'Total Orders', totalRevenue: 'Total Revenue', avgOrderValue: 'Avg Order Value', onTimeDelivery: 'On-Time Delivery', itemsCount: '{count} items', quantity: 'Qty', + days: 'days', table: { orderNumber: 'Order Number', orderId: 'Order ID', @@ -125,7 +128,8 @@ export default { totalValue: 'Total Value', status: 'Status', expectedDelivery: 'Expected Delivery', - actualDelivery: 'Actual Delivery' + actualDelivery: 'Actual Delivery', + leadTime: 'Lead Time' } }, @@ -188,6 +192,28 @@ export default { } }, + // Restocking + restocking: { + title: 'Restocking', + description: 'Set a budget and get demand-driven restock recommendations', + budgetLabel: 'Available Budget', + recommendations: 'Recommended Items', + table: { + sku: 'SKU', + itemName: 'Item Name', + demandGap: 'Demand Gap', + unitCost: 'Unit Cost', + quantity: 'Recommended Qty', + subtotal: 'Subtotal' + }, + totalCost: 'Total Cost', + remainingBudget: 'Remaining Budget', + placeOrder: 'Place Order', + placingOrder: 'Placing Order...', + noRecommendations: 'No items to restock at this budget', + orderSubmitted: 'Order {orderNumber} submitted — view it in the Orders tab' + }, + // Filters filters: { timePeriod: 'Time Period', @@ -204,6 +230,7 @@ export default { shipped: 'Shipped', processing: 'Processing', backordered: 'Backordered', + submitted: 'Submitted', inStock: 'In Stock', lowStock: 'Low Stock', adequate: 'Adequate' diff --git a/client/src/locales/ja.js b/client/src/locales/ja.js index db33223ac..bb25c62e6 100644 --- a/client/src/locales/ja.js +++ b/client/src/locales/ja.js @@ -6,6 +6,7 @@ export default { orders: '注文', finance: '財務', demandForecast: '需要予測', + restocking: '補充', companyName: '触媒コンポーネンツ', subtitle: '在庫管理システム' }, @@ -106,12 +107,14 @@ export default { title: '注文', description: '顧客注文の表示と管理', allOrders: 'すべての注文', + submittedOrders: '提出済み注文', totalOrders: '総注文数', totalRevenue: '総収益', avgOrderValue: '平均注文額', onTimeDelivery: '定時配達', itemsCount: '{count}件', quantity: '数量', + days: '日', table: { orderNumber: '注文番号', orderId: '注文ID', @@ -125,7 +128,8 @@ export default { totalValue: '合計金額', status: 'ステータス', expectedDelivery: '予定配達日', - actualDelivery: '実際の配達日' + actualDelivery: '実際の配達日', + leadTime: 'リードタイム' } }, @@ -188,6 +192,28 @@ export default { } }, + // Restocking + restocking: { + title: '補充', + description: '予算を設定し、需要に基づいた補充提案を確認する', + budgetLabel: '利用可能な予算', + recommendations: '推奨品目', + table: { + sku: 'SKU', + itemName: '品目名', + demandGap: '需要ギャップ', + unitCost: '単価', + quantity: '推奨数量', + subtotal: '小計' + }, + totalCost: '合計コスト', + remainingBudget: '残り予算', + placeOrder: '注文する', + placingOrder: '注文中...', + noRecommendations: 'この予算では補充する品目がありません', + orderSubmitted: '注文{orderNumber}が提出されました — 注文タブでご確認ください' + }, + // Filters filters: { timePeriod: '期間', @@ -204,6 +230,7 @@ export default { shipped: '出荷済み', processing: '処理中', backordered: 'バックオーダー', + submitted: '提出済み', inStock: '在庫あり', lowStock: '在庫僅少', adequate: '適量' diff --git a/client/src/main.js b/client/src/main.js index 477c2d966..cea203940 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue' import Inventory from './views/Inventory.vue' import Orders from './views/Orders.vue' import Demand from './views/Demand.vue' +import Restocking from './views/Restocking.vue' import Spending from './views/Spending.vue' import Reports from './views/Reports.vue' @@ -15,6 +16,7 @@ const router = createRouter({ { path: '/inventory', component: Inventory }, { path: '/orders', component: Orders }, { path: '/demand', component: Demand }, + { path: '/restocking', component: Restocking }, { path: '/spending', component: Spending }, { path: '/reports', component: Reports } ] diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue index 7413f6e66..2bc0d69c8 100644 --- a/client/src/views/Orders.vue +++ b/client/src/views/Orders.vue @@ -74,6 +74,51 @@ + +
+
+

{{ t('orders.submittedOrders') }} ({{ submittedOrders.length }})

+
+
{{ submittedOrdersError }}
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('orders.table.orderNumber') }}{{ t('orders.table.customer') }}{{ t('orders.table.items') }}{{ t('orders.table.orderDate') }}{{ t('orders.table.expectedDelivery') }}{{ t('orders.table.leadTime') }}{{ t('orders.table.totalValue') }}
{{ order.order_number }}{{ translateCustomerName(order.customer) }} +
+ + {{ t('orders.itemsCount', { count: order.items.length }) }} + +
+
+ {{ translateProductName(item.name) }} + {{ t('orders.quantity') }}: {{ item.quantity }} @ {{ currencySymbol }}{{ item.unit_price }} +
+
+
+
{{ formatDate(order.order_date) }}{{ formatDate(order.expected_delivery) }}{{ leadTimeDays(order) }} {{ t('orders.days') }}{{ currencySymbol }}{{ order.total_value.toLocaleString() }}
+
+
@@ -95,6 +140,8 @@ export default { const loading = ref(true) const error = ref(null) const orders = ref([]) + const submittedOrders = ref([]) + const submittedOrdersError = ref(null) // Use shared filters const { @@ -111,12 +158,14 @@ export default { const filters = getCurrentFilters() const fetchedOrders = await api.getOrders(filters) - // Sort orders by order_date (earliest first) - orders.value = fetchedOrders.sort((a, b) => { - const dateA = new Date(a.order_date) - const dateB = new Date(b.order_date) - return dateA - dateB - }) + // Restocking orders (status "Submitted") have their own section below, keep them out of this table + orders.value = fetchedOrders + .filter(order => order.status !== 'Submitted') + .sort((a, b) => { + const dateA = new Date(a.order_date) + const dateB = new Date(b.order_date) + return dateA - dateB + }) } catch (err) { error.value = 'Failed to load orders: ' + err.message } finally { @@ -124,6 +173,14 @@ export default { } } + const loadSubmittedOrders = async () => { + try { + submittedOrders.value = await api.getOrders({ status: 'Submitted' }) + } catch (err) { + submittedOrdersError.value = 'Failed to load submitted orders: ' + err.message + } + } + // Watch for filter changes and reload data watch([selectedPeriod, selectedLocation, selectedCategory, selectedStatus], () => { loadOrders() @@ -153,16 +210,25 @@ export default { }) } - onMounted(loadOrders) + const leadTimeDays = (order) => + Math.round((new Date(order.expected_delivery) - new Date(order.order_date)) / (1000 * 60 * 60 * 24)) + + onMounted(() => { + loadOrders() + loadSubmittedOrders() + }) return { t, loading, error, orders, + submittedOrders, + submittedOrdersError, getOrdersByStatus, getOrderStatusClass, formatDate, + leadTimeDays, currencySymbol, translateProductName, translateCustomerName diff --git a/client/src/views/Restocking.vue b/client/src/views/Restocking.vue new file mode 100644 index 000000000..ce17a5be4 --- /dev/null +++ b/client/src/views/Restocking.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/server/data/demand_forecasts.json b/server/data/demand_forecasts.json index e1b388385..c5f7220f8 100644 --- a/server/data/demand_forecasts.json +++ b/server/data/demand_forecasts.json @@ -6,7 +6,8 @@ "current_demand": 300, "forecasted_demand": 450, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 42.50 }, { "id": "2", @@ -15,7 +16,8 @@ "current_demand": 150, "forecasted_demand": 152, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 18.75 }, { "id": "3", @@ -24,7 +26,8 @@ "current_demand": 500, "forecasted_demand": 600, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 24.00 }, { "id": "4", @@ -33,7 +36,8 @@ "current_demand": 50, "forecasted_demand": 35, "trend": "decreasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 89.99 }, { "id": "5", @@ -42,7 +46,8 @@ "current_demand": 800, "forecasted_demand": 950, "trend": "increasing", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 15.50 }, { "id": "6", @@ -51,7 +56,8 @@ "current_demand": 120, "forecasted_demand": 121, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 56.25 }, { "id": "7", @@ -60,7 +66,8 @@ "current_demand": 250, "forecasted_demand": 252, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 32.00 }, { "id": "8", @@ -69,7 +76,8 @@ "current_demand": 180, "forecasted_demand": 182, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 21.75 }, { "id": "9", @@ -78,6 +86,7 @@ "current_demand": 95, "forecasted_demand": 96, "trend": "stable", - "period": "Next 30 days" + "period": "Next 30 days", + "unit_cost": 68.00 } ] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..3a3cf62de 100644 --- a/server/main.py +++ b/server/main.py @@ -1,7 +1,9 @@ +import random +from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field, conlist from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders app = FastAPI(title="Factory Inventory Management System") @@ -89,6 +91,7 @@ class DemandForecast(BaseModel): forecasted_demand: int trend: str period: str + unit_cost: float class BacklogItem(BaseModel): id: str @@ -120,6 +123,15 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class RestockingOrderItem(BaseModel): + sku: str + name: str + quantity: int = Field(gt=0) + unit_price: float = Field(gt=0) + +class RestockingOrderRequest(BaseModel): + items: conlist(RestockingOrderItem, min_length=1) + # API endpoints @app.get("/") def root(): @@ -166,6 +178,30 @@ def get_demand_forecasts(): """Get demand forecasts""" return demand_forecasts +@app.post("/api/restocking", response_model=Order) +def create_restocking_order(request: RestockingOrderRequest): + """Submit a restocking order built from budget-based recommendations""" + order_date = datetime.now() + delivery_days = random.randint(7, 14) + expected_delivery = order_date + timedelta(days=delivery_days) + + items = [item.model_dump() for item in request.items] + total_value = round(sum(item["quantity"] * item["unit_price"] for item in items), 2) + new_id = str(len(orders) + 1) + + new_order = { + "id": new_id, + "order_number": f"RST-{order_date.year}-{new_id.zfill(4)}", # RST prefix distinguishes restocking from customer ORD- orders + "customer": "Internal Restocking", + "items": items, + "status": "Submitted", + "order_date": order_date.isoformat(timespec='seconds'), + "expected_delivery": expected_delivery.isoformat(timespec='seconds'), + "total_value": total_value + } + orders.append(new_order) + return new_order + @app.get("/api/backlog", response_model=List[BacklogItem]) def get_backlog(): """Get backlog items with purchase order status""" diff --git a/tests/backend/test_misc_endpoints.py b/tests/backend/test_misc_endpoints.py index 5a48fda8c..85876c88a 100644 --- a/tests/backend/test_misc_endpoints.py +++ b/tests/backend/test_misc_endpoints.py @@ -25,6 +25,8 @@ def test_get_demand_forecasts(self, client): assert "forecasted_demand" in forecast assert "trend" in forecast assert "period" in forecast + assert "unit_cost" in forecast + assert isinstance(forecast["unit_cost"], (int, float)) def test_demand_forecast_trends(self, client): """Test that demand forecasts have valid trend values.""" diff --git a/tests/backend/test_restocking.py b/tests/backend/test_restocking.py new file mode 100644 index 000000000..df9c366ac --- /dev/null +++ b/tests/backend/test_restocking.py @@ -0,0 +1,79 @@ +""" +Tests for the restocking order submission endpoint. +""" +from datetime import datetime + +import pytest + + +class TestRestockingEndpoints: + """Test suite for restocking order endpoints.""" + + def _sample_items(self): + return [ + {"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 10, "unit_price": 42.50}, + {"sku": "GSK-203", "name": "High-Temperature Gasket", "quantity": 5, "unit_price": 24.00}, + ] + + def test_create_restocking_order(self, client): + """Test submitting a restocking order returns a Submitted order.""" + response = client.post("/api/restocking", json={"items": self._sample_items()}) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "Submitted" + assert data["order_number"].startswith("RST-") + assert data["customer"] == "Internal Restocking" + assert len(data["items"]) == 2 + + def test_restocking_order_total_value_calculation(self, client): + """Test that total_value is computed server-side from the submitted items.""" + items = self._sample_items() + response = client.post("/api/restocking", json={"items": items}) + data = response.json() + + expected_total = sum(item["quantity"] * item["unit_price"] for item in items) + assert abs(data["total_value"] - expected_total) < 0.01 + + def test_restocking_order_lead_time(self, client): + """Test that expected_delivery is 7-14 days after order_date.""" + response = client.post("/api/restocking", json={"items": self._sample_items()}) + data = response.json() + + order_date = datetime.fromisoformat(data["order_date"]) + expected_delivery = datetime.fromisoformat(data["expected_delivery"]) + lead_time_days = (expected_delivery - order_date).days + + assert 7 <= lead_time_days <= 14 + + def test_restocking_order_appears_in_orders_endpoint(self, client): + """Test that a submitted restocking order round-trips via GET /api/orders.""" + response = client.post("/api/restocking", json={"items": self._sample_items()}) + order_number = response.json()["order_number"] + + response = client.get("/api/orders?status=Submitted") + assert response.status_code == 200 + + data = response.json() + assert any(order["order_number"] == order_number for order in data) + + def test_restocking_order_rejects_empty_items(self, client): + """Test that an empty items list is rejected.""" + response = client.post("/api/restocking", json={"items": []}) + assert response.status_code == 422 + + def test_restocking_order_rejects_non_positive_quantity(self, client): + """Test that a non-positive quantity is rejected.""" + items = [{"sku": "WDG-001", "name": "Industrial Widget Type A", "quantity": 0, "unit_price": 42.50}] + response = client.post("/api/restocking", json={"items": items}) + assert response.status_code == 422 + + def test_demand_forecasts_include_unit_cost(self, client): + """Test that demand forecast items now expose unit_cost for restocking math.""" + response = client.get("/api/demand") + data = response.json() + + for forecast in data: + assert "unit_cost" in forecast + assert isinstance(forecast["unit_cost"], (int, float)) + assert forecast["unit_cost"] > 0 From e86fd621c3fb07cd88651dd5827ddc6ef3f72aa8 Mon Sep 17 00:00:00 2001 From: Juan Carlos Lazcano Date: Wed, 29 Jul 2026 14:08:42 -0400 Subject: [PATCH 2/4] Redesign nav as a left sidebar with a token-based design system Replaces the top horizontal nav with a fixed vertical sidebar (brand, icon nav links, active-state highlighting, profile/language in the footer), backed by a CSS custom-property token system for color, spacing, radius, and shadows. FilterBar now sits in a slim content header instead of its own sticky bar. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M3DuWAWcHvYp4K22srxCwc --- client/src/App.vue | 365 ++++++++++++++++++---------- client/src/components/FilterBar.vue | 10 +- 2 files changed, 239 insertions(+), 136 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index c2da05a5c..bc6ce5f81 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,42 +1,62 @@