This commit is contained in:
Janus C. H. Knudsen 2026-01-11 18:18:36 +01:00
parent abcf8ee75e
commit 12869e35bf
34 changed files with 1177 additions and 156 deletions

15
.hintrc Normal file
View file

@ -0,0 +1,15 @@
{
"extends": [
"development"
],
"hints": {
"compat-api/css": [
"default",
{
"ignore": [
"grid-template-columns: subgrid"
]
}
]
}
}

View file

@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
public class AttentionItemViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = AttentionItemCatalog.Get(key);
return View(model);
}
}
public class AttentionItemViewModel
{
public required string Key { get; init; }
public required string Icon { get; init; }
public required string Title { get; init; }
public required string Description { get; init; }
public required string ActionText { get; init; }
public required string Severity { get; init; }
}
public static class AttentionItemCatalog
{
private static readonly Dictionary<string, AttentionItemViewModel> Attentions = new()
{
["attention-1"] = new AttentionItemViewModel
{
Key = "attention-1",
Icon = "x-circle",
Title = "Aflyst booking",
Description = "Mette Hansen aflyste kl. 15:00 tid nu ledig",
ActionText = "Fyld tid",
Severity = "urgent"
},
["attention-2"] = new AttentionItemViewModel
{
Key = "attention-2",
Icon = "clock",
Title = "Ubekræftet booking",
Description = "Ida Rasmussen har ikke bekræftet kl. 11:30",
ActionText = "Send påmindelse",
Severity = "warning"
},
["attention-3"] = new AttentionItemViewModel
{
Key = "attention-3",
Icon = "gift",
Title = "Gavekort udløber snart",
Description = "GC-D2R4-6TY9 udløber om 3 uger (200 DKK)",
ActionText = "Se gavekort",
Severity = "info"
}
};
public static AttentionItemViewModel Get(string key)
{
if (Attentions.TryGetValue(key, out var attention))
return attention;
throw new KeyNotFoundException($"AttentionItem with key '{key}' not found");
}
public static IEnumerable<string> AllKeys => Attentions.Keys;
}

View file

@ -0,0 +1,12 @@
@model PlanTempus.Application.Features.Dashboard.Components.AttentionItemViewModel
<swp-attention-item data-key="@Model.Key" class="@Model.Severity">
<swp-attention-icon>
<i class="ph ph-@Model.Icon"></i>
</swp-attention-icon>
<swp-attention-content>
<swp-item-title>@Model.Title</swp-item-title>
<swp-item-desc>@Model.Description</swp-item-desc>
</swp-attention-content>
<swp-attention-action>@Model.ActionText</swp-attention-action>
</swp-attention-item>

View file

@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
public class AttentionListViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = AttentionListCatalog.Get(key);
return View(model);
}
}
public class AttentionListViewModel
{
public required string Key { get; init; }
public required string Title { get; init; }
public required IReadOnlyList<string> AttentionKeys { get; init; }
}
public static class AttentionListCatalog
{
private static readonly Dictionary<string, AttentionListViewModel> Lists = new()
{
["current-attentions"] = new AttentionListViewModel
{
Key = "current-attentions",
Title = "Opmærksomheder",
AttentionKeys = ["attention-1", "attention-2", "attention-3"]
}
};
public static AttentionListViewModel Get(string key)
{
if (Lists.TryGetValue(key, out var list))
return list;
throw new KeyNotFoundException($"AttentionList with key '{key}' not found");
}
}

View file

@ -0,0 +1,18 @@
@model PlanTempus.Application.Features.Dashboard.Components.AttentionListViewModel
<swp-card data-key="@Model.Key">
<swp-card-header>
<swp-card-title>
<i class="ph ph-warning-circle"></i>
@Model.Title
</swp-card-title>
</swp-card-header>
<swp-card-content>
<swp-attention-list>
@foreach (var attentionKey in Model.AttentionKeys)
{
@await Component.InvokeAsync("AttentionItem", attentionKey)
}
</swp-attention-list>
</swp-card-content>
</swp-card>

View file

