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 @@ @@ -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/Reports.vue b/client/src/views/Reports.vue index 35187eaf3..364a3e98b 100644 --- a/client/src/views/Reports.vue +++ b/client/src/views/Reports.vue @@ -142,38 +142,28 @@ export default { } }, mounted() { - console.log('Reports component mounted') this.loadData() }, methods: { async loadData() { - console.log('Loading reports data...') try { this.loading = true // Fetch quarterly data - console.log('Fetching quarterly data...') const quarterlyResponse = await axios.get('http://localhost:8001/api/reports/quarterly') this.quarterlyData = quarterlyResponse.data - console.log('Quarterly data:', this.quarterlyData) // Fetch monthly data - console.log('Fetching monthly data...') const monthlyResponse = await axios.get('http://localhost:8001/api/reports/monthly-trends') this.monthlyData = monthlyResponse.data - console.log('Monthly data:', this.monthlyData) // Calculate summary stats - console.log('Calculating summary stats...') this.calculateSummaryStats() - console.log('Summary stats calculated') } catch (err) { - console.log('Error loading reports:', err) this.error = 'Failed to load reports: ' + err.message } finally { this.loading = false - console.log('Loading complete') } }, @@ -212,7 +202,6 @@ export default { }, formatNumber(num) { - console.log('Formatting number:', num) // Format number with commas var str = num.toString() var parts = str.split('.') @@ -240,7 +229,6 @@ export default { }, formatMonth(monthStr) { - console.log('Formatting month:', monthStr) // Convert YYYY-MM to readable format var parts = monthStr.split('-') var year = parts[0] @@ -253,7 +241,6 @@ export default { }, getBarHeight(revenue) { - console.log('Calculating bar height for revenue:', revenue) // Calculate bar height (max height 200px) var maxRevenue = 0 for (var i = 0; i < this.monthlyData.length; i++) { 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/package-lock.json b/package-lock.json new file mode 100644 index 000000000..357d5a013 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,71 @@ +{ + "name": "inventory-management", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@playwright/test": "^1.62.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..1f9b2c373 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@playwright/test": "^1.62.0" + } +} 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/data/tasks.json b/server/data/tasks.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/server/data/tasks.json @@ -0,0 +1 @@ +[] diff --git a/server/main.py b/server/main.py index a0c2d8c5a..e17b6fcf8 100644 --- a/server/main.py +++ b/server/main.py @@ -1,8 +1,10 @@ +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 mock_data import inventory_items, orders, demand_forecasts, backlog_items, spending_summary, monthly_spending, category_spending, recent_transactions, purchase_orders +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, tasks 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,27 @@ class CreatePurchaseOrderRequest(BaseModel): expected_delivery_date: str notes: Optional[str] = None +class Task(BaseModel): + id: str + title: str + priority: str + dueDate: str + status: str + +class CreateTaskRequest(BaseModel): + title: str + priority: str = "medium" + dueDate: str + +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 +190,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""" @@ -179,6 +227,71 @@ def get_backlog(): result.append(item_dict) return result +@app.post("/api/purchase-orders", response_model=PurchaseOrder) +def create_purchase_order(request: CreatePurchaseOrderRequest): + """Create a purchase order for a backlog item""" + backlog_item = next((b for b in backlog_items if b["id"] == request.backlog_item_id), None) + if not backlog_item: + raise HTTPException(status_code=404, detail="Backlog item not found") + + new_po = { + "id": str(len(purchase_orders) + 1), + "backlog_item_id": request.backlog_item_id, + "supplier_name": request.supplier_name, + "quantity": request.quantity, + "unit_cost": request.unit_cost, + "expected_delivery_date": request.expected_delivery_date, + "status": "Pending", + "created_date": datetime.now().isoformat(timespec='seconds'), + "notes": request.notes + } + purchase_orders.append(new_po) + return new_po + +@app.get("/api/purchase-orders/{backlog_item_id}", response_model=PurchaseOrder) +def get_purchase_order_by_backlog_item(backlog_item_id: str): + """Get the purchase order associated with a backlog item""" + po = next((po for po in reversed(purchase_orders) if po["backlog_item_id"] == backlog_item_id), None) + if not po: + raise HTTPException(status_code=404, detail="Purchase order not found") + return po + +@app.get("/api/tasks", response_model=List[Task]) +def get_tasks(): + """Get all tasks""" + return tasks + +@app.post("/api/tasks", response_model=Task) +def create_task(request: CreateTaskRequest): + """Create a new task""" + new_task = { + "id": str(len(tasks) + 1), + "title": request.title, + "priority": request.priority, + "dueDate": request.dueDate, + "status": "pending" + } + tasks.append(new_task) + return new_task + +@app.patch("/api/tasks/{task_id}", response_model=Task) +def toggle_task(task_id: str): + """Toggle a task's status between pending and completed""" + task = next((t for t in tasks if t["id"] == task_id), None) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + task["status"] = "completed" if task["status"] == "pending" else "pending" + return task + +@app.delete("/api/tasks/{task_id}") +def delete_task(task_id: str): + """Delete a task""" + task = next((t for t in tasks if t["id"] == task_id), None) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + tasks.remove(task) + return {"success": True} + @app.get("/api/dashboard/summary") def get_dashboard_summary( warehouse: Optional[str] = None, diff --git a/server/mock_data.py b/server/mock_data.py index 2a9cd7dcb..7a9694a42 100644 --- a/server/mock_data.py +++ b/server/mock_data.py @@ -35,5 +35,8 @@ def load_json_file(filename): # Load purchase orders purchase_orders = load_json_file('purchase_orders.json') +# Load tasks +tasks = load_json_file('tasks.json') + # All data is now loaded from JSON files in the data/ directory # This allows for easier maintenance and updates of the sample data 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