diff --git a/.claude/skills/redesign-saas-ui/SKILL.md b/.claude/skills/redesign-saas-ui/SKILL.md
new file mode 100644
index 000000000..ffa8684d7
--- /dev/null
+++ b/.claude/skills/redesign-saas-ui/SKILL.md
@@ -0,0 +1,197 @@
+---
+name: redesign-saas-ui
+description: Redesign a Vue 3 app's UI into a modern SaaS-style interface — replace top navigation with a left vertical sidebar, apply a consistent spacing/design-token system, and polish to a professional look. Use when asked to modernize, restyle, or redesign a Vue frontend, convert a top nav bar into a sidebar layout, or give an app a SaaS/dashboard feel.
+---
+
+# Redesign a Vue 3 App into a Modern SaaS UI
+
+This skill turns an existing Vue 3 application into a modern SaaS-style interface: a **left vertical navigation sidebar** (instead of a top nav bar), a **consistent spacing and design-token system**, and a **polished, professional look**. It is a methodology, not a fixed theme — it discovers the target app first, then adapts.
+
+## When to use
+
+- "Redesign / modernize / restyle this app", "make it look like a SaaS dashboard", "give it a professional look"
+- "Move the top nav into a left sidebar", "add a vertical navigation sidebar"
+- "Make the spacing/typography consistent", "clean up the layout"
+
+## Guardrails (read first)
+
+1. **Delegate all `.vue` work appropriately.** Check the project's `CLAUDE.md`. If it defines a Vue specialist subagent (e.g. `vue-expert`) or a rule that `.vue` files must be created/modified via that agent, you MUST delegate creating/editing `.vue` files to it. This skill's job is to plan, coordinate, and verify. Pass the agent the concrete design tokens, the app-shell structure, and the per-file changes below.
+2. **Preserve behavior.** This is a visual/structural redesign. Do not change routing targets, data loading, API calls, computed logic, or filter behavior unless the redesign strictly requires relocating a control. Every route that worked before must still work.
+3. **Match the stack that's already there.** Detect and reuse the app's conventions — Composition API vs Options API, `
diff --git a/client/src/api.js b/client/src/api.js
index 11cb9db70..7fb9ea4d1 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -102,5 +102,20 @@ export const api = {
async getPurchaseOrderByBacklogItem(backlogItemId) {
const response = await axios.get(`${API_BASE_URL}/purchase-orders/${backlogItemId}`)
return response.data
+ },
+
+ async getRestockCandidates() {
+ const response = await axios.get(`${API_BASE_URL}/restock/candidates`)
+ return response.data
+ },
+
+ async submitRestockOrder(payload) {
+ const response = await axios.post(`${API_BASE_URL}/restock-orders`, payload)
+ return response.data
+ },
+
+ async getRestockOrders() {
+ const response = await axios.get(`${API_BASE_URL}/restock-orders`)
+ return response.data
}
}
diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue
new file mode 100644
index 000000000..0cb7b1a29
--- /dev/null
+++ b/client/src/components/AppSidebar.vue
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
diff --git a/client/src/components/FilterBar.vue b/client/src/components/FilterBar.vue
index 9e12b6945..c809ac617 100644
--- a/client/src/components/FilterBar.vue
+++ b/client/src/components/FilterBar.vue
@@ -102,50 +102,50 @@ export default {
diff --git a/client/src/main.js b/client/src/main.js
index 477c2d966..611c0a3b1 100644
--- a/client/src/main.js
+++ b/client/src/main.js
@@ -7,6 +7,7 @@ import Orders from './views/Orders.vue'
import Demand from './views/Demand.vue'
import Spending from './views/Spending.vue'
import Reports from './views/Reports.vue'
+import Restocking from './views/Restocking.vue'
const router = createRouter({
history: createWebHistory(),
@@ -14,6 +15,7 @@ const router = createRouter({
{ path: '/', component: Dashboard },
{ path: '/inventory', component: Inventory },
{ path: '/orders', component: Orders },
+ { path: '/restocking', component: Restocking },
{ path: '/demand', component: Demand },
{ path: '/spending', component: Spending },
{ path: '/reports', component: Reports }
diff --git a/client/src/views/Orders.vue b/client/src/views/Orders.vue
index 7413f6e66..cd8298dde 100644
--- a/client/src/views/Orders.vue
+++ b/client/src/views/Orders.vue
@@ -5,6 +5,36 @@
A full-stack demo application for factory inventory, orders, demand, and spending analytics. Vue 3 single-page frontend, a stateless FastAPI backend, and in-memory mock data loaded from JSON at startup — no database.
+
+ Vue 3 + Vite
+ Python FastAPI
+ In-memory JSON
+ REST over HTTP
+ SPA
+
+
+
+
+
+
+
+
+
System Architecture
+
Three cleanly separated layers. The browser runs the SPA; the API server filters data in-process; JSON files seed the in-memory store at boot.
+
+
+
+
+ Client · Port 3000
+
Presentation — Vue 3 SPA
+
Composition API, client-side routing, hand-rolled SVG charts. Served by Vite in dev.
Minimal, dependency-light stack. No database, no charting library, no CSS framework — charts and styles are hand-built.
+
+
+
+
+
Frontend
+
Client · Port 3000
+
FrameworkVue 3.4 (Composition API)
+
Build toolVite 5
+
Routingvue-router 4
+
HTTP clientaxios 1.6
+
ChartsInline SVG (custom)
+
StylingScoped CSS (no framework)
+
+
+
+
Backend
+
Server · Port 8001
+
FrameworkFastAPI 0.110+
+
ServerUvicorn
+
ValidationPydantic 2
+
RuntimePython 3.11+
+
CORSFully open (dev)
+
DocsSwagger UI at /docs
+
+
+
+
Data & Tooling
+
Storage · Dev
+
StoreIn-memory globals
+
Source7 JSON files
+
PersistenceNone
+
Py package mgruv
+
JS package mgrnpm
+
TransportREST / JSON
+
+
+
+
+
+
+
+
+
+
Data Flow
+
A single filter change propagates through the singleton filter store, out to the API, and back into reactive views.
+
+
+
+
+
1
+
User selects a filter
+
<select> in FilterBar.vue — Time Period, Warehouse, Category, or Order Status.
+
+
+
2
+
Shared state mutates
+
v-model updates a module-level ref in useFilters (singleton), shared by every view.
+
+
+
3
+
Watchers fire
+
Each mounted view watches its filters and re-runs its loader via api.js.
+
+
+
4
+
API filters in-memory
+
FastAPI runs apply_filters() / filter_by_month() over the JSON-seeded lists, validates with Pydantic.
+
+
+
5
+
Reactive render
+
Response lands in a ref; computed props transform it into tables and SVG charts.
+
+
+
+
+
Primary API Surface
+
+
+
+
Method
Endpoint
Filters
Returns
+
+
+
GET
/api/inventory
warehouse, category
Stock items (Pydantic-validated)
+
GET
/api/orders
warehouse, category, status, month
Customer orders
+
GET
/api/dashboard/summary
warehouse, category, status, month
KPI aggregates
+
GET
/api/demand
—
Demand forecasts
+
GET
/api/backlog
—
Backlog + computed PO flag
+
GET
/api/spending/*
— (client-side filtering)
Summary, monthly, categories, transactions
+
GET
/api/reports/*
— (inline bucketing)
Quarterly & monthly trends
+
+
+
+
+
+
+
+
+
+
Architectural Notes
+
Characteristics worth knowing before extending the system.
+
+
+
+
Stateless, in-process filtering
+
Every request filters in-memory lists with plain Python comprehensions — no caching, indexes, or pagination. Fine at demo scale (250 orders); the bottleneck under real load.
+
+
+
Singleton filter state
+
The 4 filters live as module-level refs in useFilters, so the FilterBar and all views share one source of truth without a store like Pinia.
+
+
+
No proxy — CORS bridges the gap
+
Vite has no dev proxy; the client hits http://localhost:8001/api directly. The backend's fully-open CORS (allow_origins=["*"]) is dev-only and must be tightened for production.
+
+
+
Read-only demo, mutations stubbed
+
Only GET endpoints are implemented. api.js declares task/purchase-order POST/PATCH/DELETE calls and CreatePurchaseOrderRequest exists, but no backend routes back them — purchase_orders.json is empty.
+
+
+
+
+
+
+
+
diff --git a/server/main.py b/server/main.py
index a0c2d8c5a..5a62705a0 100644
--- a/server/main.py
+++ b/server/main.py
@@ -1,9 +1,14 @@
+import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from typing import List, Optional
from pydantic import BaseModel
from mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders
+# In-memory store for restock orders submitted from the Restocking tab.
+# Matches the app's mock-data pattern: lives for the process lifetime, cleared on restart.
+submitted_orders: List[dict] = []
+
app = FastAPI(title="Factory Inventory Management System")
# Quarter mapping for date filtering
@@ -46,6 +51,57 @@ def apply_filters(items: list, warehouse: Optional[str] = None, category: Option
return filtered
+# --- Restocking helpers -----------------------------------------------------
+# Simulated delivery lead time (in days) per product category. Real supply-chain
+# lead times vary by product type, so we model that instead of a single default.
+CATEGORY_LEAD_TIME_DAYS = {
+ "Circuit Boards": 10,
+ "Sensors": 7,
+ "Actuators": 21,
+ "Controllers": 14,
+ "Power Supplies": 12,
+}
+DEFAULT_LEAD_TIME_DAYS = 9
+
+def infer_category(item_name: str, sku: str) -> str:
+ """Best-effort category for a demand-forecast item.
+
+ Most demand SKUs are not present in the inventory dataset, so we fall back to
+ keyword matching on the item name when no inventory record exists.
+ """
+ inv = next((i for i in inventory_items if i["sku"] == sku), None)
+ if inv:
+ return inv["category"]
+
+ name = item_name.lower()
+ if any(k in name for k in ("motor", "servo", "stepper", "actuator")):
+ return "Actuators"
+ if "sensor" in name:
+ return "Sensors"
+ if any(k in name for k in ("controller", "board", "logic")):
+ return "Controllers"
+ if "power supply" in name or "psu" in name:
+ return "Power Supplies"
+ if "pcb" in name or "circuit" in name:
+ return "Circuit Boards"
+ return "General"
+
+def estimate_unit_cost(sku: str) -> float:
+ """Unit cost from inventory when the SKU exists there; otherwise a stable estimate.
+
+ The estimate is derived deterministically from the SKU string so the same item
+ always prices the same across requests (no randomness).
+ """
+ inv = next((i for i in inventory_items if i["sku"] == sku), None)
+ if inv:
+ return float(inv["unit_cost"])
+ base = sum(ord(c) for c in sku)
+ return round(15 + (base % 200), 2)
+
+def lead_time_for(category: str) -> int:
+ """Delivery lead time in days for a given category."""
+ return CATEGORY_LEAD_TIME_DAYS.get(category, DEFAULT_LEAD_TIME_DAYS)
+
# CORS middleware
app.add_middleware(
CORSMiddleware,
@@ -120,6 +176,42 @@ class CreatePurchaseOrderRequest(BaseModel):
expected_delivery_date: str
notes: Optional[str] = None
+class RestockCandidate(BaseModel):
+ item_sku: str
+ item_name: str
+ category: str
+ current_demand: int
+ forecasted_demand: int
+ recommended_quantity: int
+ unit_cost: float
+ line_total: float
+ lead_time_days: int
+
+class RestockOrderItem(BaseModel):
+ item_sku: str
+ item_name: str
+ category: str
+ quantity: int
+ unit_cost: float
+ line_total: float
+ lead_time_days: int
+
+class CreateRestockOrderRequest(BaseModel):
+ budget: float
+ items: List[RestockOrderItem]
+
+class RestockOrder(BaseModel):
+ id: str
+ order_number: str
+ status: str
+ order_date: str
+ expected_delivery: str
+ lead_time_days: int
+ budget: float
+ total_value: float
+ item_count: int
+ items: List[RestockOrderItem]
+
# API endpoints
@app.get("/")
def root():
@@ -166,6 +258,78 @@ def get_demand_forecasts():
"""Get demand forecasts"""
return demand_forecasts
+@app.get("/api/restock/candidates", response_model=List[RestockCandidate])
+def get_restock_candidates():
+ """Restock recommendations derived from the demand forecast.
+
+ Includes only items with a positive demand gap (forecasted > current). Each item
+ is enriched with an estimated unit cost, the recommended quantity (the gap), the
+ resulting line total, and a simulated delivery lead time. Sorted by largest gap
+ first so the frontend can greedily fill a budget starting with the biggest shortfalls.
+ """
+ candidates = []
+ for f in demand_forecasts:
+ gap = f["forecasted_demand"] - f["current_demand"]
+ if gap <= 0:
+ continue
+ category = infer_category(f["item_name"], f["item_sku"])
+ unit_cost = estimate_unit_cost(f["item_sku"])
+ candidates.append({
+ "item_sku": f["item_sku"],
+ "item_name": f["item_name"],
+ "category": category,
+ "current_demand": f["current_demand"],
+ "forecasted_demand": f["forecasted_demand"],
+ "recommended_quantity": gap,
+ "unit_cost": unit_cost,
+ "line_total": round(gap * unit_cost, 2),
+ "lead_time_days": lead_time_for(category),
+ })
+ candidates.sort(key=lambda c: c["recommended_quantity"], reverse=True)
+ return candidates
+
+@app.get("/api/restock-orders", response_model=List[RestockOrder])
+def get_restock_orders():
+ """Get all submitted restock orders, newest first."""
+ return list(reversed(submitted_orders))
+
+@app.post("/api/restock-orders", response_model=RestockOrder, status_code=201)
+def create_restock_order(request: CreateRestockOrderRequest):
+ """Submit a restock order.
+
+ Validates the order is non-empty and within budget, then stamps it with an order
+ number, submission date, and expected delivery date. The order's overall lead time
+ is the longest lead time among its items (everything has arrived by then). The
+ resulting order is surfaced in the Orders tab's Submitted Orders section.
+ """
+ if not request.items:
+ raise HTTPException(status_code=400, detail="Restock order must contain at least one item")
+
+ total_value = round(sum(item.line_total for item in request.items), 2)
+ # Small epsilon guards against float rounding when the total exactly equals the budget.
+ if total_value > request.budget + 0.001:
+ raise HTTPException(status_code=400, detail="Order total exceeds the available budget")
+
+ max_lead = max(item.lead_time_days for item in request.items)
+ order_date = datetime.date.today()
+ expected_delivery = order_date + datetime.timedelta(days=max_lead)
+ seq = len(submitted_orders) + 1
+
+ order = {
+ "id": f"restock-{seq}",
+ "order_number": f"RO-{1000 + seq}",
+ "status": "Submitted",
+ "order_date": order_date.isoformat(),
+ "expected_delivery": expected_delivery.isoformat(),
+ "lead_time_days": max_lead,
+ "budget": round(request.budget, 2),
+ "total_value": total_value,
+ "item_count": len(request.items),
+ "items": [item.model_dump() for item in request.items],
+ }
+ submitted_orders.append(order)
+ return order
+
@app.get("/api/backlog", response_model=List[BacklogItem])
def get_backlog():
"""Get backlog items with purchase order status"""