From 9b2ace7bc086967268652982a3b5c54d10214e4b Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Sun, 11 Jan 2026 11:17:51 +0100 Subject: [PATCH] Adds dashboard stat cards and demo banner Introduces StatCard ViewComponent with configurable stat display Adds demo mode banner to application layout Refactors dashboard stats to use dynamic component rendering Improves dashboard presentation and user experience --- .../Components/StatCard/Default.cshtml | 16 ++ .../Components/StatCardViewComponent.cs | 90 +++++++++++ .../Features/Dashboard/Pages/Index.cshtml | 34 +--- .../Features/_Shared/Pages/_Layout.cshtml | 17 +- .../wwwroot/css/demo-banner.css | 145 ++++++++++++++++++ 5 files changed, 272 insertions(+), 30 deletions(-) create mode 100644 PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml create mode 100644 PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs create mode 100644 PlanTempus.Application/wwwroot/css/demo-banner.css diff --git a/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml b/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml new file mode 100644 index 0000000..dbbe501 --- /dev/null +++ b/PlanTempus.Application/Features/Dashboard/Components/StatCard/Default.cshtml @@ -0,0 +1,16 @@ +@model PlanTempus.Application.Features.Dashboard.Components.StatCardViewModel + + + @Model.Value + @Model.Label + @if (Model.HasTrend) + { + + @if (!string.IsNullOrEmpty(Model.TrendIcon)) + { + + } + @Model.TrendText + + } + diff --git a/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs b/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs new file mode 100644 index 0000000..ccac82f --- /dev/null +++ b/PlanTempus.Application/Features/Dashboard/Components/StatCardViewComponent.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; + +namespace PlanTempus.Application.Features.Dashboard.Components; + +/// +/// ViewComponent for rendering a stat card on the dashboard. +/// +public class StatCardViewComponent : ViewComponent +{ + public IViewComponentResult Invoke(string key) + { + var model = StatCardCatalog.Get(key); + return View(model); + } +} + +/// +/// ViewModel for the StatCard component. +/// +public class StatCardViewModel +{ + public required string Key { get; init; } + public required string Value { get; init; } + public required string Label { get; init; } + public string? TrendText { get; init; } + public string? TrendIcon { get; init; } + public string? TrendDirection { get; init; } + public string? Variant { get; init; } + public bool HasTrend => !string.IsNullOrEmpty(TrendText); +} + +/// +/// Catalog of available stat cards with their data. +/// +public static class StatCardCatalog +{ + private static readonly Dictionary Cards = new() + { + ["bookings-today"] = new StatCardViewModel + { + Key = "bookings-today", + Value = "12", + Label = "Bookinger i dag", + TrendText = "4 gennemført, 2 i gang", + TrendIcon = "ph-check-circle", + TrendDirection = "up", + Variant = "highlight" + }, + ["expected-revenue"] = new StatCardViewModel + { + Key = "expected-revenue", + Value = "8.450 kr", + Label = "Forventet omsætning", + TrendText = "+12% vs. gennemsnit", + TrendIcon = "ph-trend-up", + TrendDirection = "up", + Variant = "success" + }, + ["occupancy-rate"] = new StatCardViewModel + { + Key = "occupancy-rate", + Value = "78%", + Label = "Belægningsgrad", + TrendText = "God kapacitet", + TrendIcon = "ph-trend-up", + TrendDirection = "up", + Variant = null + }, + ["needs-attention"] = new StatCardViewModel + { + Key = "needs-attention", + Value = "4", + Label = "Kræver opmærksomhed", + TrendText = null, + TrendIcon = null, + TrendDirection = null, + Variant = "warning" + } + }; + + public static StatCardViewModel Get(string key) + { + if (Cards.TryGetValue(key, out var card)) + return card; + + throw new KeyNotFoundException($"StatCard with key '{key}' not found"); + } + + public static IEnumerable AllKeys => Cards.Keys; +} diff --git a/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml b/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml index 9e3b36c..92c24f0 100644 --- a/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Dashboard/Pages/Index.cshtml @@ -1,5 +1,5 @@ @page "/" -@using PlanTempus.Application.Features.Dashboard.Pages +@using PlanTempus.Application.Features.Dashboard.Pages @model PlanTempus.Application.Features.Dashboard.Pages.IndexModel @{ ViewData["Title"] = "Dashboard"; @@ -8,34 +8,10 @@ - - 12 - Bookinger i dag - - - 4 gennemført, 2 i gang - - - - 8.450 kr - Forventet omsætning - - - +12% vs. gennemsnit - - - - 78% - Belægningsgrad - - - God kapacitet - - - - 4 - Kræver opmærksomhed - + @await Component.InvokeAsync("StatCard", "bookings-today") + @await Component.InvokeAsync("StatCard", "expected-revenue") + @await Component.InvokeAsync("StatCard", "occupancy-rate") + @await Component.InvokeAsync("StatCard", "needs-attention") diff --git a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml index e8bb2e3..bc1b7d0 100644 --- a/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml +++ b/PlanTempus.Application/Features/_Shared/Pages/_Layout.cshtml @@ -14,12 +14,27 @@ + @await RenderSectionAsync("Styles", required: false) - + + + + + + Du ser en demo af PlanTempus. + + + + Opret konto + + + + + @await Component.InvokeAsync("SideMenu") diff --git a/PlanTempus.Application/wwwroot/css/demo-banner.css b/PlanTempus.Application/wwwroot/css/demo-banner.css new file mode 100644 index 0000000..39b9179 --- /dev/null +++ b/PlanTempus.Application/wwwroot/css/demo-banner.css @@ -0,0 +1,145 @@ +/** + * Demo Banner + * + * Persistent banner shown at the top of pages when in demo mode. + * Provides CTA to subscription selection. + */ + +/* =========================================== + DEMO BANNER + =========================================== */ +swp-demo-banner { + /* TODO: Remove display:none when demo mode is ready */ + display: none; + align-items: center; + justify-content: center; + gap: var(--spacing-4); + padding: var(--spacing-3) var(--spacing-6); + background: linear-gradient(135deg, var(--color-teal) 0%, #00796b 100%); + color: white; + font-size: var(--font-size-sm); + position: relative; + z-index: 1000; +} + +swp-demo-banner-text { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +swp-demo-banner-text i { + font-size: 18px; + opacity: 0.9; +} + +swp-demo-banner-text span { + opacity: 0.95; +} + +swp-demo-banner-text strong { + font-weight: var(--font-weight-semibold); + opacity: 1; +} + +swp-demo-banner-cta { + display: inline-flex; +} + +swp-demo-banner-cta a { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + background: white; + color: var(--color-teal); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + border-radius: var(--radius-pill); + text-decoration: none; + transition: all var(--transition-fast); + white-space: nowrap; +} + +swp-demo-banner-cta a:hover { + background: rgba(255, 255, 255, 0.95); + color: #00695c; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +swp-demo-banner-cta i { + font-size: 16px; +} + +/* Close button (optional) */ +swp-demo-banner-close { + position: absolute; + right: var(--spacing-4); + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.15); + border-radius: 50%; + color: white; + cursor: pointer; + opacity: 0.7; + transition: all var(--transition-fast); +} + +swp-demo-banner-close:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.25); +} + +swp-demo-banner-close i { + font-size: 16px; +} + +/* =========================================== + BODY OFFSET (when banner is visible) + =========================================== */ +body.has-demo-banner swp-app-layout { + /* Adjust if banner needs to push content down */ +} + +/* =========================================== + RESPONSIVE + =========================================== */ +@media (max-width: 768px) { + swp-demo-banner { + flex-wrap: wrap; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4); + } + + swp-demo-banner-text { + flex: 1 1 100%; + justify-content: center; + text-align: center; + } + + swp-demo-banner-cta { + flex: 1 1 100%; + justify-content: center; + } + + swp-demo-banner-cta a { + width: 100%; + justify-content: center; + } +} + +@media (max-width: 480px) { + swp-demo-banner-text span { + display: none; + } + + swp-demo-banner-text strong { + display: inline; + } +}