@ -7,8 +7,8 @@
</swp-booking-time> </swp-booking-time>
<swp-booking-indicator class="@Model.IndicatorColor"></swp-booking-indicator> <swp-booking-indicator class="@Model.IndicatorColor"></swp-booking-indicator>
<swp-booking-details> <swp-booking-details>
<swp-booking-service>@Model.Service</swp-booking-service> <swp-item-title>@Model.Service</swp-item-title>
<swp-booking-customer>@Model.CustomerName</swp-booking-customer> <swp-item-desc>@Model.CustomerName</swp-item-desc>
</swp-booking-details> </swp-booking-details>
<swp-booking-employee title="@Model.EmployeeName"> <swp-booking-employee title="@Model.EmployeeName">
<swp-avatar-small>@Model.EmployeeInitials</swp-avatar-small> <swp-avatar-small>@Model.EmployeeInitials</swp-avatar-small>

View file

@ -5,9 +5,9 @@
<i class="ph ph-@Model.Icon"></i> <i class="ph ph-@Model.Icon"></i>
</swp-notification-icon> </swp-notification-icon>
<swp-notification-content> <swp-notification-content>
<swp-notification-text> <swp-item-title>
<strong>@Model.Title</strong> @Model.Text <strong>@Model.Title</strong> @Model.Text
</swp-notification-text> </swp-item-title>
<swp-notification-time>@Model.Time</swp-notification-time> <swp-item-desc>@Model.Time</swp-item-desc>
</swp-notification-content> </swp-notification-content>
</swp-notification-item> </swp-notification-item>

View file

@ -0,0 +1,6 @@
@model PlanTempus.Application.Features.Dashboard.Components.QuickStatViewModel
<swp-quick-stat data-key="@Model.Key">
<swp-stat-value>@Model.Value</swp-stat-value>
<swp-stat-label>@Model.Label</swp-stat-label>
</swp-quick-stat>

View file

@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
/// <summary>
/// ViewComponent for rendering a quick stat item in the sidebar.
/// </summary>
public class QuickStatViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = QuickStatCatalog.Get(key);
return View(model);
}
}
/// <summary>
/// ViewModel for the QuickStat component.
/// </summary>
public class QuickStatViewModel
{
public required string Key { get; init; }
public required string Value { get; init; }
public required string Label { get; init; }
}
/// <summary>
/// Catalog of available quick stats with their data.
/// </summary>
public static class QuickStatCatalog
{
private static readonly Dictionary<string, QuickStatViewModel> Stats = new()
{
["bookings-week"] = new QuickStatViewModel
{
Key = "bookings-week",
Value = "47",
Label = "Bookinger"
},
["revenue-week"] = new QuickStatViewModel
{
Key = "revenue-week",
Value = "38.200 kr",
Label = "Omsætning"
},
["new-customers"] = new QuickStatViewModel
{
Key = "new-customers",
Value = "8",
Label = "Nye kunder"
},
["avg-occupancy"] = new QuickStatViewModel
{
Key = "avg-occupancy",
Value = "72%",
Label = "Gns. belægning"
}
};
public static QuickStatViewModel Get(string key)
{
if (Stats.TryGetValue(key, out var stat))
return stat;
throw new KeyNotFoundException($"QuickStat with key '{key}' not found");
}
public static IEnumerable<string> AllKeys => Stats.Keys;
}

View file

@ -0,0 +1,18 @@
@model PlanTempus.Application.Features.Dashboard.Components.QuickStatListViewModel
<swp-card data-key="@Model.Key">
<swp-card-header>
<swp-card-title>
<i class="ph ph-@Model.Icon"></i>
@Model.Title
</swp-card-title>
</swp-card-header>
<swp-card-content>
<swp-quick-stats>
@foreach (var statKey in Model.StatKeys)
{
@await Component.InvokeAsync("QuickStat", statKey)
}
</swp-quick-stats>
</swp-card-content>
</swp-card>

View file

@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
public class QuickStatListViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = QuickStatListCatalog.Get(key);
return View(model);
}
}
public class QuickStatListViewModel
{
public required string Key { get; init; }
public required string Title { get; init; }
public required string Icon { get; init; }
public required IReadOnlyList<string> StatKeys { get; init; }
}
public static class QuickStatListCatalog
{
private static readonly Dictionary<string, QuickStatListViewModel> Lists = new()
{
["this-week"] = new QuickStatListViewModel
{
Key = "this-week",
Title = "Denne uge",
Icon = "chart-line-up",
StatKeys = ["bookings-week", "revenue-week", "new-customers", "avg-occupancy"]
}
};
public static QuickStatListViewModel Get(string key)
{
if (Lists.TryGetValue(key, out var list))
return list;
throw new KeyNotFoundException($"QuickStatList with key '{key}' not found");
}
}

View file

@ -0,0 +1,9 @@
@model PlanTempus.Application.Features.Dashboard.Components.WaitlistCardViewModel
<swp-waitlist-card data-key="@Model.Key" data-drawer-trigger="@Model.DrawerTarget">
<swp-waitlist-icon>
<i class="ph ph-@Model.Icon"></i>
<swp-waitlist-badge>@Model.Count</swp-waitlist-badge>
</swp-waitlist-icon>
<swp-waitlist-label>@Model.Label</swp-waitlist-label>
</swp-waitlist-card>

View file

@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
/// <summary>
/// ViewComponent for rendering the waitlist mini card on the dashboard.
/// Displays a count badge and triggers the waitlist drawer when clicked.
/// </summary>
public class WaitlistCardViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = WaitlistCardCatalog.Get(key);
return View(model);
}
}
/// <summary>
/// ViewModel for the WaitlistCard component.
/// </summary>
public class WaitlistCardViewModel
{
public required string Key { get; init; }
public required string Label { get; init; }
public required string Icon { get; init; }
public required int Count { get; init; }
public required string DrawerTarget { get; init; }
}
/// <summary>
/// Catalog of waitlist cards with their data.
/// </summary>
public static class WaitlistCardCatalog
{
private static readonly Dictionary<string, WaitlistCardViewModel> Cards = new()
{
["waitlist"] = new WaitlistCardViewModel
{
Key = "waitlist",
Label = "På venteliste",
Icon = "users-three",
Count = 4,
DrawerTarget = "waitlist-drawer"
}
};
public static WaitlistCardViewModel Get(string key)
{
if (Cards.TryGetValue(key, out var card))
return card;
throw new KeyNotFoundException($"WaitlistCard with key '{key}' not found");
}
}

View file

@ -0,0 +1,41 @@
@model PlanTempus.Application.Features.Dashboard.Components.WaitlistItemViewModel
<swp-waitlist-item data-key="@Model.Key">
<swp-waitlist-customer>
<swp-avatar>@Model.CustomerInitials</swp-avatar>
<swp-waitlist-customer-info>
<swp-waitlist-name>@Model.CustomerName</swp-waitlist-name>
<swp-waitlist-phone>@Model.CustomerPhone</swp-waitlist-phone>
</swp-waitlist-customer-info>
</swp-waitlist-customer>
<swp-waitlist-service>@Model.Service</swp-waitlist-service>
<swp-waitlist-meta>
<swp-waitlist-periods>
<swp-label>Ønsker:</swp-label>
@foreach (var period in Model.PreferredPeriods)
{
<swp-waitlist-period-tag>@period</swp-waitlist-period-tag>
}
</swp-waitlist-periods>
<swp-waitlist-dates>
<swp-waitlist-date>
<i class="ph ph-calendar"></i>
Tilmeldt: @Model.RegisteredDate
</swp-waitlist-date>
<swp-waitlist-date class="expires @(Model.ExpiresSoon ? "soon" : "")">
<i class="ph ph-clock"></i>
Udløber: @Model.ExpiresDate
</swp-waitlist-date>
</swp-waitlist-dates>
</swp-waitlist-meta>
<swp-waitlist-actions>
<swp-btn class="secondary">
<i class="ph ph-phone"></i>
Kontakt
</swp-btn>
<swp-btn class="primary">
<i class="ph ph-calendar-plus"></i>
Book
</swp-btn>
</swp-waitlist-actions>
</swp-waitlist-item>

View file

