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
This commit is contained in:
parent
217a9cd95c
commit
9b2ace7bc0
5 changed files with 272 additions and 30 deletions
|
|
@ -0,0 +1,16 @@
|
||||||
|
@model PlanTempus.Application.Features.Dashboard.Components.StatCardViewModel
|
||||||
|
|
||||||
|
<swp-stat-card data-key="@Model.Key" class="@Model.Variant">
|
||||||
|
<swp-stat-value>@Model.Value</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.Label</swp-stat-label>
|
||||||
|
@if (Model.HasTrend)
|
||||||
|
{
|
||||||
|
<swp-stat-trend class="@Model.TrendDirection">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.TrendIcon))
|
||||||
|
{
|
||||||
|
<i class="ph @Model.TrendIcon"></i>
|
||||||
|
}
|
||||||
|
@Model.TrendText
|
||||||
|
</swp-stat-trend>
|
||||||
|
}
|
||||||
|
</swp-stat-card>
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features.Dashboard.Components;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewComponent for rendering a stat card on the dashboard.
|
||||||
|
/// </summary>
|
||||||
|
public class StatCardViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
public IViewComponentResult Invoke(string key)
|
||||||
|
{
|
||||||
|
var model = StatCardCatalog.Get(key);
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ViewModel for the StatCard component.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Catalog of available stat cards with their data.
|
||||||
|
/// </summary>
|
||||||
|
public static class StatCardCatalog
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, StatCardViewModel> 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<string> AllKeys => Cards.Keys;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
@page "/"
|
@page "/"
|
||||||
@using PlanTempus.Application.Features.Dashboard.Pages
|
@using PlanTempus.Application.Features.Dashboard.Pages
|
||||||
@model PlanTempus.Application.Features.Dashboard.Pages.IndexModel
|
@model PlanTempus.Application.Features.Dashboard.Pages.IndexModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Dashboard";
|
ViewData["Title"] = "Dashboard";
|
||||||
|
|
@ -8,34 +8,10 @@
|
||||||
<swp-page-container>
|
<swp-page-container>
|
||||||
<!-- Stats Bar -->
|
<!-- Stats Bar -->
|
||||||
<swp-stats-bar>
|
<swp-stats-bar>
|
||||||
<swp-stat-card class="highlight">
|
@await Component.InvokeAsync("StatCard", "bookings-today")
|
||||||
<swp-stat-value>12</swp-stat-value>
|
@await Component.InvokeAsync("StatCard", "expected-revenue")
|
||||||
<swp-stat-label>Bookinger i dag</swp-stat-label>
|
@await Component.InvokeAsync("StatCard", "occupancy-rate")
|
||||||
<swp-stat-trend class="up">
|
@await Component.InvokeAsync("StatCard", "needs-attention")
|
||||||
<i class="ph ph-check-circle"></i>
|
|
||||||
4 gennemført, 2 i gang
|
|
||||||
</swp-stat-trend>
|
|
||||||
</swp-stat-card>
|
|
||||||
<swp-stat-card class="success">
|
|
||||||
<swp-stat-value>8.450 kr</swp-stat-value>
|
|
||||||
<swp-stat-label>Forventet omsætning</swp-stat-label>
|
|
||||||
<swp-stat-trend class="up">
|
|
||||||
<i class="ph ph-trend-up"></i>
|
|
||||||
+12% vs. gennemsnit
|
|
||||||
</swp-stat-trend>
|
|
||||||
</swp-stat-card>
|
|
||||||
<swp-stat-card>
|
|
||||||
<swp-stat-value>78%</swp-stat-value>
|
|
||||||
<swp-stat-label>Belægningsgrad</swp-stat-label>
|
|
||||||
<swp-stat-trend class="up">
|
|
||||||
<i class="ph ph-trend-up"></i>
|
|
||||||
God kapacitet
|
|
||||||
</swp-stat-trend>
|
|
||||||
</swp-stat-card>
|
|
||||||
<swp-stat-card class="warning">
|
|
||||||
<swp-stat-value>4</swp-stat-value>
|
|
||||||
<swp-stat-label>Kræver opmærksomhed</swp-stat-label>
|
|
||||||
</swp-stat-card>
|
|
||||||
</swp-stats-bar>
|
</swp-stats-bar>
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,27 @@
|
||||||
<link rel="stylesheet" href="~/css/sidebar.css">
|
<link rel="stylesheet" href="~/css/sidebar.css">
|
||||||
<link rel="stylesheet" href="~/css/topbar.css">
|
<link rel="stylesheet" href="~/css/topbar.css">
|
||||||
<link rel="stylesheet" href="~/css/drawers.css">
|
<link rel="stylesheet" href="~/css/drawers.css">
|
||||||
|
<link rel="stylesheet" href="~/css/demo-banner.css">
|
||||||
<!-- Page Components -->
|
<!-- Page Components -->
|
||||||
<link rel="stylesheet" href="~/css/page.css">
|
<link rel="stylesheet" href="~/css/page.css">
|
||||||
<link rel="stylesheet" href="~/css/stats.css">
|
<link rel="stylesheet" href="~/css/stats.css">
|
||||||
@await RenderSectionAsync("Styles", required: false)
|
@await RenderSectionAsync("Styles", required: false)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="has-demo-banner">
|
||||||
|
<!-- Demo Mode Banner -->
|
||||||
|
<swp-demo-banner>
|
||||||
|
<swp-demo-banner-text>
|
||||||
|
<i class="ph ph-info"></i>
|
||||||
|
<span>Du ser en <strong>demo</strong> af PlanTempus.</span>
|
||||||
|
</swp-demo-banner-text>
|
||||||
|
<swp-demo-banner-cta>
|
||||||
|
<a href="/pricing">
|
||||||
|
<span>Opret konto</span>
|
||||||
|
<i class="ph ph-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</swp-demo-banner-cta>
|
||||||
|
</swp-demo-banner>
|
||||||
|
|
||||||
<swp-app-layout id="appLayout">
|
<swp-app-layout id="appLayout">
|
||||||
@await Component.InvokeAsync("SideMenu")
|
@await Component.InvokeAsync("SideMenu")
|
||||||
<partial name="_TopBar" />
|
<partial name="_TopBar" />
|
||||||
|
|
|
||||||
145
PlanTempus.Application/wwwroot/css/demo-banner.css
Normal file
145
PlanTempus.Application/wwwroot/css/demo-banner.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue