diff --git a/CulinaryCommandApp/Components/App.razor b/CulinaryCommandApp/Components/App.razor index 88e8715..427815d 100644 --- a/CulinaryCommandApp/Components/App.razor +++ b/CulinaryCommandApp/Components/App.razor @@ -8,6 +8,9 @@ + + + @@ -31,6 +34,16 @@ crossorigin="anonymous"> + + + + + + + + + + diff --git a/CulinaryCommandApp/Components/Pages/Dashboard.razor b/CulinaryCommandApp/Components/Pages/Dashboard.razor index a50c2ca..9e8e0f0 100644 --- a/CulinaryCommandApp/Components/Pages/Dashboard.razor +++ b/CulinaryCommandApp/Components/Pages/Dashboard.razor @@ -6,10 +6,20 @@ @using CulinaryCommandApp.AIDashboard.Services.Reporting @using CulinaryCommandApp.AIDashboard.Services.DTOs @using CulinaryCommand.Services +@using CulinaryCommandApp.Inventory.Services.Interfaces +@using CulinaryCommandApp.Inventory.Services +@using CulinaryCommandApp.Inventory.DTOs +@using CulinaryCommand.Data +@using CulinaryCommand.PurchaseOrder.Entities +@using Microsoft.EntityFrameworkCore @inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment Env +@inject AppDbContext Db @inject NavigationManager Nav @inject AIReportingService ReportingService @inject IUserContextService UserCtx +@inject IInventoryManagementService InventorySvc +@inject ITaskAssignmentService TaskSvc +@inject LocationState LocationState @if (!_ready) { @@ -18,7 +28,7 @@ else if (!_isSignedIn) {
- You’re not signed in. + You're not signed in.
} @@ -40,6 +50,148 @@ 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 inventory items are sufficiently stocked.

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

No tasks for this location.

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

No completed tasks yet.

+ } +
+
+
+
+ + + +
+
+ Recent Activity + Last 7 days +
+
+ @if (!_activityFeed.Any()) + { +

No recent activity.

+ } + else + { + + } +
+
+ +
+ +
Weekly Report Analysis
@@ -140,21 +292,55 @@ else } @code { + // ── Auth ───────────────────────────────────────────────────────── private bool _ready; private bool _isSignedIn; private string? _role; + private int? _locationId; + // ── AI ─────────────────────────────────────────────────────────── private bool _aiLoading; private string? _aiAnalysis; private AIAnalysisResultDTO? _aiAnalysisObj; private bool _aiLoadedOnce; + // ── Inventory ──────────────────────────────────────────────────── + private List _inventoryData = new(); + private List _lowStockItems = new(); + + // ── Activity feed ──────────────────────────────────────────────── + private record ActivityItem(string Icon, string BadgeClass, string Text, string? Who, DateTime When); + private List _activityFeed = new(); + + // ── Task chart ─────────────────────────────────────────────────── + private List _taskData = new(); + private IList _taskChartData = new List(); + private Layout _taskLayout = new(); + + // ── Employee task completion ────────────────────────────────────── + private record EmployeeCount(string Name, int Count); + private List _employeeTaskCounts = new(); + + // Shared config + private Config _chartConfig = new() { Responsive = true }; + + // ── Lifecycle ──────────────────────────────────────────────────── protected override async Task OnInitializedAsync() { var ctx = await UserCtx.GetAsync(); _isSignedIn = ctx.User?.Id != null; _role = ctx.User?.Role; + await LocationState.SetLocationsAsync(ctx.AccessibleLocations); + _locationId = LocationState.CurrentLocation?.Id; + + if (_isSignedIn && _locationId.HasValue) + { + await LoadInventoryChartAsync(_locationId.Value); + await LoadTaskChartAsync(_locationId.Value); + await LoadActivityFeedAsync(_locationId.Value); + } + _ready = true; } @@ -168,37 +354,165 @@ else _aiLoading = true; StateHasChanged(); - try{ - if (!string.IsNullOrWhiteSpace(_aiAnalysis)) + try + { + 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); } - } + } } - catch(Exception e) + catch (Exception e) { Console.WriteLine($"AI Analysis failed: {e.Message}"); _aiAnalysisObj = null; _aiAnalysis = null; - } - finally { + finally + { _aiLoading = false; StateHasChanged(); } - _aiLoading = false; - //_aiAnalysisObj = null; // Uncoomment above and delete curent like to activate AI - StateHasChanged(); } } + // ── Data loaders ───────────────────────────────────────────────── + private async Task LoadInventoryChartAsync(int locationId) + { + _inventoryData = await InventorySvc.GetItemsByLocationAsync(locationId); + _lowStockItems = _inventoryData + .Where(i => i.IsLowStock) + .OrderBy(i => i.CurrentQuantity) + .ToList(); + } + + private async Task LoadTaskChartAsync(int locationId) + { + _taskData = await TaskSvc.GetByLocationAsync(locationId); + + var groups = _taskData + .GroupBy(t => string.IsNullOrWhiteSpace(t.Status) ? "Unknown" : t.Status) + .ToDictionary(g => g.Key, g => g.Count()); + + _taskChartData = new List + { + new Pie + { + Labels = groups.Keys.Select(k => (object)k).ToList(), + Values = groups.Values.Select(v => (object)v).ToList(), + Hole = (decimal?)0.4 + } + }; + + _taskLayout = new Layout + { + PaperBgColor = "transparent", + PlotBgColor = "transparent", + Margin = new Plotly.Blazor.LayoutLib.Margin { T = 10, B = 10, L = 10, R = 10 }, + ShowLegend = true + }; + + _employeeTaskCounts = _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 async Task LoadActivityFeedAsync(int locationId) + { + var cutoff = DateTime.UtcNow.AddDays(-7); + var items = new List(); + + // Completed tasks from already-loaded task data + var completedTasks = _taskData + .Where(t => string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) + && t.UpdatedAt >= cutoff) + .OrderByDescending(t => t.UpdatedAt) + .Take(10); + + foreach (var t in completedTasks) + { + var who = t.User?.Name ?? "Someone"; + items.Add(new ActivityItem( + Icon: "bi-check-circle-fill", + BadgeClass: "text-bg-success", + Text: $"Task \"{t.Name}\" marked complete", + Who: who, + When: t.UpdatedAt + )); + } + + // Tasks created in the last 7 days (non-completed) + var newTasks = _taskData + .Where(t => !string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) + && t.CreatedAt >= cutoff) + .OrderByDescending(t => t.CreatedAt) + .Take(5); + + foreach (var t in newTasks) + { + items.Add(new ActivityItem( + Icon: "bi-clipboard-plus", + BadgeClass: "text-bg-primary", + Text: $"New task assigned: \"{t.Name}\"", + Who: t.User?.Name, + When: t.CreatedAt + )); + } + + // Recent purchase orders + var recentOrders = await Db.PurchaseOrders + .Where(po => po.LocationId == locationId && po.CreatedAt >= cutoff) + .OrderByDescending(po => po.CreatedAt) + .Take(5) + .ToListAsync(); + + foreach (var po in recentOrders) + { + var statusLabel = po.Status switch + { + PurchaseOrderStatus.Draft => "created (Draft)", + PurchaseOrderStatus.Submitted => "submitted", + PurchaseOrderStatus.PartiallyReceived => "partially received", + PurchaseOrderStatus.Received => "received", + PurchaseOrderStatus.Cancelled => "cancelled", + _ => po.Status.ToString() + }; + + items.Add(new ActivityItem( + Icon: "bi-cart", + BadgeClass: "text-bg-warning", + Text: $"Purchase order {po.OrderNumber} {statusLabel} — {po.VendorName}", + Who: null, + When: po.CreatedAt + )); + } + + _activityFeed = items + .OrderByDescending(i => i.When) + .Take(15) + .ToList(); + } + + private static string TimeAgo(DateTime utc) + { + var diff = DateTime.UtcNow - utc; + if (diff.TotalMinutes < 1) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + return $"{(int)diff.TotalDays}d ago"; + } + private void NavigateToSignIn() { Nav.NavigateTo("/login", forceLoad: true); diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor b/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor index 7cea837..52ec2c0 100644 --- a/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor +++ b/CulinaryCommandApp/Components/Pages/UserSettings/LocationSettings/ConfigureLocation.razor @@ -6,6 +6,7 @@ @using CulinaryCommand.Services @using CulinaryCommand.Services.UserContextSpace @using CulinaryCommandApp.Inventory.Entities +@using Unit = CulinaryCommandApp.Inventory.Entities.Unit @using CulinaryCommandApp.Inventory.Services @using CulinaryCommandApp.Inventory.Services.Interfaces @using Microsoft.AspNetCore.Authorization diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/Settings.css b/CulinaryCommandApp/Components/Pages/UserSettings/Settings.css index 1e23a8e..a35fe3f 100644 --- a/CulinaryCommandApp/Components/Pages/UserSettings/Settings.css +++ b/CulinaryCommandApp/Components/Pages/UserSettings/Settings.css @@ -13,7 +13,7 @@ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); padding: 40px; width: 100%; - max-width: 700px; + max-width: 960px; } .app-title { diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/Settings.razor b/CulinaryCommandApp/Components/Pages/UserSettings/Settings.razor index 46cdc20..12794b3 100644 --- a/CulinaryCommandApp/Components/Pages/UserSettings/Settings.razor +++ b/CulinaryCommandApp/Components/Pages/UserSettings/Settings.razor @@ -57,6 +57,12 @@ Company + @@ -87,6 +93,10 @@ break; + case "inventory-config": + + break; + default: break; diff --git a/CulinaryCommandApp/Components/Pages/UserSettings/SettingsInventoryConfigurations.razor b/CulinaryCommandApp/Components/Pages/UserSettings/SettingsInventoryConfigurations.razor new file mode 100644 index 0000000..bb0d966 --- /dev/null +++ b/CulinaryCommandApp/Components/Pages/UserSettings/SettingsInventoryConfigurations.razor @@ -0,0 +1,314 @@ +@rendermode InteractiveServer + +@using CulinaryCommand.Services.UserContextSpace +@using CulinaryCommandApp.Inventory.Entities +@using Unit = CulinaryCommandApp.Inventory.Entities.Unit +@using CulinaryCommandApp.Inventory.Services.Interfaces +@using System.ComponentModel.DataAnnotations + +@inject IUnitService UnitService +@inject IUserContextService UserCtx +@inject NavigationManager Nav + +
+

+ Inventory Configurations +

+ + @if (!_ready) + { +
+
+ Loading... +
+
+ } + else + { + +
+
+ Unit of Measurement +
+

Define standard units for ingredients and recipes in your kitchen

+ + + + +
+
+ + +
Full name of the measurement unit
+ +
+
+ + +
Short form for display
+ +
+ @*
+ + +
Multiplier relative to base unit (e.g. 1000 for kg if gram is base)
+
*@ +
+ + @if (!string.IsNullOrEmpty(_saveError)) + { +
+ @_saveError +
+ } + +
+ @if (_editingId.HasValue) + { + + + } + else + { + + + } +
+
+
+ + +
+
+ Existing Units +
+

Units that have been set and are usable across ingredients and recipes

+ + @if (!_units.Any()) + { +
+ + No units configured yet. Add one above to get started. +
+ } + else + { +
+ + + + + + + + + + + @foreach (var unit in _units) + { + + + + + + + } + +
Unit NameAbbreviationConv. FactorActions
@unit.Name@unit.Abbreviation@unit.ConversionFactor + + +
+
+
+ @_units.Count unit(s) configured +
+ } +
+ } +
+ + +@if (_unitToDelete is not null) +{ + +} + +@code { + private UserContext? _ctx; + private bool _ready; + private bool _allowed; + private bool _saving; + private bool _deleting; + private int? _editingId; + private string? _saveError; + private Unit? _unitToDelete; + private List _units = new(); + + private UnitFormModel _form = new(); + + protected override async Task OnInitializedAsync() + { + _ctx = await UserCtx.GetAsync(); + + if (_ctx.IsAuthenticated != true) + { + Nav.NavigateTo("/login", true); + return; + } + + if (_ctx.User is not null) + { + _allowed = true; + _units = await UnitService.GetAllAsync(); + } + + _ready = true; + } + + private async Task HandleSave() + { + if (!_allowed || _saving) return; + + _saving = true; + _saveError = null; + + try + { + if (_editingId.HasValue) + { + var unit = new Unit + { + Id = _editingId.Value, + Name = _form.Name!, + Abbreviation = _form.Abbreviation!, + ConversionFactor = _form.ConversionFactor + }; + await UnitService.UpdateAsync(unit); + } + else + { + var unit = new Unit + { + Name = _form.Name!, + Abbreviation = _form.Abbreviation!, + ConversionFactor = _form.ConversionFactor + }; + await UnitService.CreateAsync(unit); + } + + _units = await UnitService.GetAllAsync(); + ResetForm(); + } + catch + { + _saveError = "An error occurred while saving. Please try again."; + } + finally + { + _saving = false; + } + } + + private void StartEdit(Unit unit) + { + _editingId = unit.Id; + _saveError = null; + _form = new UnitFormModel + { + Name = unit.Name, + Abbreviation = unit.Abbreviation, + ConversionFactor = unit.ConversionFactor + }; + } + + private void CancelEdit() => ResetForm(); + + private void ResetForm() + { + _form = new UnitFormModel(); + _editingId = null; + _saveError = null; + } + + private void ConfirmDelete(Unit unit) + { + _unitToDelete = unit; + } + + private void CancelDelete() + { + _unitToDelete = null; + } + + private async Task DeleteUnit() + { + if (_unitToDelete is null || _deleting) return; + + _deleting = true; + try + { + await UnitService.DeleteAsync(_unitToDelete.Id); + _units = await UnitService.GetAllAsync(); + + if (_editingId == _unitToDelete.Id) + ResetForm(); + + _unitToDelete = null; + } + finally + { + _deleting = false; + } + } + + private class UnitFormModel + { + [Required(ErrorMessage = "Unit name is required.")] + [StringLength(100, ErrorMessage = "Name must be 100 characters or fewer.")] + public string? Name { get; set; } + + [Required(ErrorMessage = "Abbreviation is required.")] + [StringLength(20, ErrorMessage = "Abbreviation must be 20 characters or fewer.")] + public string? Abbreviation { get; set; } + + public decimal ConversionFactor { get; set; } = 1; + } +} diff --git a/CulinaryCommandApp/Components/_Imports.razor b/CulinaryCommandApp/Components/_Imports.razor index 8306129..ecacefb 100644 --- a/CulinaryCommandApp/Components/_Imports.razor +++ b/CulinaryCommandApp/Components/_Imports.razor @@ -12,4 +12,11 @@ @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 BlazorBootstrap; +@using Plotly.Blazor +@using Plotly.Blazor.LayoutLib +@using Plotly.Blazor.Traces +@using Plotly.Blazor.Traces.ScatterLib +@using Plotly.Blazor.Traces.BarLib +@using Plotly.Blazor.Traces.PieLib \ No newline at end of file diff --git a/CulinaryCommandApp/CulinaryCommand.csproj b/CulinaryCommandApp/CulinaryCommand.csproj index 2989c24..9e5665e 100644 --- a/CulinaryCommandApp/CulinaryCommand.csproj +++ b/CulinaryCommandApp/CulinaryCommand.csproj @@ -15,6 +15,7 @@ + @@ -28,6 +29,7 @@ all + PreserveNewest diff --git a/CulinaryCommandApp/Program.cs b/CulinaryCommandApp/Program.cs index 3578ae5..3d18971 100644 --- a/CulinaryCommandApp/Program.cs +++ b/CulinaryCommandApp/Program.cs @@ -29,13 +29,14 @@ var builder = WebApplication.CreateBuilder(args); +builder.Services.AddBlazorBootstrap(); + // // ===================== // UI // ===================== builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); - // // ===================== // Cognito Authentication (MUST be before Build) @@ -50,6 +51,11 @@ .AddCookie() .AddOpenIdConnect(options => { + options.CorrelationCookie.SameSite = SameSiteMode.None; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.NonceCookie.SameSite = SameSiteMode.None; + options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; // ---- Read Cognito config (env/appsettings) ---- @@ -117,8 +123,12 @@ // ===================== // AI Services // ===================== -builder.Services.AddSingleton(_ => new Client()); -builder.Services.AddScoped(); +builder.Services.AddSingleton(sp => +{ + var apiKey = builder.Configuration["Google:ApiKey"] + ?? throw new InvalidOperationException("Missing config: Google:ApiKey"); + return new Client(apiKey: apiKey); +});builder.Services.AddScoped(); // // ===================== diff --git a/CulinaryCommandApp/Recipe/Services/RecipeService.cs b/CulinaryCommandApp/Recipe/Services/RecipeService.cs index 0e063bc..5f84827 100644 --- a/CulinaryCommandApp/Recipe/Services/RecipeService.cs +++ b/CulinaryCommandApp/Recipe/Services/RecipeService.cs @@ -57,9 +57,6 @@ public RecipeService(AppDbContext db) public async Task CreateAsync(Rec.Recipe recipe) { - if (string.IsNullOrWhiteSpace(recipe.Category)) - throw new Exception("Category is required."); - _db.Recipes.Add(recipe); await _db.SaveChangesAsync(); }