@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
namespace PlanTempus.Application.Features.Dashboard.Components;
/// <summary>
/// ViewComponent for rendering a waitlist item in the waitlist drawer.
/// </summary>
public class WaitlistItemViewComponent : ViewComponent
{
public IViewComponentResult Invoke(string key)
{
var model = WaitlistItemCatalog.Get(key);
return View(model);
}
}
/// <summary>
/// ViewModel for the WaitlistItem component.
/// </summary>
public class WaitlistItemViewModel
{
public required string Key { get; init; }
public required string CustomerName { get; init; }
public required string CustomerInitials { get; init; }
public required string CustomerPhone { get; init; }
public required string Service { get; init; }
public required IReadOnlyList<string> PreferredPeriods { get; init; }
public required string RegisteredDate { get; init; }
public required string ExpiresDate { get; init; }
public bool ExpiresSoon { get; init; }
}
/// <summary>
/// Catalog of waitlist items with demo data.
/// </summary>
public static class WaitlistItemCatalog
{
private static readonly Dictionary<string, WaitlistItemViewModel> Items = new()
{
["waitlist-1"] = new WaitlistItemViewModel
{
Key = "waitlist-1",
CustomerName = "Emma Christensen",
CustomerInitials = "EC",
CustomerPhone = "+45 12 34 56 78",
Service = "Dameklip + Farve",
PreferredPeriods = ["Mandag-Onsdag", "Formiddag"],
RegisteredDate = "2. jan 2026",
ExpiresDate = "16. jan 2026",
ExpiresSoon = false
},
["waitlist-2"] = new WaitlistItemViewModel
{
Key = "waitlist-2",
CustomerName = "Mikkel Sørensen",
CustomerInitials = "MS",
CustomerPhone = "+45 23 45 67 89",
Service = "Herreklip",
PreferredPeriods = ["Weekend"],
RegisteredDate = "30. dec 2025",
ExpiresDate = "6. jan 2026",
ExpiresSoon = true
},
["waitlist-3"] = new WaitlistItemViewModel
{
Key = "waitlist-3",
CustomerName = "Lise Andersen",
CustomerInitials = "LA",
CustomerPhone = "+45 34 56 78 90",
Service = "Balayage",
PreferredPeriods = ["Tirsdag-Torsdag", "Eftermiddag"],
RegisteredDate = "28. dec 2025",
ExpiresDate = "11. jan 2026",
ExpiresSoon = false
},
["waitlist-4"] = new WaitlistItemViewModel
{
Key = "waitlist-4",
CustomerName = "Peter Hansen",
CustomerInitials = "PH",
CustomerPhone = "+45 45 67 89 01",
Service = "Herreklip + Skæg",
PreferredPeriods = ["Fleksibel"],
RegisteredDate = "27. dec 2025",
ExpiresDate = "10. jan 2026",
ExpiresSoon = false
}
};
public static WaitlistItemViewModel Get(string key)
{
if (Items.TryGetValue(key, out var item))
return item;
throw new KeyNotFoundException($"WaitlistItem with key '{key}' not found");
}
public static IEnumerable<string> AllKeys => Items.Keys;
}

View file

@ -33,12 +33,21 @@
<!-- Today's Bookings --> <!-- Today's Bookings -->
@await Component.InvokeAsync("BookingList", "todays-bookings") @await Component.InvokeAsync("BookingList", "todays-bookings")
<!-- Attention Items -->
@await Component.InvokeAsync("AttentionList", "current-attentions")
</swp-main-column> </swp-main-column>
<swp-side-column> <swp-side-column>
<!-- Notifications --> <!-- Notifications -->
@await Component.InvokeAsync("NotificationList", "recent-notifications") @await Component.InvokeAsync("NotificationList", "recent-notifications")
<!-- Waitlist Card -->
@await Component.InvokeAsync("WaitlistCard", "waitlist")
<!-- Quick Stats (This Week) -->
@await Component.InvokeAsync("QuickStatList", "this-week")
<!-- Quick Actions --> <!-- Quick Actions -->
<swp-card> <swp-card>
<swp-card-header> <swp-card-header>

View file

@ -0,0 +1,21 @@
@using PlanTempus.Application.Features.Dashboard.Components
<swp-waitlist-drawer data-drawer="lg" id="waitlist-drawer">
<swp-drawer-header>
<swp-drawer-title>
Venteliste <swp-count>(@WaitlistItemCatalog.AllKeys.Count())</swp-count>
</swp-drawer-title>
<swp-drawer-close data-drawer-close>
<i class="ph ph-x"></i>
</swp-drawer-close>
</swp-drawer-header>
<swp-drawer-body>
<swp-waitlist-list>
@foreach (var key in WaitlistItemCatalog.AllKeys)
{
@await Component.InvokeAsync("WaitlistItem", key)
}
</swp-waitlist-list>
</swp-drawer-body>
</swp-waitlist-drawer>

