diff --git a/CulinaryCommandApp/Components/App.razor b/CulinaryCommandApp/Components/App.razor index 7a8e5e8..2776020 100644 --- a/CulinaryCommandApp/Components/App.razor +++ b/CulinaryCommandApp/Components/App.razor @@ -28,6 +28,8 @@ + + diff --git a/CulinaryCommandApp/Components/Pages/Dashboard.razor b/CulinaryCommandApp/Components/Pages/Dashboard.razor index b251589..f3cd977 100644 --- a/CulinaryCommandApp/Components/Pages/Dashboard.razor +++ b/CulinaryCommandApp/Components/Pages/Dashboard.razor @@ -1,5 +1,6 @@ @page "/dashboard" @rendermode InteractiveServer +@implements IDisposable @using CulinaryCommand.Components.Custom @using CulinaryCommand.Services.UserContextSpace @@ -7,12 +8,19 @@ @using CulinaryCommandApp.AIDashboard.Services.DTOs @using CulinaryCommand.Services @using CulinaryCommandApp.Inventory.DTOs -@using CulinaryCommandApp.Data.Models +@using CulinaryCommandApp.Inventory.Services.Interfaces +@using CulinaryCommand.Data.Entities +@using CulinaryCommand.PurchaseOrder.Entities +@using Microsoft.EntityFrameworkCore + @inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment Env @inject NavigationManager Nav @inject LocationState LocationState @inject AIReportingService ReportingService @inject IUserContextService UserCtx +@inject IInventoryManagementService InventorySvc +@inject ITaskAssignmentService TaskSvc +@inject AppDbContext Db @if (!_ready) { @@ -21,13 +29,15 @@ else if (!_isSignedIn) {
- You’re not signed in. + You're not signed in.
} else {
+ +
Kitchen Operations
@@ -43,7 +53,7 @@ else
Low Stock
-
@_lowStockItems.Count
+
@_lowStockItems.Count
Open Tasks
@@ -68,93 +78,269 @@ else
-
+ + + +
+ + +
+
+
+ + Low Stock Items + @_lowStockItems.Count +
+
+ @if (_lowStockItems.Any()) + { +
+ + + + + + + + + + + + + @foreach (var item in _lowStockItems) + { + + + + + + + + + } + +
IngredientCategoryIn StockReorder LevelUnitVendor
@item.Name@item.Category@item.CurrentQuantity.ToString("0.##")@item.ReorderLevel.ToString("0.##")@item.Unit@(item.VendorName ?? "—")
+
+ } + else + { +
+

+ All items sufficiently stocked. +

+
+ } +
+
+
+ + +
+
+
Task Status Breakdown
+
+ @if (_taskData.Any()) + { + + } + else + { +

No tasks for this location.

+ } +
+
+
+
+ + +
+ + +
+
+
Tasks Completed per Employee
+
+ @if (_empTaskCounts.Any()) + { + + + + + + + + + @foreach (var e in _empTaskCounts) + { + + + + + } + +
EmployeeCompleted
@e.Name + @e.Count +
+ } + else + { +

No completed tasks yet.

+ } +
+
+
+ + +
+
+
+ Live Activity (Last 7 Days) + + @_activityRefreshedAt.ToLocalTime().ToString("HH:mm:ss") + +
+
+ @if (_activityChartData.Any()) + { + + } + else + { +

No recent activity in the last 7 days.

+ } +
+
+
+
+ +
+ + +
+
+ Weekly Report Analysis + @if (_aiAnalysisObj != null) + { + Generated: @_aiAnalysisObj.GeneratedAt?.ToLocalTime().ToString("g") + } +
-
Weekly Report Analysis
@if (_aiLoading) { -
Loading analysis from Gemini...
+
+
+

Loading analysis from Gemini…

+
} else if (_aiAnalysisObj == null) { -
No analysis available.
+

No analysis available.

} else { +
-
-
+ +
+
@_aiAnalysisObj.Title

@_aiAnalysisObj.Summary

-

Generated: @_aiAnalysisObj.GeneratedAt?.ToLocalTime().ToString("g")

-

Confidence: @((_aiAnalysisObj.Confidence ?? 0).ToString("P0"))

-
-
- @if (_aiAnalysisObj.Metrics != null && _aiAnalysisObj.Metrics.Any()) - { - foreach (var m in _aiAnalysisObj.Metrics) - { -
-
-
-
@m.Label
-

@m.Value @(!string.IsNullOrWhiteSpace(m.Unit) ? m.Unit : "")

-
-
-
- } - } +
+
+
AI Confidence
+
+ +
+
+
+ + @if (_aiMetricsData.Any()) + { +
+
+
Key Metrics
+
+ +
+
+ } +
+ + +
-
- @if (_aiAnalysisObj.Recommendations != null && _aiAnalysisObj.Recommendations.Any()) - { -
Recommendations
+ @if (_aiAnalysisObj.Recommendations != null && _aiAnalysisObj.Recommendations.Any()) + { +
+
+
Recommendations
    @foreach (var r in _aiAnalysisObj.Recommendations) { -
  • @r
  • +
  • + @r +
  • }
- } +
-
-
+ } - @if (_aiAnalysisObj.Sections != null && _aiAnalysisObj.Sections.Any()) - { -
- @foreach (var s in _aiAnalysisObj.Sections) - { -
-
-
-
@s.Heading
-

@s.Body

+ @if (_aiAnalysisObj.Sections != null && _aiAnalysisObj.Sections.Any()) + { +
+
+ @foreach (var s in _aiAnalysisObj.Sections) + { +
+
+
+
@s.Heading
+

@s.Body

+
+
-
+ }
- } -
- } +
+ } +
+ @if (_aiAnalysisObj.Anomalies != null && _aiAnalysisObj.Anomalies.Any()) {
-
Anomalies
+
Anomalies
- - +
RowReason
+ + + @foreach (var a in _aiAnalysisObj.Anomalies) { - + + + }
RowReason
@a.Row@a.Reason
@a.Row@a.Reason
@@ -164,27 +350,83 @@ else }
+
} @code { - private bool _ready; - private bool _isSignedIn; + + // ── Auth / location ─────────────────────────────────────────────── + private bool _ready; + private bool _isSignedIn; private string? _role; + private int? _locationId; + // ── Raw data ────────────────────────────────────────────────────── private List _lowStockItems = new(); - private List _taskData = new(); + private List _taskData = new(); + private List _recentOrders = new(); - private bool _aiLoading; + // ── Plotly – shared config (no mode bar) ────────────────────────── + private Config _dashChartConfig = new() + { + Responsive = true, + DisplayModeBar = Plotly.Blazor.ConfigLib.DisplayModeBarEnum.False + }; + + // ── Card 2: Task Status Donut ───────────────────────────────────── + private IList _taskStatusChartData = new List(); + private Layout _taskStatusLayout = new(); + + // ── Card 3: Employee Task Completion (table) ────────────────────── + private record EmployeeCount(string Name, int Count); + private List _empTaskCounts = new(); + + // ── Card 4: Live Activity Timeline ─────────────────────────────── + private IList _activityChartData = new List(); + private Layout _activityLayout = new(); + private DateTime _activityRefreshedAt = DateTime.UtcNow; + private System.Threading.Timer? _refreshTimer; + + // ── AI analysis ─────────────────────────────────────────────────── + private bool _aiLoading; private string? _aiAnalysis; private AIAnalysisResultDTO? _aiAnalysisObj; - private bool _aiLoadedOnce; + private bool _aiLoadedOnce; + + private Config _aiChartConfig = new() + { + Responsive = true, + DisplayModeBar = Plotly.Blazor.ConfigLib.DisplayModeBarEnum.False + }; + private IList _aiGaugeData = new List(); + private Layout _aiGaugeLayout = new(); + private IList _aiMetricsData = new List(); + private Layout _aiMetricsLayout = new(); + + // ───────────────────────────────────────────────────────────────── protected override async Task OnInitializedAsync() { var ctx = await UserCtx.GetAsync(); _isSignedIn = ctx.User?.Id != null; - _role = ctx.User?.Role; + _role = ctx.User?.Role; + + await LocationState.SetLocationsAsync(ctx.AccessibleLocations); + _locationId = LocationState.CurrentLocation?.Id; + + if (_isSignedIn && _locationId.HasValue) + { + await LoadDashboardDataAsync(_locationId.Value); + + // Auto-refresh the activity chart every 30 s + _refreshTimer = new System.Threading.Timer(async _ => + { + await LoadActivityAsync(_locationId!.Value); + BuildActivityChart(); + await InvokeAsync(StateHasChanged); + }, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + } _ready = true; } @@ -196,42 +438,298 @@ else if (_isSignedIn && !_aiLoadedOnce) { _aiLoadedOnce = true; - _aiLoading = true; + _aiLoading = true; StateHasChanged(); - try{ - if (!string.IsNullOrWhiteSpace(_aiAnalysis)) + try + { + var csvPath = Path.Combine(Env.ContentRootPath, "AIDashboard", "Services", "Reporting", "test_data.csv"); + _aiAnalysis = await ReportingService.AnalyzeCsvAsync(csvPath); + + if (!string.IsNullOrWhiteSpace(_aiAnalysis)) { - var csvPath = Path.Combine(Env.ContentRootPath, "AIDashboard", "Services", "Reporting", "test_data.csv"); - _aiAnalysis = await ReportingService.AnalyzeCsvAsync(csvPath); - - // Try to deserialize the returned JSON into the DTO so we can render structured cards UI. - if (!string.IsNullOrWhiteSpace(_aiAnalysis)) - { - var options = new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - _aiAnalysisObj = System.Text.Json.JsonSerializer.Deserialize(_aiAnalysis, options); - } - } + var options = new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + _aiAnalysisObj = System.Text.Json.JsonSerializer.Deserialize(_aiAnalysis, options); + + if (_aiAnalysisObj != null) + BuildAICharts(_aiAnalysisObj); + } } - catch(Exception e) + catch (Exception e) { Console.WriteLine($"AI Analysis failed: {e.Message}"); _aiAnalysisObj = null; - _aiAnalysis = null; - + _aiAnalysis = null; } - finally { + finally + { _aiLoading = false; StateHasChanged(); } - _aiLoading = false; - //_aiAnalysisObj = null; // Uncoomment above and delete curent like to activate AI - StateHasChanged(); } } - private void NavigateToSignIn() + // ── Data loaders ────────────────────────────────────────────────── + + private async Task LoadDashboardDataAsync(int locationId) + { + var allInventory = await InventorySvc.GetItemsByLocationAsync(locationId); + _lowStockItems = allInventory + .Where(i => i.IsLowStock) + .OrderBy(i => i.CurrentQuantity) + .ToList(); + + _taskData = await TaskSvc.GetByLocationAsync(locationId); + + await LoadActivityAsync(locationId); + BuildOperationalCharts(); + } + + private async Task LoadActivityAsync(int locationId) + { + var cutoff = DateTime.UtcNow.AddDays(-7); + _recentOrders = await Db.PurchaseOrders + .Where(po => po.LocationId == locationId && po.CreatedAt >= cutoff) + .OrderByDescending(po => po.CreatedAt) + .Take(25) + .ToListAsync(); + + _activityRefreshedAt = DateTime.UtcNow; + } + + // ── Chart builders ──────────────────────────────────────────────── + + private void BuildOperationalCharts() + { + BuildTaskStatusChart(); + BuildEmpTaskCounts(); + BuildActivityChart(); + } + + private void BuildEmpTaskCounts() + { + _empTaskCounts = _taskData + .Where(t => string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) + && t.User != null) + .GroupBy(t => t.User!.Name ?? "Unknown") + .Select(g => new EmployeeCount(g.Key, g.Count())) + .OrderByDescending(e => e.Count) + .ToList(); + } + + private void BuildTaskStatusChart() { - Nav.NavigateTo("/login", forceLoad: true); + if (!_taskData.Any()) return; + + var groups = _taskData + .GroupBy(t => string.IsNullOrWhiteSpace(t.Status) ? "Unknown" : t.Status) + .ToDictionary(g => g.Key, g => g.Count()); + + _taskStatusChartData = new List + { + new Pie + { + Labels = groups.Keys.Select(k => (object)k).ToList(), + Values = groups.Values.Select(v => (object)v).ToList(), + Hole = (decimal?)0.45, + Marker = new Plotly.Blazor.Traces.PieLib.Marker + { + Colors = groups.Keys.Select(k => (object)StatusColor(k)).ToList() + } + } + }; + + _taskStatusLayout = new Layout + { + PaperBgColor = "transparent", + PlotBgColor = "transparent", + Height = 280, + Margin = new Margin { T = 20, B = 20, L = 10, R = 10 }, + ShowLegend = true, + Legend = new List + { + new() { Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H, Y = (decimal?)-0.1 } + } + }; } + + private void BuildActivityChart() + { + var cutoff = DateTime.UtcNow.AddDays(-7); + var traces = new List(); + + // Completed tasks + var completed = _taskData + .Where(t => string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) + && t.UpdatedAt >= cutoff) + .OrderBy(t => t.UpdatedAt) + .Take(30) + .ToList(); + + if (completed.Any()) + { + traces.Add(new Scatter + { + Name = "Task Completed", + Mode = Plotly.Blazor.Traces.ScatterLib.ModeFlag.Markers, + X = completed.Select(t => (object?)t.UpdatedAt.ToString("yyyy-MM-dd HH:mm:ss")).ToList(), + Y = completed.Select(_ => (object?)"Task Completed").ToList(), + CustomData = completed.Select(t => (object?)(t.Name ?? "—")).ToList(), + HoverTemplate = "%{customdata}
%{x}", + Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Color = "#198754", Size = (decimal?)14 } + }); + } + + var created = _taskData + .Where(t => !string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) + && t.CreatedAt >= cutoff) + .OrderBy(t => t.CreatedAt) + .Take(20) + .ToList(); + + if (created.Any()) + { + traces.Add(new Scatter + { + Name = "Task Created", + Mode = Plotly.Blazor.Traces.ScatterLib.ModeFlag.Markers, + X = created.Select(t => (object?)t.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")).ToList(), + Y = created.Select(_ => (object?)"Task Created").ToList(), + CustomData = created.Select(t => (object?)(t.Name ?? "—")).ToList(), + HoverTemplate = "%{customdata}
%{x}", + Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Color = "#0d6efd", Size = (decimal?)14 } + }); + } + + if (_recentOrders.Any()) + { + traces.Add(new Scatter + { + Name = "Purchase Order", + Mode = Plotly.Blazor.Traces.ScatterLib.ModeFlag.Markers, + X = _recentOrders.Select(po => (object?)po.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")).ToList(), + Y = _recentOrders.Select(_ => (object?)"Purchase Order").ToList(), + CustomData = _recentOrders.Select(po => (object?)$"{po.OrderNumber} – {po.VendorName}").ToList(), + HoverTemplate = "%{customdata}
%{x}", + Marker = new Plotly.Blazor.Traces.ScatterLib.Marker { Color = "#fd7e14", Size = (decimal?)14 } + }); + } + + _activityChartData = traces; + + _activityLayout = new Layout + { + PaperBgColor = "transparent", + PlotBgColor = "transparent", + Height = 280, + Margin = new Margin { T = 10, B = 40, L = 10, R = 10 }, + ShowLegend = true, + Legend = new List + { + new() { Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H, Y = (decimal?)-0.2 } + }, + XAxis = new List + { + new() { Type = Plotly.Blazor.LayoutLib.XAxisLib.TypeEnum.Date } + }, + YAxis = new List + { + new() { AutoMargin = Plotly.Blazor.LayoutLib.YAxisLib.AutoMarginFlag.True } + } + }; + } + + // ── AI chart builders ───────────────────────────────────────────── + + private void BuildAICharts(AIAnalysisResultDTO result) + { + var confidencePercent = (decimal?)((result.Confidence ?? 0) * 100); + + _aiGaugeData = new List + { + new Indicator + { + Mode = Plotly.Blazor.Traces.IndicatorLib.ModeFlag.Gauge + | Plotly.Blazor.Traces.IndicatorLib.ModeFlag.Number, + Value = confidencePercent, + Number = new Plotly.Blazor.Traces.IndicatorLib.Number { Suffix = "%" }, + Gauge = new Plotly.Blazor.Traces.IndicatorLib.Gauge + { + Axis = new Plotly.Blazor.Traces.IndicatorLib.GaugeLib.Axis { Range = new List { 0, 100 } }, + Bar = new Plotly.Blazor.Traces.IndicatorLib.GaugeLib.Bar { Color = "#198754" }, + Steps = new List + { + new() { Range = new List { 0, 50 }, Color = "#f8d7da" }, + new() { Range = new List { 50, 75 }, Color = "#fff3cd" }, + new() { Range = new List { 75, 100 }, Color = "#d1e7dd" } + } + } + } + }; + + _aiGaugeLayout = new Layout + { + PaperBgColor = "transparent", + PlotBgColor = "transparent", + Height = 220, + Margin = new Margin { T = 10, B = 10, L = 20, R = 20 } + }; + + if (result.Metrics == null || !result.Metrics.Any()) return; + + var parsed = result.Metrics + .Select(m => (label: m.Label ?? "", num: TryParseMetric(m.Value))) + .Where(x => x.num.HasValue) + .ToList(); + + if (!parsed.Any()) return; + + _aiMetricsData = new List + { + new Bar + { + Y = parsed.Select(x => (object?)x.label).ToList(), + X = parsed.Select(x => (object?)x.num).ToList(), + Orientation = Plotly.Blazor.Traces.BarLib.OrientationEnum.H, + Marker = new Plotly.Blazor.Traces.BarLib.Marker + { + Color = new List { "#0d6efd" } + } + } + }; + + _aiMetricsLayout = new Layout + { + PaperBgColor = "transparent", + PlotBgColor = "transparent", + Height = 220, + Margin = new Margin { T = 10, B = 30, L = 10, R = 10 }, + XAxis = new List { new() { AutoMargin = Plotly.Blazor.LayoutLib.XAxisLib.AutoMarginFlag.True } }, + YAxis = new List { new() { AutoMargin = Plotly.Blazor.LayoutLib.YAxisLib.AutoMarginFlag.True } } + }; + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private static string StatusColor(string status) => status.ToLowerInvariant() switch + { + "completed" => "#198754", + "pending" => "#ffc107", + "in progress" => "#0d6efd", + "in-progress" => "#0d6efd", + "cancelled" => "#dc3545", + _ => "#6c757d" + }; + + private static double? TryParseMetric(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var cleaned = System.Text.RegularExpressions.Regex.Replace(raw, @"[^0-9.\-]", ""); + return double.TryParse(cleaned, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : null; + } + + private void NavigateToSignIn() => Nav.NavigateTo("/login", forceLoad: true); + + public void Dispose() => _refreshTimer?.Dispose(); } diff --git a/CulinaryCommandApp/Components/_Imports.razor b/CulinaryCommandApp/Components/_Imports.razor index 8306129..bb5118c 100644 --- a/CulinaryCommandApp/Components/_Imports.razor +++ b/CulinaryCommandApp/Components/_Imports.razor @@ -12,4 +12,7 @@ @using CulinaryCommand.Services @using CulinaryCommand.Components.Layout @using CulinaryCommand.Components.Custom -@using CulinaryCommand.Components.Pages \ No newline at end of file +@using CulinaryCommand.Components.Pages +@using Plotly.Blazor +@using Plotly.Blazor.Traces +@using Plotly.Blazor.LayoutLib \ No newline at end of file diff --git a/CulinaryCommandApp/CulinaryCommand.csproj b/CulinaryCommandApp/CulinaryCommand.csproj index 2989c24..3a3564a 100644 --- a/CulinaryCommandApp/CulinaryCommand.csproj +++ b/CulinaryCommandApp/CulinaryCommand.csproj @@ -28,6 +28,7 @@ all + PreserveNewest diff --git a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor index 89ebcf7..e6a1481 100644 --- a/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor +++ b/CulinaryCommandApp/Inventory/Pages/Inventory/InventoryCatalog.razor @@ -34,10 +34,28 @@ } else { -
-
-

Inventory Catalog

-

@(LocationState.CurrentLocation is not null ? $"{LocationState.CurrentLocation.Name} — ingredient catalog" : "Manage your ingredient catalog")

+
+
+
Kitchen Operations
+

Inventory Catalog

+

Manage your location's ingredient catalog.

+
+
+
+
Location
+
@(LocationState.CurrentLocation?.Name ?? "—")
+
+
+
+
+
Total Items
+
@itemCatalog.Count
+
+
+
Low Stock
+
@itemCatalog.Count(i => i.IsLowStock)
+
+
diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index e464ed2..015919d 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -118,7 +118,14 @@ // ===================== // AI Services // ===================== -builder.Services.AddSingleton(_ => new Client()); +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var apiKey = config["Google:ApiKey"] + ?? throw new InvalidOperationException( + "Missing configuration key 'Google:ApiKey'. Add it to appsettings or set the GOOGLE_API_KEY environment variable."); + return new Client(apiKey: apiKey); +}); builder.Services.AddScoped(); //