diff --git a/ginos-gelato/client/package-lock.json b/ginos-gelato/client/package-lock.json
index ab4c67b..52f4a9e 100644
--- a/ginos-gelato/client/package-lock.json
+++ b/ginos-gelato/client/package-lock.json
@@ -74,7 +74,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1414,7 +1413,6 @@
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1432,7 +1430,6 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1488,7 +1485,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -1676,7 +1672,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1910,7 +1905,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -2348,7 +2342,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3142,7 +3135,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -3685,7 +3677,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3881,7 +3872,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3894,7 +3884,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4484,7 +4473,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4554,7 +4542,6 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
diff --git a/ginos-gelato/client/src/App.tsx b/ginos-gelato/client/src/App.tsx
index 0b66338..4078128 100644
--- a/ginos-gelato/client/src/App.tsx
+++ b/ginos-gelato/client/src/App.tsx
@@ -8,6 +8,7 @@ import AboutUs from './pages/AboutUs';
import OurFlavors from './pages/OurFlavors';
import Locations from './pages/Locations';
import Catering from './pages/Catering';
+import ManagementDashboard from './pages/ManagementDashboard';
import Header from './components/Layout/Header';
import Footer from './components/Layout/Footer';
import { CartProvider } from './contexts/CartContext';
@@ -28,6 +29,7 @@ const App: React.FC = () => {
} />
} />
} />
+ } />
diff --git a/ginos-gelato/client/src/components/Layout/Header.tsx b/ginos-gelato/client/src/components/Layout/Header.tsx
index 722e3eb..6cb4460 100644
--- a/ginos-gelato/client/src/components/Layout/Header.tsx
+++ b/ginos-gelato/client/src/components/Layout/Header.tsx
@@ -53,6 +53,15 @@ const Header: React.FC = () => {
+
+
+ 📊 Management
+
+
+
diff --git a/ginos-gelato/client/src/pages/ManagementDashboard.tsx b/ginos-gelato/client/src/pages/ManagementDashboard.tsx
new file mode 100644
index 0000000..17f3b22
--- /dev/null
+++ b/ginos-gelato/client/src/pages/ManagementDashboard.tsx
@@ -0,0 +1,423 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+ getQueueStats,
+ getPeakHours,
+ getHistoricalMetrics,
+ exportOrdersCsv,
+ updateOrderStatus,
+} from '../services/api';
+import type { QueueStats, PeakHourData, DailyMetrics, ActiveOrderInfo, OrderStatus } from '../types';
+
+const STATUS_COLORS: Record = {
+ Pending: 'bg-yellow-100 text-yellow-800 border border-yellow-300',
+ InProgress: 'bg-blue-100 text-blue-800 border border-blue-300',
+ Completed: 'bg-green-100 text-green-800 border border-green-300',
+ Cancelled: 'bg-red-100 text-red-800 border border-red-300',
+};
+
+const AUTO_REFRESH_INTERVAL_MS = 30_000;
+
+const STATUS_EMOJI: Record = {
+ Pending: '⏳',
+ InProgress: '🍦',
+ Completed: '✅',
+ Cancelled: '❌',
+};
+
+const EXPORT_RANGE_OPTIONS = [
+ { label: 'Last 7 days', value: 7 },
+ { label: 'Last 30 days', value: 30 },
+ { label: 'Last 90 days', value: 90 },
+];
+
+function formatHour(hour: number): string {
+ if (hour === 0) return '12 AM';
+ if (hour < 12) return `${hour} AM`;
+ if (hour === 12) return '12 PM';
+ return `${hour - 12} PM`;
+}
+
+function PeakHoursChart({ data }: { data: PeakHourData[] }) {
+ const maxCount = Math.max(...data.map(d => d.orderCount), 1);
+ const businessHours = data.filter(d => d.hour >= 9 && d.hour <= 21);
+
+ return (
+
+
+ {businessHours.map(d => {
+ const heightPct = (d.orderCount / maxCount) * 100;
+ const isPeak = d.orderCount === maxCount && maxCount > 0;
+ return (
+
+
0 ? 5 : 0)}%` }}
+ />
+ {/* Tooltip */}
+
+
+ {d.orderCount} orders
+ {d.averageWaitMinutes > 0 && (
+ · {d.averageWaitMinutes}m avg
+ )}
+
+
+
+
+ {formatHour(d.hour)}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function ActiveOrderRow({
+ order,
+ onStatusChange,
+}: {
+ order: ActiveOrderInfo;
+ onStatusChange: (id: number, status: OrderStatus) => void;
+}) {
+ const statusClass = STATUS_COLORS[order.status] ?? '';
+
+ return (
+
+ | #{order.orderId} |
+ {order.customerName || '—'} |
+
+
+ {STATUS_EMOJI[order.status]} {order.status}
+
+ |
+ {order.orderType} |
+ {order.elapsedMinutes} min |
+ {order.iceCreamCount} item(s) |
+
+
+ {order.status === 'Pending' && (
+
+ )}
+ {order.status === 'InProgress' && (
+
+ )}
+ {(order.status === 'Pending' || order.status === 'InProgress') && (
+
+ )}
+
+ |
+
+ );
+}
+
+const ManagementDashboard: React.FC = () => {
+ const [queueStats, setQueueStats] = useState
(null);
+ const [peakHours, setPeakHours] = useState([]);
+ const [history, setHistory] = useState([]);
+ const [historyDays, setHistoryDays] = useState(30);
+ const [peakDays, setPeakDays] = useState(7);
+ const [exportDays, setExportDays] = useState(30);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [exporting, setExporting] = useState(false);
+ const [lastRefresh, setLastRefresh] = useState(new Date());
+
+ const loadData = useCallback(async () => {
+ try {
+ setError(null);
+ const [stats, peaks, hist] = await Promise.all([
+ getQueueStats(),
+ getPeakHours(peakDays),
+ getHistoricalMetrics(historyDays),
+ ]);
+ setQueueStats(stats);
+ setPeakHours(peaks);
+ setHistory(hist);
+ setLastRefresh(new Date());
+ } catch {
+ setError('Unable to load dashboard data. Is the backend running?');
+ } finally {
+ setLoading(false);
+ }
+ }, [peakDays, historyDays]);
+
+ useEffect(() => {
+ loadData();
+ const interval = setInterval(loadData, AUTO_REFRESH_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [loadData]);
+
+ const handleStatusChange = async (orderId: number, status: OrderStatus) => {
+ try {
+ await updateOrderStatus(orderId, status);
+ await loadData();
+ } catch {
+ setError('Failed to update order status.');
+ }
+ };
+
+ const todayRevenue = useMemo(() => {
+ const todayPrefix = new Date().toISOString().slice(0, 10);
+ return history.find(d => d.date.startsWith(todayPrefix))?.totalRevenue ?? 0;
+ }, [history]);
+
+ const handleExport = async () => {
+ setExporting(true);
+ try {
+ await exportOrdersCsv(exportDays);
+ } catch {
+ setError('CSV export failed.');
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
🍦
+
Loading dashboard…
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ 📊 Management Dashboard
+
+
+ Last updated: {lastRefresh.toLocaleTimeString()} · Auto-refreshes every 30s
+
+
+
+
+
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* KPI Cards */}
+ {queueStats && (
+
+
+
In Queue
+
{queueStats.totalInQueue}
+
+ {queueStats.pendingCount} pending · {queueStats.inProgressCount} in progress
+
+
+
+
Avg Prep Time
+
+ {queueStats.averagePrepTimeMinutes}
+ min
+
+
Last 24 hours
+
+
+
Est. Wait (new)
+
+ {queueStats.estimatedWaitMinutes}
+ min
+
+
Based on current queue
+
+
+
Today's Revenue
+
+ ${todayRevenue.toFixed(2)}
+
+
Completed orders
+
+
+ )}
+
+ {/* Active Orders */}
+
+
+
🍦 Active Order Queue
+ {queueStats && (
+
+ {queueStats.totalInQueue} order(s)
+
+ )}
+
+ {queueStats && queueStats.activeOrders.length > 0 ? (
+
+
+
+
+ | Order |
+ Customer |
+ Status |
+ Type |
+ Elapsed |
+ Items |
+ Actions |
+
+
+
+ {queueStats.activeOrders.map(order => (
+
+ ))}
+
+
+
+ ) : (
+
+
🎉
+
No active orders right now!
+
+ )}
+
+
+ {/* Peak Hours Chart */}
+
+
+
📈 Peak Hours
+
+
+ {peakHours.length > 0 ? (
+
+ ) : (
+
No data available yet.
+ )}
+
+ 🟠 Peak hour highlighted in orange
+
+
+
+ {/* Historical Metrics Table */}
+
+
+
📅 Historical Performance
+
+
+ {history.length > 0 ? (
+
+
+
+
+ | Date |
+ Total Orders |
+ Completed |
+ Avg Prep (min) |
+ Revenue |
+
+
+
+ {history.map(day => (
+
+ |
+ {new Date(day.date).toLocaleDateString(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ })}
+ |
+ {day.totalOrders} |
+ {day.completedOrders} |
+
+ {day.averagePrepTimeMinutes > 0 ? `${day.averagePrepTimeMinutes}` : '—'}
+ |
+
+ ${day.totalRevenue.toFixed(2)}
+ |
+
+ ))}
+
+
+
+ ) : (
+
No historical data available.
+ )}
+
+
+ {/* CSV Export */}
+
+
📤 Export Report
+
+
+
+
+ Includes order ID, customer, status, prep time and revenue data.
+
+
+
+
+ );
+};
+
+export default ManagementDashboard;
diff --git a/ginos-gelato/client/src/services/api.ts b/ginos-gelato/client/src/services/api.ts
index 1cb458c..4ad4a9c 100644
--- a/ginos-gelato/client/src/services/api.ts
+++ b/ginos-gelato/client/src/services/api.ts
@@ -1,4 +1,5 @@
import axios from 'axios';
+import type { QueueStats, PeakHourData, DailyMetrics, OrderStatus, OrderType } from '../types';
const API_BASE_URL = 'http://localhost:5000/api'; // Adjust the base URL as needed
@@ -33,4 +34,54 @@ export const createOrder = async (orderData: any) => {
console.error('Error creating order:', error);
throw error;
}
+};
+
+// Function to update order status
+export const updateOrderStatus = async (orderId: number, status: OrderStatus) => {
+ try {
+ const response = await axios.patch(`${API_BASE_URL}/orders/${orderId}/status`, { status });
+ return response.data;
+ } catch (error) {
+ console.error('Error updating order status:', error);
+ throw error;
+ }
+};
+
+// Queue management API functions
+
+export const getQueueStats = async (): Promise => {
+ const response = await axios.get(`${API_BASE_URL}/queue/stats`);
+ return response.data;
+};
+
+export const getAveragePrepTime = async (orderType?: OrderType): Promise<{ averagePrepTimeMinutes: number; orderType: OrderType | null }> => {
+ const params = orderType ? { orderType } : {};
+ const response = await axios.get(`${API_BASE_URL}/queue/average-prep-time`, { params });
+ return response.data;
+};
+
+export const getPeakHours = async (days = 7): Promise => {
+ const response = await axios.get(`${API_BASE_URL}/queue/peak-hours`, { params: { days } });
+ return response.data;
+};
+
+export const getHistoricalMetrics = async (days = 30): Promise => {
+ const response = await axios.get(`${API_BASE_URL}/queue/history`, { params: { days } });
+ return response.data;
+};
+
+export const exportOrdersCsv = async (days = 30): Promise => {
+ const response = await axios.get(`${API_BASE_URL}/queue/export/csv`, {
+ params: { days },
+ responseType: 'blob',
+ });
+ const url = window.URL.createObjectURL(new Blob([response.data]));
+ const link = document.createElement('a');
+ link.href = url;
+ const filename = `orders-${new Date().toISOString().slice(0, 10)}.csv`;
+ link.setAttribute('download', filename);
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
};
\ No newline at end of file
diff --git a/ginos-gelato/client/src/types/index.ts b/ginos-gelato/client/src/types/index.ts
index bbba827..dc10d6f 100644
--- a/ginos-gelato/client/src/types/index.ts
+++ b/ginos-gelato/client/src/types/index.ts
@@ -22,4 +22,41 @@ export type Order = {
iceCreams: IceCream[];
totalAmount: number;
createdAt: string;
+};
+
+export type OrderStatus = 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
+export type OrderType = 'Pickup' | 'Delivery';
+
+export type ActiveOrderInfo = {
+ orderId: number;
+ customerName: string;
+ status: OrderStatus;
+ orderType: OrderType;
+ orderDate: string;
+ startedAt: string | null;
+ elapsedMinutes: number;
+ iceCreamCount: number;
+};
+
+export type QueueStats = {
+ totalInQueue: number;
+ pendingCount: number;
+ inProgressCount: number;
+ averagePrepTimeMinutes: number;
+ estimatedWaitMinutes: number;
+ activeOrders: ActiveOrderInfo[];
+};
+
+export type PeakHourData = {
+ hour: number;
+ orderCount: number;
+ averageWaitMinutes: number;
+};
+
+export type DailyMetrics = {
+ date: string;
+ totalOrders: number;
+ completedOrders: number;
+ averagePrepTimeMinutes: number;
+ totalRevenue: number;
};
\ No newline at end of file
diff --git a/ginos-gelato/server/Controllers/OrdersController.cs b/ginos-gelato/server/Controllers/OrdersController.cs
index aab6e4c..5e0f546 100644
--- a/ginos-gelato/server/Controllers/OrdersController.cs
+++ b/ginos-gelato/server/Controllers/OrdersController.cs
@@ -20,7 +20,7 @@ public OrdersController(OrderService orderService)
[HttpPost]
public async Task> CreateOrder(Order order)
{
- var createdOrder = await _orderService.CreateOrderAsync(order.IceCreams);
+ var createdOrder = await _orderService.CreateOrderAsync(order.IceCreams, order.CustomerName, order.OrderType);
return CreatedAtAction(nameof(GetOrder), new { id = createdOrder.Id }, createdOrder);
}
@@ -41,5 +41,18 @@ public async Task>> GetOrders()
var orders = await _orderService.GetOrdersAsync();
return Ok(orders);
}
+
+ [HttpPatch("{id}/status")]
+ public async Task> UpdateOrderStatus(int id, [FromBody] UpdateOrderStatusRequest request)
+ {
+ var order = await _orderService.UpdateOrderStatusAsync(id, request.Status);
+ if (order == null)
+ {
+ return NotFound();
+ }
+ return Ok(order);
+ }
}
-}
\ No newline at end of file
+
+ public record UpdateOrderStatusRequest(OrderStatus Status);
+}
diff --git a/ginos-gelato/server/Controllers/QueueController.cs b/ginos-gelato/server/Controllers/QueueController.cs
new file mode 100644
index 0000000..3eb1a9e
--- /dev/null
+++ b/ginos-gelato/server/Controllers/QueueController.cs
@@ -0,0 +1,59 @@
+using Microsoft.AspNetCore.Mvc;
+using GinosGelato.Models;
+using GinosGelato.Services;
+
+namespace GinosGelato.Controllers
+{
+ [Route("api/[controller]")]
+ [ApiController]
+ public class QueueController : ControllerBase
+ {
+ private readonly OrderQueueService _queueService;
+
+ public QueueController(OrderQueueService queueService)
+ {
+ _queueService = queueService;
+ }
+
+ /// Returns current queue stats including active orders and estimated wait time.
+ [HttpGet("stats")]
+ public async Task GetQueueStats()
+ {
+ var stats = await _queueService.GetQueueStatsAsync();
+ return Ok(stats);
+ }
+
+ /// Returns average preparation time for the last 24 hours, optionally filtered by order type.
+ [HttpGet("average-prep-time")]
+ public async Task GetAveragePrepTime([FromQuery] OrderType? orderType = null)
+ {
+ var minutes = await _queueService.GetAveragePrepTimeAsync(orderType);
+ return Ok(new { averagePrepTimeMinutes = minutes, orderType });
+ }
+
+ /// Returns order volume and average wait time grouped by hour of day.
+ [HttpGet("peak-hours")]
+ public async Task GetPeakHours([FromQuery] int days = 7)
+ {
+ var data = await _queueService.GetPeakHoursAsync(days);
+ return Ok(data);
+ }
+
+ /// Returns daily order metrics for the given number of past days.
+ [HttpGet("history")]
+ public async Task GetHistory([FromQuery] int days = 30)
+ {
+ var metrics = await _queueService.GetHistoricalMetricsAsync(days);
+ return Ok(metrics);
+ }
+
+ /// Downloads a CSV report of all orders in the given date range.
+ [HttpGet("export/csv")]
+ public async Task ExportCsv([FromQuery] int days = 30)
+ {
+ var csvBytes = await _queueService.ExportToCsvAsync(days);
+ var filename = $"orders-{DateTime.UtcNow:yyyy-MM-dd}.csv";
+ return File(csvBytes, "text/csv", filename);
+ }
+ }
+}
diff --git a/ginos-gelato/server/Models/Order.cs b/ginos-gelato/server/Models/Order.cs
index dfc5fb5..34e8dcc 100644
--- a/ginos-gelato/server/Models/Order.cs
+++ b/ginos-gelato/server/Models/Order.cs
@@ -1,5 +1,19 @@
namespace GinosGelato.Models
{
+ public enum OrderStatus
+ {
+ Pending,
+ InProgress,
+ Completed,
+ Cancelled
+ }
+
+ public enum OrderType
+ {
+ Pickup,
+ Delivery
+ }
+
public class Order
{
public int Id { get; set; }
@@ -7,5 +21,9 @@ public class Order
public List IceCreams { get; set; } = new List();
public decimal TotalPrice { get; set; }
public DateTime OrderDate { get; set; }
+ public OrderStatus Status { get; set; } = OrderStatus.Pending;
+ public OrderType OrderType { get; set; } = OrderType.Pickup;
+ public DateTime? StartedAt { get; set; }
+ public DateTime? CompletedAt { get; set; }
}
}
\ No newline at end of file
diff --git a/ginos-gelato/server/Program.cs b/ginos-gelato/server/Program.cs
index 7746fd5..b3447f6 100644
--- a/ginos-gelato/server/Program.cs
+++ b/ginos-gelato/server/Program.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using GinosGelato.Data;
+using GinosGelato.Models;
using GinosGelato.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -9,6 +10,7 @@
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
+ options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter());
});
// Add Entity Framework
@@ -17,6 +19,7 @@
// Add custom services
builder.Services.AddScoped();
+builder.Services.AddScoped();
// Add Swagger/OpenAPI
builder.Services.AddEndpointsApiExplorer();
@@ -35,6 +38,13 @@
var app = builder.Build();
+// Seed demo data for the management dashboard
+using (var scope = app.Services.CreateScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ SeedDemoData(db);
+}
+
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
@@ -50,4 +60,77 @@
app.MapControllers();
-app.Run();
\ No newline at end of file
+app.Run();
+
+static void SeedDemoData(ApplicationDbContext db)
+{
+ if (db.Orders.Any())
+ return;
+
+ var now = DateTime.UtcNow;
+ var rng = new Random(42);
+ var names = new[] { "Alice", "Bob", "Carol", "David", "Eve", "Frank", "Grace", "Hank" };
+ var orders = new List();
+
+ // Historical completed orders spanning the last 14 days
+ for (int i = 0; i < 80; i++)
+ {
+ var daysAgo = rng.Next(1, 15);
+ var hour = rng.Next(10, 21);
+ var orderDate = now.AddDays(-daysAgo).Date.AddHours(hour).AddMinutes(rng.Next(0, 60));
+ var prepMinutes = rng.Next(3, 12);
+ var startDelay = rng.Next(1, 4);
+ var orderType = (OrderType)rng.Next(0, 2);
+
+ var iceCream = new IceCream
+ {
+ ContainerType = rng.Next(0, 2) == 0 ? "cone" : "cup",
+ Flavors = new List { "Vanilla", "Chocolate" }.Take(rng.Next(1, 3)).ToList(),
+ Toppings = new List { "Sprinkles" },
+ Price = 4.50m + rng.Next(0, 4) * 0.5m
+ };
+
+ orders.Add(new Order
+ {
+ CustomerName = names[rng.Next(names.Length)],
+ IceCreams = new List { iceCream },
+ TotalPrice = iceCream.Price,
+ OrderDate = orderDate,
+ Status = OrderStatus.Completed,
+ OrderType = orderType,
+ StartedAt = orderDate.AddMinutes(startDelay),
+ CompletedAt = orderDate.AddMinutes(startDelay + prepMinutes)
+ });
+ }
+
+ // A few active orders in the queue right now
+ var activeCustomers = new[] { "Iris", "Jack", "Karen", "Leo" };
+ for (int i = 0; i < activeCustomers.Length; i++)
+ {
+ var minutesAgo = rng.Next(2, 15);
+ var status = i < 2 ? OrderStatus.InProgress : OrderStatus.Pending;
+ var iceCream = new IceCream
+ {
+ ContainerType = "cup",
+ Flavors = new List { "Strawberry" },
+ Toppings = new List(),
+ Price = 4.50m
+ };
+
+ var orderDate = now.AddMinutes(-minutesAgo);
+ orders.Add(new Order
+ {
+ CustomerName = activeCustomers[i],
+ IceCreams = new List { iceCream },
+ TotalPrice = iceCream.Price,
+ OrderDate = orderDate,
+ Status = status,
+ OrderType = OrderType.Pickup,
+ StartedAt = status == OrderStatus.InProgress ? orderDate.AddMinutes(1) : null,
+ CompletedAt = null
+ });
+ }
+
+ db.Orders.AddRange(orders);
+ db.SaveChanges();
+}
\ No newline at end of file
diff --git a/ginos-gelato/server/Services/OrderQueueService.cs b/ginos-gelato/server/Services/OrderQueueService.cs
new file mode 100644
index 0000000..72d6583
--- /dev/null
+++ b/ginos-gelato/server/Services/OrderQueueService.cs
@@ -0,0 +1,237 @@
+using System.Globalization;
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+using GinosGelato.Data;
+using GinosGelato.Models;
+
+namespace GinosGelato.Services
+{
+ public record QueueStats(
+ int TotalInQueue,
+ int PendingCount,
+ int InProgressCount,
+ double AveragePrepTimeMinutes,
+ double EstimatedWaitMinutes,
+ List ActiveOrders
+ );
+
+ public record ActiveOrderInfo(
+ int OrderId,
+ string CustomerName,
+ OrderStatus Status,
+ OrderType OrderType,
+ DateTime OrderDate,
+ DateTime? StartedAt,
+ double ElapsedMinutes,
+ int IceCreamCount
+ );
+
+ public record PeakHourData(int Hour, int OrderCount, double AverageWaitMinutes);
+
+ public record DailyMetrics(
+ DateOnly Date,
+ int TotalOrders,
+ int CompletedOrders,
+ double AveragePrepTimeMinutes,
+ double TotalRevenue
+ );
+
+ public class OrderQueueService
+ {
+ private readonly ApplicationDbContext _context;
+
+ public OrderQueueService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task GetQueueStatsAsync()
+ {
+ var now = DateTime.UtcNow;
+
+ var activeOrders = await _context.Orders
+ .Include(o => o.IceCreams)
+ .Where(o => o.Status == OrderStatus.Pending || o.Status == OrderStatus.InProgress)
+ .OrderBy(o => o.OrderDate)
+ .ToListAsync();
+
+ var completedLast24h = await _context.Orders
+ .Where(o => o.Status == OrderStatus.Completed
+ && o.CompletedAt != null
+ && o.StartedAt != null
+ && o.OrderDate >= now.AddHours(-24))
+ .ToListAsync();
+
+ double avgPrepTime = completedLast24h.Any()
+ ? completedLast24h
+ .Select(o => (o.CompletedAt!.Value - o.StartedAt!.Value).TotalMinutes)
+ .Average()
+ : 5.0; // Default 5 minutes if no data
+
+ int queueAhead = activeOrders.Count;
+ double estimatedWait = queueAhead * avgPrepTime;
+
+ var activeOrderInfos = activeOrders.Select(o =>
+ {
+ var reference = o.StartedAt ?? o.OrderDate;
+ var elapsed = (now - reference).TotalMinutes;
+ return new ActiveOrderInfo(
+ o.Id,
+ o.CustomerName,
+ o.Status,
+ o.OrderType,
+ o.OrderDate,
+ o.StartedAt,
+ Math.Round(elapsed, 1),
+ o.IceCreams.Count
+ );
+ }).ToList();
+
+ return new QueueStats(
+ TotalInQueue: queueAhead,
+ PendingCount: activeOrders.Count(o => o.Status == OrderStatus.Pending),
+ InProgressCount: activeOrders.Count(o => o.Status == OrderStatus.InProgress),
+ AveragePrepTimeMinutes: Math.Round(avgPrepTime, 1),
+ EstimatedWaitMinutes: Math.Round(estimatedWait, 1),
+ ActiveOrders: activeOrderInfos
+ );
+ }
+
+ public async Task GetAveragePrepTimeAsync(OrderType? orderType = null)
+ {
+ var cutoff = DateTime.UtcNow.AddHours(-24);
+
+ var query = _context.Orders
+ .Where(o => o.Status == OrderStatus.Completed
+ && o.CompletedAt != null
+ && o.StartedAt != null
+ && o.OrderDate >= cutoff);
+
+ if (orderType.HasValue)
+ query = query.Where(o => o.OrderType == orderType.Value);
+
+ var orders = await query.ToListAsync();
+
+ if (!orders.Any())
+ return 5.0;
+
+ return Math.Round(
+ orders.Average(o => (o.CompletedAt!.Value - o.StartedAt!.Value).TotalMinutes),
+ 1
+ );
+ }
+
+ public async Task> GetPeakHoursAsync(int days = 7)
+ {
+ var cutoff = DateTime.UtcNow.AddDays(-days);
+
+ var orders = await _context.Orders
+ .Where(o => o.OrderDate >= cutoff)
+ .ToListAsync();
+
+ var peakHours = orders
+ .GroupBy(o => o.OrderDate.Hour)
+ .Select(g =>
+ {
+ var completed = g.Where(o =>
+ o.Status == OrderStatus.Completed &&
+ o.CompletedAt != null &&
+ o.StartedAt != null).ToList();
+
+ double avgWait = completed.Any()
+ ? completed.Average(o => (o.CompletedAt!.Value - o.OrderDate).TotalMinutes)
+ : 0;
+
+ return new PeakHourData(g.Key, g.Count(), Math.Round(avgWait, 1));
+ })
+ .OrderBy(p => p.Hour)
+ .ToList();
+
+ // Fill missing hours with zeros
+ var result = Enumerable.Range(0, 24)
+ .Select(h => peakHours.FirstOrDefault(p => p.Hour == h) ?? new PeakHourData(h, 0, 0))
+ .ToList();
+
+ return result;
+ }
+
+ public async Task> GetHistoricalMetricsAsync(int days = 30)
+ {
+ var cutoff = DateTime.UtcNow.AddDays(-days).Date;
+
+ var orders = await _context.Orders
+ .Include(o => o.IceCreams)
+ .Where(o => o.OrderDate >= cutoff)
+ .ToListAsync();
+
+ var metrics = orders
+ .GroupBy(o => DateOnly.FromDateTime(o.OrderDate))
+ .Select(g =>
+ {
+ var completed = g.Where(o =>
+ o.Status == OrderStatus.Completed &&
+ o.CompletedAt != null &&
+ o.StartedAt != null).ToList();
+
+ double avgPrep = completed.Any()
+ ? completed.Average(o => (o.CompletedAt!.Value - o.StartedAt!.Value).TotalMinutes)
+ : 0;
+
+ return new DailyMetrics(
+ Date: g.Key,
+ TotalOrders: g.Count(),
+ CompletedOrders: completed.Count,
+ AveragePrepTimeMinutes: Math.Round(avgPrep, 1),
+ TotalRevenue: (double)g.Sum(o => o.TotalPrice)
+ );
+ })
+ .OrderByDescending(m => m.Date)
+ .ToList();
+
+ return metrics;
+ }
+
+ public async Task ExportToCsvAsync(int days = 30)
+ {
+ var cutoff = DateTime.UtcNow.AddDays(-days);
+
+ var orders = await _context.Orders
+ .Include(o => o.IceCreams)
+ .Where(o => o.OrderDate >= cutoff)
+ .OrderByDescending(o => o.OrderDate)
+ .ToListAsync();
+
+ var sb = new StringBuilder();
+ sb.AppendLine("OrderId,CustomerName,OrderDate,Status,OrderType,IceCreamCount,TotalPrice,StartedAt,CompletedAt,PrepTimeMinutes");
+
+ foreach (var order in orders)
+ {
+ double? prepTime = order.StartedAt.HasValue && order.CompletedAt.HasValue
+ ? Math.Round((order.CompletedAt.Value - order.StartedAt.Value).TotalMinutes, 1)
+ : null;
+
+ sb.AppendLine(string.Join(",",
+ order.Id,
+ $"\"{EscapeCsv(order.CustomerName)}\"",
+ order.OrderDate.ToString("o", CultureInfo.InvariantCulture),
+ order.Status,
+ order.OrderType,
+ order.IceCreams.Count,
+ order.TotalPrice.ToString("F2", CultureInfo.InvariantCulture),
+ order.StartedAt?.ToString("o", CultureInfo.InvariantCulture) ?? "",
+ order.CompletedAt?.ToString("o", CultureInfo.InvariantCulture) ?? "",
+ prepTime?.ToString(CultureInfo.InvariantCulture) ?? ""
+ ));
+ }
+
+ return Encoding.UTF8.GetBytes(sb.ToString());
+ }
+
+ private static string EscapeCsv(string value)
+ {
+ return value.Replace("\"", "\"\"")
+ .Replace("\r", " ")
+ .Replace("\n", " ");
+ }
+ }
+}
diff --git a/ginos-gelato/server/Services/OrderService.cs b/ginos-gelato/server/Services/OrderService.cs
index cc87625..ce9e05c 100644
--- a/ginos-gelato/server/Services/OrderService.cs
+++ b/ginos-gelato/server/Services/OrderService.cs
@@ -16,13 +16,16 @@ public OrderService(ApplicationDbContext context)
_context = context;
}
- public async Task CreateOrderAsync(List iceCreams)
+ public async Task CreateOrderAsync(List iceCreams, string customerName = "", OrderType orderType = OrderType.Pickup)
{
var order = new Order
{
+ CustomerName = customerName,
IceCreams = iceCreams,
TotalPrice = iceCreams.Sum(ic => ic.Price),
- OrderDate = DateTime.UtcNow
+ OrderDate = DateTime.UtcNow,
+ Status = OrderStatus.Pending,
+ OrderType = orderType
};
_context.Orders.Add(order);
@@ -33,12 +36,29 @@ public async Task CreateOrderAsync(List iceCreams)
public async Task> GetOrdersAsync()
{
- return await _context.Orders.ToListAsync();
+ return await _context.Orders.Include(o => o.IceCreams).ToListAsync();
}
public async Task GetOrderByIdAsync(int orderId)
{
- return await _context.Orders.FindAsync(orderId);
+ return await _context.Orders.Include(o => o.IceCreams).FirstOrDefaultAsync(o => o.Id == orderId);
+ }
+
+ public async Task UpdateOrderStatusAsync(int orderId, OrderStatus status)
+ {
+ var order = await _context.Orders.Include(o => o.IceCreams).FirstOrDefaultAsync(o => o.Id == orderId);
+ if (order == null)
+ return null;
+
+ order.Status = status;
+
+ if (status == OrderStatus.InProgress && order.StartedAt == null)
+ order.StartedAt = DateTime.UtcNow;
+ else if (status == OrderStatus.Completed && order.CompletedAt == null)
+ order.CompletedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return order;
}
}
}
\ No newline at end of file