View file

@ -20,6 +20,9 @@
<link rel="stylesheet" href="~/css/stats.css"> <link rel="stylesheet" href="~/css/stats.css">
<link rel="stylesheet" href="~/css/bookings.css"> <link rel="stylesheet" href="~/css/bookings.css">
<link rel="stylesheet" href="~/css/notifications.css"> <link rel="stylesheet" href="~/css/notifications.css">
<link rel="stylesheet" href="~/css/attentions.css">
<link rel="stylesheet" href="~/css/quick-stats.css">
<link rel="stylesheet" href="~/css/waitlist.css">
@await RenderSectionAsync("Styles", required: false) @await RenderSectionAsync("Styles", required: false)
</head> </head>
<body class="has-demo-banner"> <body class="has-demo-banner">
@ -47,6 +50,7 @@
</swp-app-layout> </swp-app-layout>
<partial name="_ProfileDrawer" /> <partial name="_ProfileDrawer" />
<partial name="_WaitlistDrawer" />
<swp-drawer-overlay id="drawerOverlay"></swp-drawer-overlay> <swp-drawer-overlay id="drawerOverlay"></swp-drawer-overlay>
<script type="module" src="~/js/app.js"></script> <script type="module" src="~/js/app.js"></script>

View file

@ -0,0 +1,114 @@
/**
* Attentions CSS
*
* Styling for attention/alert components on dashboard
*/
/* ===========================================
ATTENTION LIST
=========================================== */
swp-attention-list {
display: contents;
}
/* ===========================================
ATTENTION ITEM
=========================================== */
swp-attention-item {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-5) var(--spacing-6);
background: var(--color-background-alt);
border-radius: var(--radius-xl);
border-left: 3px solid var(--color-border);
cursor: pointer;
transition: background var(--transition-fast);
}
swp-attention-item:hover {
background: var(--color-background-hover);
}
/* Severity: Urgent (red) */
swp-attention-item.urgent {
border-left-color: var(--color-red);
background: color-mix(in srgb, var(--color-red) 5%, var(--color-background-alt));
}
swp-attention-item.urgent:hover {
background: color-mix(in srgb, var(--color-red) 8%, var(--color-background-alt));
}
/* Severity: Warning (amber) */
swp-attention-item.warning {
border-left-color: var(--color-amber);
background: color-mix(in srgb, var(--color-amber) 5%, var(--color-background-alt));
}
swp-attention-item.warning:hover {
background: color-mix(in srgb, var(--color-amber) 8%, var(--color-background-alt));
}
/* Severity: Info (blue) */
swp-attention-item.info {
border-left-color: var(--color-blue);
}
/* ===========================================
ATTENTION ICON
=========================================== */
swp-attention-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--color-background-hover);
border-radius: var(--radius-xl);
color: var(--color-text-secondary);
font-size: var(--font-size-xl);
}
/* Icon colors per severity */
swp-attention-item.urgent swp-attention-icon {
background: color-mix(in srgb, var(--color-red) 15%, transparent);
color: var(--color-red);
}
swp-attention-item.warning swp-attention-icon {
background: color-mix(in srgb, var(--color-amber) 15%, transparent);
color: var(--color-amber);
}
swp-attention-item.info swp-attention-icon {
background: color-mix(in srgb, var(--color-blue) 15%, transparent);
color: var(--color-blue);
}
/* ===========================================
ATTENTION CONTENT
=========================================== */
swp-attention-content {
display: flex;
flex-direction: column;
min-width: 0;
}
/* ===========================================
ATTENTION ACTION
=========================================== */
swp-attention-action {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-teal);
cursor: pointer;
white-space: nowrap;
transition: text-decoration var(--transition-fast);
}
swp-attention-action:hover {
text-decoration: underline;
}

View file

