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 @@

{{ t('orders.description') }}

+
+
+

Submitted Orders

+
+
Loading submitted orders...
+
{{ restockError }}
+
+ No restock orders submitted yet. +
+
+ +
+
+
{{ t('common.loading') }}
{{ error }}
@@ -96,6 +126,10 @@ export default { const error = ref(null) const orders = ref([]) + const restockOrders = ref([]) + const restockLoading = ref(true) + const restockError = ref(null) + // Use shared filters const { selectedPeriod, @@ -129,6 +163,18 @@ export default { loadOrders() }) + const loadRestockOrders = async () => { + try { + restockLoading.value = true + restockError.value = null + restockOrders.value = await api.getRestockOrders() + } catch (err) { + restockError.value = 'Failed to load submitted restock orders: ' + err.message + } finally { + restockLoading.value = false + } + } + const getOrdersByStatus = (status) => { return orders.value.filter(order => order.status === status) } @@ -153,13 +199,19 @@ export default { }) } - onMounted(loadOrders) + onMounted(() => { + loadOrders() + loadRestockOrders() + }) return { t, loading, error, orders, + restockOrders, + restockLoading, + restockError, getOrdersByStatus, getOrderStatusClass, formatDate, @@ -172,6 +224,97 @@ export default { diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 000000000..a40318ea3 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,403 @@ + + + + + + Architecture — Factory Inventory Management + + + +
+
+

System Architecture

+

Factory Inventory Management System

+

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.

+
+ App.vue — layout + global FilterBar + vue-router — 6 routes + Dashboard + Inventory + Orders + Demand + Spending + Reports + useFilters — singleton state + api.js — axios client +
+
+ +
+ + HTTP · REST · JSON  (GET /api/* · CORS open) +
+ +
+ Server · Port 8001 +

Application — FastAPI

+

Stateless request handling. Pure-Python filtering over in-memory lists, Pydantic response validation, Swagger at /docs.

+
+ main.py — 14 GET endpoints + apply_filters() — warehouse / category / status + filter_by_month() — month + quarter + Pydantic models + CORSMiddleware +
+
+ +
+ + Loaded once at import  (mock_data.py) +
+ +
+ Data · In-memory +

Data — JSON Mock Store

+

Read from server/data/ into module-level globals at startup. No persistence; a restart reloads from disk.

+
+ inventory.json · 32 + orders.json · 250 + demand_forecasts.json · 9 + backlog_items.json · 4 + spending.json · 3 sets + transactions.json · 56 + purchase_orders.json · 0 +
+
+
+
+
+ + +
+
+
+

Tech Stack

+

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

+
+ + + + + + + + + + + + + +
MethodEndpointFiltersReturns
GET/api/inventorywarehouse, categoryStock items (Pydantic-validated)
GET/api/orderswarehouse, category, status, monthCustomer orders
GET/api/dashboard/summarywarehouse, category, status, monthKPI aggregates
GET/api/demandDemand forecasts
GET/api/backlogBacklog + 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"""