@ -8,10 +8,7 @@
BOOKING LIST BOOKING LIST
=========================================== */ =========================================== */
swp-booking-list { swp-booking-list {
display: grid; display: contents;
grid-template-columns: 50px 4px 1fr auto auto;
gap: var(--spacing-4);
padding: 0 var(--card-body-padding);
} }
/* =========================================== /* ===========================================
@ -90,22 +87,6 @@ swp-booking-details {
overflow: hidden; overflow: hidden;
} }
swp-booking-service {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
swp-booking-customer {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* =========================================== /* ===========================================
BOOKING EMPLOYEE BOOKING EMPLOYEE
=========================================== */ =========================================== */
@ -174,7 +155,7 @@ swp-current-time {
padding: var(--spacing-4) var(--spacing-6); padding: var(--spacing-4) var(--spacing-6);
background: color-mix(in srgb, var(--color-teal) 10%, transparent); background: color-mix(in srgb, var(--color-teal) 10%, transparent);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
margin: 0 var(--card-body-padding) var(--spacing-4) var(--card-body-padding); margin-bottom: var(--spacing-4);
} }
swp-current-time i { swp-current-time i {

View file

@ -5,8 +5,36 @@
*/ */
/* =========================================== /* ===========================================
BASE DRAWER BASE DRAWER (Generic)
=========================================== */ =========================================== */
[data-drawer] {
position: fixed;
top: 0;
right: 0;
width: var(--drawer-width, 320px);
height: 100vh;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: var(--z-drawer);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform var(--transition-normal);
}
[data-drawer].active,
[data-drawer].open {
transform: translateX(0);
}
/* Drawer width variants */
[data-drawer="sm"] { --drawer-width: 280px; }
[data-drawer="md"] { --drawer-width: 360px; }
[data-drawer="lg"] { --drawer-width: 420px; }
[data-drawer="xl"] { --drawer-width: 480px; }
/* Legacy support for existing drawers */
swp-profile-drawer, swp-profile-drawer,
swp-notification-drawer, swp-notification-drawer,
swp-todo-drawer { swp-todo-drawer {
@ -38,17 +66,26 @@ swp-drawer-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--spacing-4) var(--spacing-5); padding: var(--spacing-10) var(--spacing-12);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
swp-drawer-title { swp-drawer-title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
font-weight: 600; font-weight: var(--font-weight-semibold);
color: var(--color-text); color: var(--color-text);
} }
swp-drawer-title swp-count {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
swp-drawer-close { swp-drawer-close {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -56,8 +93,8 @@ swp-drawer-close {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: none; border: none;
background: transparent; background: var(--color-background-alt);
border-radius: var(--border-radius); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
color: var(--color-text-secondary); color: var(--color-text-secondary);
transition: all var(--transition-fast); transition: all var(--transition-fast);
@ -73,12 +110,13 @@ swp-drawer-close i {
} }
/* =========================================== /* ===========================================
DRAWER CONTENT DRAWER CONTENT / BODY
=========================================== */ =========================================== */
swp-drawer-content { swp-drawer-content,
swp-drawer-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: var(--spacing-5); padding: var(--spacing-8);
} }
swp-drawer-divider { swp-drawer-divider {

View file

@ -8,10 +8,7 @@
NOTIFICATION LIST NOTIFICATION LIST
=========================================== */ =========================================== */
swp-notification-list { swp-notification-list {
display: grid; display: contents;
grid-template-columns: 50px 1fr;
gap: var(--spacing-4) var(--spacing-6);
padding: 0 var(--card-body-padding);
} }
/* =========================================== /* ===========================================
@ -70,23 +67,3 @@ swp-notification-content {
min-width: 0; min-width: 0;
} }
swp-notification-text {
display: block;
font-size: var(--font-size-md);
color: var(--color-text);
line-height: var(--line-height-snug);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
swp-notification-text strong {
font-weight: var(--font-weight-semibold);
}
swp-notification-time {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
margin-top: var(--spacing-1);
}

View file

@ -49,6 +49,7 @@ swp-card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: var(--spacing-5);
margin-bottom: var(--spacing-5); margin-bottom: var(--spacing-5);
} }
@ -56,6 +57,7 @@ swp-card-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin: calc(-1 * var(--spacing-5)) calc(-1 * var(--spacing-5)) var(--spacing-4) calc(-1 * var(--spacing-5));
padding: var(--spacing-4) var(--spacing-5); padding: var(--spacing-4) var(--spacing-5);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@ -86,7 +88,44 @@ swp-card-action:hover {
} }
swp-card-content { swp-card-content {
padding: var(--spacing-5); display: block;
}
/* Card content with grid lists - auto-detect via :has() */
swp-card-content:has(> swp-booking-list),
swp-card-content:has(> swp-notification-list),
swp-card-content:has(> swp-attention-list) {
display: grid;
gap: var(--spacing-4);
}
swp-card-content:has(> swp-booking-list) {
grid-template-columns: 50px 4px 1fr auto auto;
}
swp-card-content:has(> swp-notification-list) {
grid-template-columns: 50px 1fr;
}
swp-card-content:has(> swp-attention-list) {
grid-template-columns: 50px 1fr auto;
}
/* Generic list item title & description */
swp-item-title {
display: block;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
swp-item-desc {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
} }
/* =========================================== /* ===========================================
@ -98,14 +137,11 @@ swp-dashboard-grid {
gap: var(--spacing-5); gap: var(--spacing-5);
} }
swp-main-column { swp-main-column,
display: flex;
flex-direction: column;
}
swp-side-column { swp-side-column {
display: flex; display: grid;
flex-direction: column; gap: var(--spacing-5);
align-content: start;
} }
/* =========================================== /* ===========================================

View file

@ -0,0 +1,38 @@
/**
* Quick Stats CSS
*
* Styling for quick stats components in sidebar
*/
/* ===========================================
QUICK STATS CONTAINER
=========================================== */
swp-quick-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-3);
}
/* ===========================================
QUICK STAT ITEM
=========================================== */
swp-quick-stat {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3);
background: var(--color-background-alt);
border-radius: var(--radius-md);
}
swp-quick-stat swp-stat-value {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
font-family: var(--font-mono);
color: var(--color-text);
}
swp-quick-stat swp-stat-label {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}

View file

@ -182,33 +182,6 @@ swp-stat-card.highlight.filled swp-stat-change {
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
} }
/* ===========================================
QUICK STATS (Compact Variant)
=========================================== */
swp-quick-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-3);
}
swp-quick-stat {
display: flex;
flex-direction: column;
text-align: center;
padding: var(--spacing-3);
background: var(--color-background);
border-radius: var(--border-radius);
}
swp-quick-stat swp-stat-value {
font-size: var(--font-size-xl);
}
swp-quick-stat swp-stat-label {
font-size: var(--font-size-xs);
margin-top: var(--spacing-1);
}
/* =========================================== /* ===========================================
STAT ITEM (Inline Variant) STAT ITEM (Inline Variant)
=========================================== */ =========================================== */

View file

@ -0,0 +1,250 @@
/**
* Waitlist CSS
*
* Styling for waitlist mini card and drawer items
*/
/* ===========================================
WAITLIST MINI CARD
=========================================== */
swp-waitlist-card {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
}
swp-waitlist-card:hover {
border-color: var(--color-teal);
box-shadow: var(--shadow-md);
}
swp-waitlist-icon {
position: relative;
font-size: var(--font-size-2xl);
color: var(--color-text-secondary);
}
swp-waitlist-badge {
position: absolute;
top: -8px;
right: -8px;
min-width: 20px;
height: 20px;
padding: 0 var(--spacing-2);
background: var(--color-teal);
color: white;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
}
swp-waitlist-label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
/* ===========================================
WAITLIST LIST
=========================================== */
swp-waitlist-list {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
/* ===========================================
WAITLIST ITEM
=========================================== */
swp-waitlist-item {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-8);
background: var(--color-background-alt);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
/* ===========================================
CUSTOMER SECTION
=========================================== */
swp-waitlist-customer {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
swp-waitlist-customer swp-avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
flex-shrink: 0;
}
swp-waitlist-customer-info {
flex: 1;
min-width: 0;
}
swp-waitlist-name {
display: block;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
swp-waitlist-phone {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
/* ===========================================
SERVICE TAG
=========================================== */
swp-waitlist-service {
display: inline-block;
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
color: var(--color-teal);
padding: var(--spacing-2) var(--spacing-3);
background: color-mix(in srgb, var(--color-teal) 10%, transparent);
border-radius: var(--radius-sm);
}
/* ===========================================
META SECTION (Preferences & Dates)
=========================================== */
swp-waitlist-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
swp-waitlist-periods {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
swp-waitlist-periods swp-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-waitlist-period-tag {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
background: var(--color-background);
border-radius: var(--radius-sm);
color: var(--color-text);
}
swp-waitlist-dates {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
swp-waitlist-date {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: var(--spacing-1);
}
swp-waitlist-date i {
font-size: var(--font-size-sm);
}
swp-waitlist-date.expires.soon {
color: var(--color-amber);
font-weight: var(--font-weight-medium);
}
/* ===========================================
ACTION BUTTONS
=========================================== */
swp-waitlist-actions {
display: flex;
gap: var(--spacing-4);
padding-top: var(--spacing-4);
border-top: 1px solid var(--color-border);
}
swp-waitlist-actions swp-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--spacing-6);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
font-family: var(--font-family);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
border: none;
}
swp-waitlist-actions swp-btn.secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
swp-waitlist-actions swp-btn.secondary:hover {
background: var(--color-background-hover);
}
swp-waitlist-actions swp-btn.primary {
background: var(--color-teal);
color: white;
}
swp-waitlist-actions swp-btn.primary:hover {
opacity: 0.9;
}
/* ===========================================
EMPTY STATE
=========================================== */
swp-waitlist-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-10) var(--spacing-6);
text-align: center;
}
swp-waitlist-empty i {
font-size: 48px;
color: var(--color-border);
margin-bottom: var(--spacing-4);
}
swp-waitlist-empty span {
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,7 @@ export class DrawerController {
private newTodoDrawer: HTMLElement | null = null; private newTodoDrawer: HTMLElement | null = null;
private overlay: HTMLElement | null = null; private overlay: HTMLElement | null = null;
private activeDrawer: DrawerName | null = null; private activeDrawer: DrawerName | null = null;
private activeGenericDrawer: HTMLElement | null = null;
constructor() { constructor() {
this.profileDrawer = document.getElementById('profileDrawer'); this.profileDrawer = document.getElementById('profileDrawer');
@ -22,6 +23,7 @@ export class DrawerController {
this.overlay = document.getElementById('drawerOverlay'); this.overlay = document.getElementById('drawerOverlay');
this.setupListeners(); this.setupListeners();
this.setupGenericDrawers();
} }
/** /**
@ -71,11 +73,37 @@ export class DrawerController {
[this.profileDrawer, this.notificationDrawer, this.todoDrawer, this.newTodoDrawer] [this.profileDrawer, this.notificationDrawer, this.todoDrawer, this.newTodoDrawer]
.forEach(drawer => drawer?.classList.remove('active')); .forEach(drawer => drawer?.classList.remove('active'));
// Close any generic drawers
this.closeGenericDrawer();
this.overlay?.classList.remove('active'); this.overlay?.classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
this.activeDrawer = null; this.activeDrawer = null;
} }
/**
* Open a generic drawer by ID
*/
openGenericDrawer(drawerId: string): void {
this.closeAll();
const drawer = document.getElementById(drawerId);
if (drawer && this.overlay) {
drawer.classList.add('open');
this.overlay.classList.add('active');
document.body.style.overflow = 'hidden';
this.activeGenericDrawer = drawer;
}
}
/**
* Close the currently open generic drawer
*/
closeGenericDrawer(): void {
this.activeGenericDrawer?.classList.remove('open');
this.activeGenericDrawer = null;
}
/** /**
* Open profile drawer * Open profile drawer
*/ */
@ -223,4 +251,35 @@ export class DrawerController {
option.classList.add('active'); option.classList.add('active');
} }
} }
/**
* Setup generic drawer triggers and close buttons
* Uses data-drawer-trigger="drawer-id" and data-drawer-close attributes
*/
private setupGenericDrawers(): void {
// Handle drawer triggers
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
const trigger = target.closest<HTMLElement>('[data-drawer-trigger]');
if (trigger) {
const drawerId = trigger.dataset.drawerTrigger;
if (drawerId) {
this.openGenericDrawer(drawerId);
}
}
});
// Handle drawer close buttons
document.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
const closeBtn = target.closest<HTMLElement>('[data-drawer-close]');
if (closeBtn) {
this.closeGenericDrawer();
this.overlay?.classList.remove('active');
document.body.style.overflow = '';
}
});
}
} }