From 120367acbbc40244f316f3994b7eba051163169f Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Fri, 16 Jan 2026 22:03:22 +0100 Subject: [PATCH] Enhances Services module with detail view and interactions Adds comprehensive service detail view with multiple tabs and dynamic interactions Implements client-side navigation between service list and detail views Introduces mock service data catalog for flexible component rendering Extends localization support for new service detail screens Improves user experience by adding edit capabilities and smooth view transitions --- .claude/settings.local.json | 3 +- .../Localization/Translations/da.json | 52 ++ .../CategoryTableViewComponent.cs | 68 --- .../Components/CategoryTable/Default.cshtml | 31 - .../ServiceCategoryGroup/Default.cshtml | 6 +- .../Components/ServiceDetailCatalog.cs | 171 ++++++ .../ServiceDetailGeneral/Default.cshtml | 129 +++++ .../ServiceDetailGeneralViewComponent.cs | 142 +++++ .../ServiceDetailHeader/Default.cshtml | 40 ++ .../ServiceDetailHeaderViewComponent.cs | 61 ++ .../ServiceDetailView/Default.cshtml | 89 +++ .../ServiceDetailViewViewComponent.cs | 47 ++ .../Components/ServiceTable/Default.cshtml | 67 ++- .../ServiceTable/ServiceTableViewComponent.cs | 2 + .../Features/Services/Pages/Index.cshtml | 31 +- .../wwwroot/css/components.css | 547 ++++++++++++++++++ .../wwwroot/css/controls.css | 92 +++ .../wwwroot/css/drawers.css | 6 + .../wwwroot/css/employees.css | 436 +------------- .../wwwroot/css/services.css | 108 ++-- .../wwwroot/ts/modules/controls.ts | 52 ++ .../wwwroot/ts/modules/services.ts | 197 ++++++- 22 files changed, 1780 insertions(+), 597 deletions(-) delete mode 100644 PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs delete mode 100644 PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/Default.cshtml create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/ServiceDetailGeneralViewComponent.cs create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/Default.cshtml create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/ServiceDetailHeaderViewComponent.cs create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml create mode 100644 PlanTempus.Application/Features/Services/Components/ServiceDetailView/ServiceDetailViewViewComponent.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 47b3665..b4ee63a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(tree:*)", "Bash(npm run analyze-css:*)", "Bash(node:*)", - "Bash(npx esbuild:*)" + "Bash(npx esbuild:*)", + "mcp__ide__getDiagnostics" ] } } diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json index 657f464..363deeb 100644 --- a/PlanTempus.Application/Features/Localization/Translations/da.json +++ b/PlanTempus.Application/Features/Localization/Translations/da.json @@ -241,6 +241,58 @@ "duration": "Varighed", "price": "Pris", "serviceCount": "Antal services" + }, + "detail": { + "back": "Tilbage til services", + "save": "Gem ændringer", + "tabs": { + "general": "Generelt", + "prices": "Priser", + "duration": "Varighed", + "employees": "Medarbejdere", + "addons": "Tilvalg", + "rules": "Regler" + }, + "general": { + "basic": "Grundlæggende", + "serviceName": "Servicenavn", + "category": "Kategori", + "calendarColor": "Farve i kalenderen", + "isActive": "Service aktiv", + "internalNotes": "Interne noter", + "bookingType": "Bookingtype", + "canBookAsMain": "Kan bookes som hovedservice", + "canBookAsMainDesc": "Vises i servicelisten og kan bookes selvstændigt", + "canBookAsAddon": "Kan bookes som tilvalg", + "canBookAsAddonDesc": "Kan tilføjes som ekstra ydelse til andre services", + "onlineBooking": "Online booking", + "showInOnlineBooking": "Vis i online booking", + "showInOnlineBookingDesc": "Synlig for kunder i online booking", + "isFeatured": "Fremhævet service", + "isFeaturedDesc": "Vises øverst med fremhævet styling", + "description": "Beskrivelse", + "image": "Billede", + "uploadImage": "+ Upload billede" + }, + "header": { + "duration": "min varighed", + "fromPrice": "fra pris", + "employees": "medarbejdere", + "bookingsThisYear": "bookinger i år", + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "categoryDrawer": { + "title": "Opret kategori", + "name": "Kategorinavn", + "description": "Beskrivelse", + "visibilitySection": "Synlighed", + "showInBooking": "Kategorien skal vises i online booking", + "showInBookingDescription": "Kategorien vil stadig være synlig her i systemet", + "timePeriod": "Skal kun være synlig i følgende tidsperiode", + "timePeriodHint": "Efterlad felterne blanke for ingen tidsbegrænsning", + "save": "Gem kategori" + } } }, "employees": { diff --git a/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs b/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs deleted file mode 100644 index 2720403..0000000 --- a/PlanTempus.Application/Features/Services/Components/CategoryTable/CategoryTableViewComponent.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text.Json; -using Microsoft.AspNetCore.Mvc; -using PlanTempus.Application.Features.Localization.Services; - -namespace PlanTempus.Application.Features.Services.Components; - -public class CategoryTableViewComponent : ViewComponent -{ - private readonly ILocalizationService _localization; - private readonly IWebHostEnvironment _env; - - public CategoryTableViewComponent(ILocalizationService localization, IWebHostEnvironment env) - { - _localization = localization; - _env = env; - } - - public IViewComponentResult Invoke(string key) - { - var data = LoadServiceData(); - var model = new CategoryTableViewModel - { - Key = key, - CreateButtonText = _localization.Get("services.createCategory"), - ColumnCategory = _localization.Get("services.table.category"), - ColumnServiceCount = _localization.Get("services.table.serviceCount"), - Categories = data.Categories - .OrderBy(c => c.SortOrder) - .Select(c => new CategoryItemViewModel - { - Id = c.Id, - Name = c.Name, - SortOrder = c.SortOrder, - ServiceCount = data.Services.Count(s => s.CategoryId == c.Id) - }) - .ToList() - }; - - return View(model); - } - - private ServiceMockData LoadServiceData() - { - var jsonPath = Path.Combine(_env.ContentRootPath, "Features", "Services", "Data", "servicesMock.json"); - var json = System.IO.File.ReadAllText(jsonPath); - return JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }) ?? new ServiceMockData(); - } -} - -public class CategoryTableViewModel -{ - public required string Key { get; init; } - public required string CreateButtonText { get; init; } - public required string ColumnCategory { get; init; } - public required string ColumnServiceCount { get; init; } - public required IReadOnlyList Categories { get; init; } -} - -public class CategoryItemViewModel -{ - public required string Id { get; init; } - public required string Name { get; init; } - public int SortOrder { get; init; } - public int ServiceCount { get; init; } -} diff --git a/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml b/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml deleted file mode 100644 index 57e21e6..0000000 --- a/PlanTempus.Application/Features/Services/Components/CategoryTable/Default.cshtml +++ /dev/null @@ -1,31 +0,0 @@ -@model PlanTempus.Application.Features.Services.Components.CategoryTableViewModel - - -
- - - @Model.CreateButtonText - -
- - - - - @Model.ColumnCategory - @Model.ColumnServiceCount - - - @foreach (var category in Model.Categories) - { - - @category.Name - @category.ServiceCount - - - - - - - } - - diff --git a/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml index 0d9465a..589c14c 100644 --- a/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml +++ b/PlanTempus.Application/Features/Services/Components/ServiceCategoryGroup/Default.cshtml @@ -10,7 +10,11 @@ - + + + + + @foreach (var service in Model.Services) diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs new file mode 100644 index 0000000..a153fca --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs @@ -0,0 +1,171 @@ +namespace PlanTempus.Application.Features.Services.Components; + +/// +/// Shared catalog for service detail data. +/// Used by all ServiceDetail* ViewComponents. +/// +public static class ServiceDetailCatalog +{ + private static readonly Dictionary Services = new() + { + ["service-1"] = new ServiceDetailRecord + { + Key = "service-1", + Name = "Klip & Farve", + Category = "Kombi-behandlinger", + CalendarColor = "deep-orange", + IsActive = true, + DurationRange = "60-120", + FromPrice = "795 kr", + EmployeeCount = "4", + BookingsThisYear = "156", + Tags = new() { new("Populær", "popular"), new("Kombi", "combo"), new("Farve", "color") }, + InternalNotes = "Komplet farvebehandling med klip. Husk konsultation ved første besøg. Anbefal Olaplex til kemisk behandlet hår.", + CanBookAsMain = true, + CanBookAsAddon = false, + ShowInOnlineBooking = true, + IsFeatured = false, + Description = "Forkæl dig selv med en komplet forvandling! Vores Klip & Farve behandling inkluderer professionel farverådgivning, farvning tilpasset din hudtone, præcisionsklip og styling. Perfekt til dig der ønsker et helt nyt look." + }, + ["service-2"] = new ServiceDetailRecord + { + Key = "service-2", + Name = "Herreklip", + Category = "Klip", + CalendarColor = "blue", + IsActive = true, + DurationRange = "30", + FromPrice = "295 kr", + EmployeeCount = "6", + BookingsThisYear = "312", + Tags = new() { new("Populær", "popular") }, + InternalNotes = "Standard herreklip. Inkluderer vask og styling.", + CanBookAsMain = true, + CanBookAsAddon = false, + ShowInOnlineBooking = true, + IsFeatured = true, + Description = "Klassisk herreklip med vask, klip og styling. Vores erfarne stylister sikrer dig et skarpt og velplejet look." + }, + ["service-3"] = new ServiceDetailRecord + { + Key = "service-3", + Name = "Dameklip", + Category = "Klip", + CalendarColor = "teal", + IsActive = true, + DurationRange = "45-60", + FromPrice = "395 kr", + EmployeeCount = "5", + BookingsThisYear = "248", + Tags = new() { new("Populær", "popular") }, + InternalNotes = "Standard dameklip. Altid konsultation først.", + CanBookAsMain = true, + CanBookAsAddon = false, + ShowInOnlineBooking = true, + IsFeatured = true, + Description = "Professionel dameklip tilpasset din ansigtsform og ønsker. Inkluderer vask, klip og føn." + }, + ["service-4"] = new ServiceDetailRecord + { + Key = "service-4", + Name = "Balayage", + Category = "Farve", + CalendarColor = "purple", + IsActive = true, + DurationRange = "120-180", + FromPrice = "1.295 kr", + EmployeeCount = "3", + BookingsThisYear = "89", + Tags = new() { new("Farve", "color"), new("Premium", "popular") }, + InternalNotes = "Avanceret farveteknik. Kun certificerede stylister. Kræver tid til konsultation.", + CanBookAsMain = true, + CanBookAsAddon = false, + ShowInOnlineBooking = true, + IsFeatured = false, + Description = "Smuk, naturlig farveeffekt med håndmalede highlights. Perfekt til dig der ønsker et solkysset look med lavt vedligehold." + }, + ["service-5"] = new ServiceDetailRecord + { + Key = "service-5", + Name = "Olaplex Behandling", + Category = "Behandlinger", + CalendarColor = "green", + IsActive = true, + DurationRange = "30", + FromPrice = "295 kr", + EmployeeCount = "6", + BookingsThisYear = "134", + Tags = new() { new("Tilvalg", "combo") }, + InternalNotes = "Kan tilføjes til alle farvebehandlinger. Anbefales ved kemisk behandlet hår.", + CanBookAsMain = false, + CanBookAsAddon = true, + ShowInOnlineBooking = true, + IsFeatured = false, + Description = "Intensiv hårbehandling der reparerer og styrker håret. Ideel som tilvalg til farvebehandlinger." + }, + ["service-6"] = new ServiceDetailRecord + { + Key = "service-6", + Name = "Bryllupsfrisure", + Category = "Styling", + CalendarColor = "amber", + IsActive = false, + DurationRange = "90-120", + FromPrice = "895 kr", + EmployeeCount = "2", + BookingsThisYear = "24", + Tags = new() { new("Premium", "popular"), new("Sæson", "combo") }, + InternalNotes = "Kun Maria og Anna kan bookes til dette. Kræver prøve-session 2 uger før.", + CanBookAsMain = true, + CanBookAsAddon = false, + ShowInOnlineBooking = false, + IsFeatured = false, + Description = "Eksklusiv bryllupsfrisure med forudgående konsultation og prøvesession. Vi skaber din drømmefrisure til den store dag." + } + }; + + public static ServiceDetailRecord Get(string key) + { + if (!Services.TryGetValue(key, out var service)) + throw new KeyNotFoundException($"Service with key '{key}' not found"); + return service; + } + + public static IEnumerable AllKeys => Services.Keys; +} + +/// +/// Complete service detail record used across all detail ViewComponents. +/// +public record ServiceDetailRecord +{ + // Identity + public required string Key { get; init; } + public required string Name { get; init; } + public required string Category { get; init; } + public required string CalendarColor { get; init; } + public required bool IsActive { get; init; } + + // Stats (header) + public required string DurationRange { get; init; } + public required string FromPrice { get; init; } + public required string EmployeeCount { get; init; } + public required string BookingsThisYear { get; init; } + + // Tags + public List Tags { get; init; } = new(); + + // Generelt tab - Grundlæggende + public required string InternalNotes { get; init; } + + // Generelt tab - Bookingtype + public required bool CanBookAsMain { get; init; } + public required bool CanBookAsAddon { get; init; } + + // Generelt tab - Online booking + public required bool ShowInOnlineBooking { get; init; } + public required bool IsFeatured { get; init; } + public required string Description { get; init; } +} + +public record ServiceTag(string Text, string CssClass); diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/Default.cshtml new file mode 100644 index 0000000..a176741 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/Default.cshtml @@ -0,0 +1,129 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceDetailGeneralViewModel + + + +
+ + + @Model.LabelBasic + + + @Model.LabelServiceName + + + + @Model.LabelCategory + + +
+ Kombi-behandlinger + Klip + Farve + Behandlinger + Styling +
+
+
+ + @Model.LabelCalendarColor + + +
+ Rød + Pink + Lilla + Mørk lilla + Indigo + Blå + Lyseblå + Cyan + Teal + Grøn + Lysegrøn + Lime + Gul + Amber + Orange + Mørk orange +
+
+
+ + @Model.LabelIsActive + + @Model.ToggleYes + @Model.ToggleNo + + +
+ + @Model.LabelInternalNotes + +
+ + + + @Model.LabelBookingType + +
+ @Model.LabelCanBookAsMain + @Model.LabelCanBookAsMainDesc +
+ + @Model.ToggleYes + @Model.ToggleNo + +
+ +
+ @Model.LabelCanBookAsAddon + @Model.LabelCanBookAsAddonDesc +
+ + @Model.ToggleYes + @Model.ToggleNo + +
+
+
+ + +
+ + + @Model.LabelOnlineBooking + +
+ @Model.LabelShowInOnlineBooking + @Model.LabelShowInOnlineBookingDesc +
+ + @Model.ToggleYes + @Model.ToggleNo + +
+ +
+ @Model.LabelIsFeatured + @Model.LabelIsFeaturedDesc +
+ + @Model.ToggleYes + @Model.ToggleNo + +
+ + @Model.LabelDescription + + + @Model.LabelImage + @Model.LabelUploadImage +
+
+
diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/ServiceDetailGeneralViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/ServiceDetailGeneralViewComponent.cs new file mode 100644 index 0000000..3368df6 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailGeneral/ServiceDetailGeneralViewComponent.cs @@ -0,0 +1,142 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Services.Components; + +public class ServiceDetailGeneralViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + // Category display -> value mapping + private static readonly Dictionary CategoryValues = new() + { + ["Kombi-behandlinger"] = "kombi", + ["Klip"] = "klip", + ["Farve"] = "farve", + ["Behandlinger"] = "behandlinger", + ["Styling"] = "styling" + }; + + // Color value -> display label mapping + private static readonly Dictionary ColorLabels = new() + { + ["red"] = "Rød", + ["pink"] = "Pink", + ["purple"] = "Lilla", + ["deep-purple"] = "Mørk lilla", + ["indigo"] = "Indigo", + ["blue"] = "Blå", + ["light-blue"] = "Lyseblå", + ["cyan"] = "Cyan", + ["teal"] = "Teal", + ["green"] = "Grøn", + ["light-green"] = "Lysegrøn", + ["lime"] = "Lime", + ["yellow"] = "Gul", + ["amber"] = "Amber", + ["orange"] = "Orange", + ["deep-orange"] = "Mørk orange" + }; + + public ServiceDetailGeneralViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string key) + { + var service = ServiceDetailCatalog.Get(key); + + var model = new ServiceDetailGeneralViewModel + { + // Data + Name = service.Name, + Category = service.Category, + CategoryValue = CategoryValues.GetValueOrDefault(service.Category, "kombi"), + CalendarColor = service.CalendarColor, + CalendarColorLabel = ColorLabels.GetValueOrDefault(service.CalendarColor, service.CalendarColor), + IsActive = service.IsActive, + InternalNotes = service.InternalNotes, + CanBookAsMain = service.CanBookAsMain, + CanBookAsAddon = service.CanBookAsAddon, + ShowInOnlineBooking = service.ShowInOnlineBooking, + IsFeatured = service.IsFeatured, + Description = service.Description, + + // Labels + LabelBasic = _localization.Get("services.detail.general.basic"), + LabelServiceName = _localization.Get("services.detail.general.serviceName"), + LabelCategory = _localization.Get("services.detail.general.category"), + LabelCalendarColor = _localization.Get("services.detail.general.calendarColor"), + LabelIsActive = _localization.Get("services.detail.general.isActive"), + LabelInternalNotes = _localization.Get("services.detail.general.internalNotes"), + LabelBookingType = _localization.Get("services.detail.general.bookingType"), + LabelCanBookAsMain = _localization.Get("services.detail.general.canBookAsMain"), + LabelCanBookAsMainDesc = _localization.Get("services.detail.general.canBookAsMainDesc"), + LabelCanBookAsAddon = _localization.Get("services.detail.general.canBookAsAddon"), + LabelCanBookAsAddonDesc = _localization.Get("services.detail.general.canBookAsAddonDesc"), + LabelOnlineBooking = _localization.Get("services.detail.general.onlineBooking"), + LabelShowInOnlineBooking = _localization.Get("services.detail.general.showInOnlineBooking"), + LabelShowInOnlineBookingDesc = _localization.Get("services.detail.general.showInOnlineBookingDesc"), + LabelIsFeatured = _localization.Get("services.detail.general.isFeatured"), + LabelIsFeaturedDesc = _localization.Get("services.detail.general.isFeaturedDesc"), + LabelDescription = _localization.Get("services.detail.general.description"), + LabelImage = _localization.Get("services.detail.general.image"), + LabelUploadImage = _localization.Get("services.detail.general.uploadImage"), + ToggleYes = _localization.Get("common.yes"), + ToggleNo = _localization.Get("common.no") + }; + + return View(model); + } +} + +public class ServiceDetailGeneralViewModel +{ + // Data + public required string Name { get; init; } + public required string Category { get; init; } + public required string CategoryValue { get; init; } + public required string CalendarColor { get; init; } + public required string CalendarColorLabel { get; init; } + public required bool IsActive { get; init; } + public required string InternalNotes { get; init; } + public required bool CanBookAsMain { get; init; } + public required bool CanBookAsAddon { get; init; } + public required bool ShowInOnlineBooking { get; init; } + public required bool IsFeatured { get; init; } + public required string Description { get; init; } + + // Labels - Basic + public required string LabelBasic { get; init; } + public required string LabelServiceName { get; init; } + public required string LabelCategory { get; init; } + public required string LabelCalendarColor { get; init; } + public required string LabelIsActive { get; init; } + + // Labels - Internal Notes + public required string LabelInternalNotes { get; init; } + + // Labels - Booking Type + public required string LabelBookingType { get; init; } + public required string LabelCanBookAsMain { get; init; } + public required string LabelCanBookAsMainDesc { get; init; } + public required string LabelCanBookAsAddon { get; init; } + public required string LabelCanBookAsAddonDesc { get; init; } + + // Labels - Online Booking + public required string LabelOnlineBooking { get; init; } + public required string LabelShowInOnlineBooking { get; init; } + public required string LabelShowInOnlineBookingDesc { get; init; } + public required string LabelIsFeatured { get; init; } + public required string LabelIsFeaturedDesc { get; init; } + + // Labels - Description & Image + public required string LabelDescription { get; init; } + public required string LabelImage { get; init; } + public required string LabelUploadImage { get; init; } + + // Toggle labels + public required string ToggleYes { get; init; } + public required string ToggleNo { get; init; } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/Default.cshtml new file mode 100644 index 0000000..470cdb0 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/Default.cshtml @@ -0,0 +1,40 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceDetailHeaderViewModel + + + + + @Model.Name + @if (Model.Tags.Any()) + { + + @foreach (var tag in Model.Tags) + { + @tag.Text + } + + } + + + @Model.StatusText + + + + + @Model.DurationRange + @Model.LabelDuration + + + @Model.FromPrice + @Model.LabelFromPrice + + + @Model.EmployeeCount + @Model.LabelEmployees + + + @Model.BookingsThisYear + @Model.LabelBookingsThisYear + + + + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/ServiceDetailHeaderViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/ServiceDetailHeaderViewComponent.cs new file mode 100644 index 0000000..10d7b4f --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailHeader/ServiceDetailHeaderViewComponent.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Services.Components; + +public class ServiceDetailHeaderViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public ServiceDetailHeaderViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string key) + { + var service = ServiceDetailCatalog.Get(key); + + var model = new ServiceDetailHeaderViewModel + { + Name = service.Name, + IsActive = service.IsActive, + StatusText = service.IsActive + ? _localization.Get("services.detail.header.active") + : _localization.Get("services.detail.header.inactive"), + DurationRange = service.DurationRange, + FromPrice = service.FromPrice, + EmployeeCount = service.EmployeeCount, + BookingsThisYear = service.BookingsThisYear, + LabelDuration = _localization.Get("services.detail.header.duration"), + LabelFromPrice = _localization.Get("services.detail.header.fromPrice"), + LabelEmployees = _localization.Get("services.detail.header.employees"), + LabelBookingsThisYear = _localization.Get("services.detail.header.bookingsThisYear"), + Tags = service.Tags.Select(t => new ServiceTagViewModel { Text = t.Text, CssClass = t.CssClass }).ToList() + }; + + return View(model); + } +} + +public class ServiceDetailHeaderViewModel +{ + public required string Name { get; init; } + public required bool IsActive { get; init; } + public required string StatusText { get; init; } + public required string DurationRange { get; init; } + public required string FromPrice { get; init; } + public required string EmployeeCount { get; init; } + public required string BookingsThisYear { get; init; } + public required string LabelDuration { get; init; } + public required string LabelFromPrice { get; init; } + public required string LabelEmployees { get; init; } + public required string LabelBookingsThisYear { get; init; } + public List Tags { get; init; } = new(); +} + +public class ServiceTagViewModel +{ + public required string Text { get; init; } + public required string CssClass { get; init; } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml new file mode 100644 index 0000000..f34757b --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml @@ -0,0 +1,89 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceDetailViewViewModel + + + + + + + + + + + @Model.BackText + + + + + + @Model.SaveButtonText + + + + + + @await Component.InvokeAsync("ServiceDetailHeader", Model.ServiceKey) + + + + + @Model.TabGeneral + @Model.TabPrices + @Model.TabDuration + @Model.TabEmployees + @Model.TabAddons + @Model.TabRules + + + + + + + @await Component.InvokeAsync("ServiceDetailGeneral", Model.ServiceKey) + + + + + + + Priser +

Priser-tab kommer snart...

+
+
+
+ + + + + Varighed +

Varighed-tab kommer snart...

+
+
+
+ + + + + Medarbejdere +

Medarbejdere-tab kommer snart...

+
+
+
+ + + + + Tilvalg +

Tilvalg-tab kommer snart...

+
+
+
+ + + + + Regler +

Regler-tab kommer snart...

+
+
+
+
diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailView/ServiceDetailViewViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/ServiceDetailViewViewComponent.cs new file mode 100644 index 0000000..cf24fac --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/ServiceDetailViewViewComponent.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Services.Components; + +public class ServiceDetailViewViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public ServiceDetailViewViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string key) + { + var service = ServiceDetailCatalog.Get(key); + + var model = new ServiceDetailViewViewModel + { + ServiceKey = service.Key, + BackText = _localization.Get("services.detail.back"), + SaveButtonText = _localization.Get("services.detail.save"), + TabGeneral = _localization.Get("services.detail.tabs.general"), + TabPrices = _localization.Get("services.detail.tabs.prices"), + TabDuration = _localization.Get("services.detail.tabs.duration"), + TabEmployees = _localization.Get("services.detail.tabs.employees"), + TabAddons = _localization.Get("services.detail.tabs.addons"), + TabRules = _localization.Get("services.detail.tabs.rules") + }; + + return View(model); + } +} + +public class ServiceDetailViewViewModel +{ + public required string ServiceKey { get; init; } + public required string BackText { get; init; } + public required string SaveButtonText { get; init; } + public required string TabGeneral { get; init; } + public required string TabPrices { get; init; } + public required string TabDuration { get; init; } + public required string TabEmployees { get; init; } + public required string TabAddons { get; init; } + public required string TabRules { get; init; } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml index ea20b9b..bcfaa30 100644 --- a/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml +++ b/PlanTempus.Application/Features/Services/Components/ServiceTable/Default.cshtml @@ -5,10 +5,16 @@ - - - @Model.CreateButtonText - + + + + @Model.CreateCategoryButtonText + + + + @Model.CreateButtonText + + @@ -25,3 +31,56 @@ } + + +
+ + Opret kategori + + + + + + + + Kategorinavn + + + + + Beskrivelse + + + + + + + Synlighed + + + + Kategorien skal vises i online booking + Kategorien vil stadig være synlig her i systemet + + + Ja + Nej + + + + + Skal kun være synlig i følgende tidsperiode + + + + + + Efterlad felterne blanke for ingen tidsbegrænsning + + + + + Annuller + Gem kategori + +
diff --git a/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs index 95fd115..155b7e8 100644 --- a/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs +++ b/PlanTempus.Application/Features/Services/Components/ServiceTable/ServiceTableViewComponent.cs @@ -23,6 +23,7 @@ public class ServiceTableViewComponent : ViewComponent Key = key, SearchPlaceholder = _localization.Get("services.searchPlaceholder"), CreateButtonText = _localization.Get("services.createService"), + CreateCategoryButtonText = _localization.Get("services.createCategory"), ColumnService = _localization.Get("services.table.service"), ColumnDuration = _localization.Get("services.table.duration"), ColumnPrice = _localization.Get("services.table.price"), @@ -66,6 +67,7 @@ public class ServiceTableViewModel public required string Key { get; init; } public required string SearchPlaceholder { get; init; } public required string CreateButtonText { get; init; } + public required string CreateCategoryButtonText { get; init; } public required string ColumnService { get; init; } public required string ColumnDuration { get; init; } public required string ColumnPrice { get; init; } diff --git a/PlanTempus.Application/Features/Services/Pages/Index.cshtml b/PlanTempus.Application/Features/Services/Pages/Index.cshtml index c769c80..ba0b611 100644 --- a/PlanTempus.Application/Features/Services/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Services/Pages/Index.cshtml @@ -5,6 +5,7 @@ ViewData["Title"] = "Services"; } + @@ -21,30 +22,12 @@ @await Component.InvokeAsync("ServiceStatCard", "average-price") - - - - - Services - - - - Kategorier - - - - - - @await Component.InvokeAsync("ServiceTable", "all-services") - - - - - - - @await Component.InvokeAsync("CategoryTable", "all-categories") - - + + @await Component.InvokeAsync("ServiceTable", "all-services") + + + +@await Component.InvokeAsync("ServiceDetailView", "service-1") diff --git a/PlanTempus.Application/wwwroot/css/components.css b/PlanTempus.Application/wwwroot/css/components.css index 07d5bc9..410495a 100644 --- a/PlanTempus.Application/wwwroot/css/components.css +++ b/PlanTempus.Application/wwwroot/css/components.css @@ -557,6 +557,10 @@ swp-section-label { padding-bottom: var(--spacing-4); border-bottom: 1px solid var(--color-border); margin-bottom: var(--spacing-6); + + &.spaced { + margin-top: var(--spacing-6); + } } /* Section header - wrapper when action link is needed */ @@ -727,6 +731,281 @@ swp-form-input { resize: vertical; min-height: 80px; } + + &.date-range { + display: flex; + align-items: center; + gap: var(--spacing-3); + + input { + flex: 1; + } + + span { + color: var(--color-text-secondary); + } + } +} + +/* =========================================== + DRAWER FORM PATTERNS + Scoped styles for forms inside drawers + =========================================== */ +[data-drawer] { + /* Form row - vertical layout */ + swp-form-row { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; + + &.spaced { + margin-top: 24px; + } + } + + /* Form labels - uppercase style */ + swp-form-label { + font-size: 11px; + font-weight: 400; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + + .optional, + .auto { + font-weight: 400; + text-transform: none; + color: var(--color-text-muted); + } + } + + /* Form value - read-only display */ + swp-form-value { + font-size: 15px; + font-weight: 500; + color: var(--color-text); + } + + /* Form divider */ + swp-form-divider { + display: block; + height: 1px; + background: var(--color-border); + margin: 20px 0; + } + + /* Form hint text */ + swp-form-hint { + display: block; + font-size: 12px; + color: var(--color-text-muted); + margin: -8px 0 16px 0; + line-height: 1.4; + } + + /* Form group - gray card background */ + swp-form-group { + display: block; + padding: 16px; + background: var(--color-background-alt); + border-radius: 8px; + margin-top: 16px; + + swp-form-row:last-child { + margin-bottom: 0; + } + } + + /* Form select wrapper */ + swp-form-select { + display: block; + + select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); + background: var(--color-surface); + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--color-teal); + } + } + } + + /* Text inputs */ + input[type="text"], + input[type="date"] { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); + background: var(--color-surface); + + &::placeholder { + color: var(--color-text-muted); + } + + &:focus { + outline: none; + border-color: var(--color-teal); + } + } + + /* Textarea */ + textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 14px; + font-family: var(--font-family); + color: var(--color-text); + background: var(--color-surface); + resize: vertical; + + &::placeholder { + color: var(--color-text-muted); + } + + &:focus { + outline: none; + border-color: var(--color-teal); + } + } + + /* Date range inputs */ + swp-date-range { + display: flex; + align-items: center; + gap: 12px; + + input { + flex: 1; + + &.inactive { + opacity: 0.5; + background: var(--color-background-alt); + + &:focus { + opacity: 1; + background: var(--color-surface); + } + } + } + + span { + color: var(--color-text-secondary); + } + } + + /* Drawer header with background */ + swp-drawer-header { + background: var(--color-background-alt); + padding: 20px 24px; + } + + swp-drawer-title { + font-size: 18px; + } + + /* Drawer body padding */ + swp-drawer-body { + padding: 24px; + } + + /* Drawer footer with background */ + swp-drawer-footer { + display: flex; + gap: 12px; + padding: 20px 24px; + border-top: 1px solid var(--color-border); + background: var(--color-background-alt); + + swp-btn { + flex: 1; + } + } +} + +/* =========================================== + DRAWER DATA ROWS (checkbox + label + input) + For rate-style drawer content + =========================================== */ +[data-drawer] swp-data-table { + display: grid; + grid-template-columns: 28px 1fr 100px; +} + +[data-drawer] swp-data-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--color-teal); + } +} + +[data-drawer] swp-data-label { + font-size: var(--font-size-base); + + &.disabled { + opacity: 0.4; + } +} + +[data-drawer] swp-data-input { + display: flex; + align-items: center; + justify-self: end; + gap: 4px; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + + input { + width: 100px; + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-family: var(--font-mono); + text-align: right; + } + + &.disabled input { + opacity: 0.4; + background: var(--color-background); + } +} + +[data-drawer] swp-section-label { + margin-bottom: 12px; +} + +[data-drawer] swp-data-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--color-border); } /* =========================================== @@ -751,3 +1030,271 @@ swp-empty-state { color: var(--color-text-secondary); } } + +/* =========================================== + TAGS (Generic) + =========================================== */ +swp-tags-row { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex-wrap: wrap; +} + +swp-tag { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-3); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.3px; + border-radius: var(--radius-sm); + background: var(--color-background); + color: var(--color-text-secondary); + + &.master { + background: var(--bg-purple-strong); + color: var(--color-purple); + } + + &.senior { + background: var(--bg-blue-strong); + color: var(--color-blue); + } + + &.junior { + background: var(--bg-amber-strong); + color: #b45309; + } + + &.cert { + background: var(--bg-teal-strong); + color: var(--color-teal); + } + + &.popular { + background: var(--bg-amber-strong); + color: #b45309; + } + + &.combo { + background: var(--bg-teal-strong); + color: var(--color-teal); + } + + &.color { + background: var(--bg-purple-strong); + color: var(--color-purple); + } +} + +/* =========================================== + STATUS INDICATOR (Generic) + =========================================== */ +swp-status-indicator { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-4); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + margin-left: auto; + + &[data-active="true"] { + background: var(--bg-green-strong); + color: var(--color-green); + border: 1px solid var(--bg-green-border); + } + + &[data-active="false"] { + background: var(--bg-red-medium); + color: var(--color-red); + border: 1px solid var(--bg-red-border); + } + + .icon { + font-size: var(--font-size-base); + } +} + +/* =========================================== + FACT BOXES (Inline) + =========================================== */ +swp-fact-boxes-inline { + display: flex; + gap: var(--spacing-12); + margin-top: var(--spacing-1); + flex-wrap: wrap; +} + +swp-fact-inline { + display: flex; + align-items: baseline; + gap: var(--spacing-2); + + swp-fact-inline-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + font-family: var(--font-mono); + color: var(--color-text); + } + + swp-fact-inline-label { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + } +} + +/* =========================================== + EDIT SECTION (Grid + Subgrid) + =========================================== */ +swp-edit-section { + display: grid; + grid-template-columns: 140px 1fr; + gap: var(--spacing-4); +} + +swp-edit-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + + input { + font-size: var(--font-size-base); + padding: var(--spacing-4) var(--spacing-5); + border-radius: var(--radius-sm); + background: var(--color-background-alt); + border: 1px solid var(--color-border); + color: var(--color-text); + transition: all var(--transition-fast); + cursor: text; + + &:hover { + background: var(--color-background); + } + + &:focus { + outline: none; + background: var(--color-surface); + border-color: var(--color-teal); + } + + &[data-type="number"] { + font-family: var(--font-mono); + text-align: right; + width: 150px; + justify-self: end; + } + } +} + +swp-edit-label { + font-size: var(--font-size-md); + color: var(--color-text-secondary); +} + +swp-edit-value { + font-size: var(--font-size-base); + color: var(--color-text); + padding: var(--spacing-4) var(--spacing-5); + border-radius: var(--radius-sm); + background: var(--color-background-alt); + border: 1px solid transparent; + transition: all var(--transition-fast); + cursor: text; + + &:hover { + background: var(--color-background); + } + + &:focus { + outline: none; + background: var(--color-surface); + border-color: var(--color-teal); + } + + &.mono { + font-family: var(--font-mono); + width: 150px; + text-align: right; + justify-self: end; + } +} + +swp-edit-select { + display: block; + + select { + width: 100%; + padding: var(--spacing-2) var(--spacing-4); + font-size: var(--font-size-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + cursor: pointer; + + &:focus { + outline: none; + border-color: var(--color-teal); + } + } +} + +/* =========================================== + VIEW TRANSITIONS (List/Detail swap) + =========================================== */ +.view-fade-out { + opacity: 0; +} + +.view-fade-in { + opacity: 1; +} + +/* =========================================== + BACK LINK + =========================================== */ +swp-back-link { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + transition: color var(--transition-fast); + + &:hover { + color: var(--color-teal); + } + + i { + font-size: 16px; + } +} + +/* =========================================== + DETAIL GRID (2-column layout) + =========================================== */ +swp-detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-8); + + > div { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + } +} + +@media (max-width: 900px) { + swp-detail-grid { + grid-template-columns: 1fr; + } +} diff --git a/PlanTempus.Application/wwwroot/css/controls.css b/PlanTempus.Application/wwwroot/css/controls.css index ef9f927..af3ee2e 100644 --- a/PlanTempus.Application/wwwroot/css/controls.css +++ b/PlanTempus.Application/wwwroot/css/controls.css @@ -181,3 +181,95 @@ swp-notification-intro { color: var(--color-text-secondary); margin-bottom: var(--spacing-5); } + +/* =========================================== + SELECT DROPDOWN (Popover API) + =========================================== */ +swp-select { + position: relative; + display: inline-block; +} + +swp-select button { + display: flex; + align-items: center; + gap: var(--spacing-3); + padding: var(--spacing-2) var(--spacing-3); + font-size: var(--font-size-base); + font-family: inherit; + border-radius: var(--radius-sm); + background: var(--color-background-alt); + border: 1px solid transparent; + cursor: pointer; + transition: all 150ms ease; + min-width: 160px; + anchor-name: --select-trigger; + + &:hover { + background: var(--color-background); + } + + &:focus { + outline: none; + border-color: var(--color-teal); + } +} + +swp-select-value { + flex: 1; + text-align: left; + color: var(--color-text); +} + +swp-select button i { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + transition: transform 150ms ease; +} + +swp-select button[aria-expanded="true"] i { + transform: rotate(180deg); +} + +swp-select [popover] { + position: absolute; + position-anchor: --select-trigger; + top: anchor(bottom); + left: anchor(left); + margin: var(--spacing-1) 0 0 0; + padding: var(--spacing-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: anchor-size(width); + max-height: 280px; + overflow-y: auto; +} + +swp-select [popover]:popover-open { + display: flex; + flex-direction: column; + gap: 2px; +} + +swp-select-option { + display: flex; + align-items: center; + padding: var(--spacing-2) var(--spacing-3); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 150ms ease; + font-size: var(--font-size-sm); + color: var(--color-text); + + &:hover { + background: var(--color-background); + } + + &.selected { + background: var(--bg-teal-subtle); + font-weight: var(--font-weight-medium); + color: var(--color-teal); + } +} diff --git a/PlanTempus.Application/wwwroot/css/drawers.css b/PlanTempus.Application/wwwroot/css/drawers.css index a8c1b2e..fda035e 100644 --- a/PlanTempus.Application/wwwroot/css/drawers.css +++ b/PlanTempus.Application/wwwroot/css/drawers.css @@ -117,6 +117,9 @@ swp-drawer-body { flex: 1; overflow-y: auto; padding: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-5); } swp-drawer-divider { @@ -260,6 +263,9 @@ swp-toggle-switch input:checked + swp-toggle-track::before { DRAWER FOOTER =========================================== */ swp-drawer-footer { + display: flex; + justify-content: flex-end; + gap: var(--spacing-3); padding: var(--spacing-4) var(--spacing-5); border-top: 1px solid var(--color-border); flex-shrink: 0; diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css index 7394d82..b2df4e5 100644 --- a/PlanTempus.Application/wwwroot/css/employees.css +++ b/PlanTempus.Application/wwwroot/css/employees.css @@ -4,14 +4,15 @@ * Employees-specific styling only. * Reuses: swp-stat-card (stats.css), swp-stats-row (stats.css), swp-tab-bar (tabs.css), * swp-btn, swp-status-badge, swp-icon-btn, swp-card, swp-section-label, - * swp-add-button (components.css), + * swp-add-button, swp-tags-row, swp-tag, swp-status-indicator, + * swp-fact-boxes-inline, swp-fact-inline, swp-edit-section/row/label/value/select, + * swp-detail-grid, swp-back-link (components.css), * swp-row-toggle (cash.css), * swp-sticky-header, swp-header-content (page.css), * swp-toggle-slider, swp-checkbox-list (controls.css) * * Creates: swp-employee-table, swp-employee-row, swp-user-info, - * swp-employee-avatar-large, swp-employee-detail-header, - * swp-fact-inline, swp-edit-section/row/label/value/select, swp-detail-grid, + * swp-employee-avatar-large, swp-employee-detail-header, swp-employee-status, * swp-salary-table, swp-document-list/item/info/name/meta/actions, * swp-subsection/title, swp-simple-list/item/text */ @@ -262,51 +263,7 @@ swp-employee-name { } /* =========================================== - TAGS - =========================================== */ -swp-tags-row { - display: flex; - align-items: center; - gap: var(--spacing-2); - flex-wrap: wrap; -} - -swp-tag { - display: inline-flex; - align-items: center; - gap: var(--spacing-1); - padding: var(--spacing-1) var(--spacing-3); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.3px; - border-radius: var(--radius-sm); - background: var(--color-background); - color: var(--color-text-secondary); - - &.master { - background: var(--bg-purple-strong); - color: var(--color-purple); - } - - &.senior { - background: var(--bg-blue-strong); - color: var(--color-blue); - } - - &.junior { - background: var(--bg-amber-strong); - color: #b45309; - } - - &.cert { - background: var(--bg-teal-strong); - color: var(--color-teal); - } -} - -/* =========================================== - EMPLOYEE STATUS INDICATOR + EMPLOYEE STATUS (alias for swp-status-indicator) =========================================== */ swp-employee-status { display: inline-flex; @@ -338,131 +295,7 @@ swp-employee-status { } /* =========================================== - FACT BOXES (Inline) - =========================================== */ -swp-fact-boxes-inline { - display: flex; - gap: var(--spacing-12); - margin-top: var(--spacing-1); - flex-wrap: wrap; -} - -swp-fact-inline { - display: flex; - align-items: baseline; - gap: var(--spacing-2); - - swp-fact-inline-value { - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - font-family: var(--font-mono); - color: var(--color-text); - } - - swp-fact-inline-label { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - } -} - -/* =========================================== - EDIT SECTION (Grid + Subgrid) - =========================================== */ -swp-edit-section { - display: grid; - grid-template-columns: 140px 1fr; - gap: var(--spacing-4); -} - -swp-edit-row { - display: grid; - grid-column: 1 / -1; - grid-template-columns: subgrid; - align-items: center; - - input { - font-size: var(--font-size-base); - padding: var(--spacing-4) var(--spacing-5); - border-radius: var(--radius-sm); - background: var(--color-background-alt); - border: 1px solid var(--color-border); - color: var(--color-text); - transition: all var(--transition-fast); - cursor: text; - - &:hover { - background: var(--color-background); - } - - &:focus { - outline: none; - background: var(--color-surface); - border-color: var(--color-teal); - } - - &[data-type="number"] { - font-family: var(--font-mono); - text-align: right; - width: 150px; - justify-self: end; - } - } -} - -swp-edit-label { - font-size: var(--font-size-md); - color: var(--color-text-secondary); -} - -swp-edit-value { - font-size: var(--font-size-base); - color: var(--color-text); - padding: var(--spacing-4) var(--spacing-5); - border-radius: var(--radius-sm); - background: var(--color-background-alt); - border: 1px solid transparent; - transition: all var(--transition-fast); - cursor: text; - - &:hover { - background: var(--color-background); - } - - &:focus { - outline: none; - background: var(--color-surface); - border-color: var(--color-teal); - } - - &.mono { - font-family: var(--font-mono); - width: 150px; - text-align: right; - justify-self: end; - } -} - -swp-edit-select { - display: block; - - select { - width: 100%; - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-base); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - cursor: pointer; - - &:focus { - outline: none; - border-color: var(--color-teal); - } - } -} - -/* =========================================== - VIEW CONTAINERS (List/Detail swap) + VIEW CONTAINERS (Employee-specific) =========================================== */ swp-employees-list-view, swp-employee-detail-view { @@ -478,48 +311,6 @@ swp-employee-detail-view { min-height: calc(100vh - 60px); } -/* View transition states */ -.view-fade-out { - opacity: 0; -} - -.view-fade-in { - opacity: 1; -} - -swp-back-link { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - color: var(--color-text-secondary); - font-size: var(--font-size-sm); - cursor: pointer; - transition: color var(--transition-fast); - - &:hover { - color: var(--color-teal); - } - - i { - font-size: 16px; - } -} - -/* =========================================== - DETAIL GRID (2-column layout) - =========================================== */ -swp-detail-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--spacing-8); - - > div { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - } -} - /* =========================================== SCHEDULE GRID (Hours tab) =========================================== */ @@ -756,78 +547,6 @@ swp-simple-item { } } -/* =========================================== - RATES DRAWER CONTENT - =========================================== */ -.rates-content { - swp-data-table { - display: grid; - grid-template-columns: 28px 1fr 100px; - } - - swp-data-row { - display: grid; - grid-column: 1 / -1; - grid-template-columns: subgrid; - align-items: center; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--color-border); - - &:last-child { - border-bottom: none; - } - - input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--color-teal); - } - } - - swp-data-label { - font-size: var(--font-size-base); - - &.disabled { - opacity: 0.4; - } - } - - swp-data-input { - display: flex; - align-items: center; - justify-self: end; - gap: 4px; - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - - input { - width: 100px; - padding: 6px 8px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - font-size: var(--font-size-sm); - font-family: var(--font-mono); - text-align: right; - } - - &.disabled input { - opacity: 0.4; - background: var(--color-background); - } - } - - swp-section-label { - margin-bottom: 12px; - } - - swp-data-section { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--color-border); - } -} - /* =========================================== FOCUS HIGHLIGHT (double-click to edit) =========================================== */ @@ -917,6 +636,7 @@ swp-schedule-table { overflow: hidden; border: 1px solid var(--color-border); background: var(--color-surface); + } swp-schedule-cell { @@ -1263,143 +983,6 @@ swp-employee-display { } } -/* =========================================== - SCHEDULE DRAWER (matches POC exactly) - =========================================== */ - -/* Drawer header with background */ -#schedule-drawer swp-drawer-header { - background: var(--color-background-alt); - padding: 20px 24px; -} - -#schedule-drawer swp-drawer-title { - font-size: 18px; -} - -/* Drawer body/content */ -#schedule-drawer swp-drawer-body { - padding: 24px; -} - -/* Form row layout */ -#schedule-drawer swp-form-row { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 16px; -} - -/* Form labels - uppercase style from POC */ -#schedule-drawer swp-form-label { - font-size: 11px; - font-weight: 400; - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - - .optional, - .auto { - font-weight: 400; - text-transform: none; - color: var(--color-text-muted); - } -} - -/* Form value (read-only display) */ -#schedule-drawer swp-form-value { - font-size: 15px; - font-weight: 500; - color: var(--color-text); -} - -/* Form divider */ -#schedule-drawer swp-form-divider { - display: block; - height: 1px; - background: var(--color-border); - margin: 20px 0; -} - -/* Form hint text */ -#schedule-drawer swp-form-hint { - display: block; - font-size: 12px; - color: var(--color-text-muted); - margin: -8px 0 16px 0; - line-height: 1.4; -} - -/* Form group - gray card background from POC */ -#schedule-drawer swp-form-group { - display: block; - padding: 16px; - background: var(--color-background-alt); - border-radius: 8px; - margin-top: 16px; - - swp-form-row:last-child { - margin-bottom: 0; - } -} - -/* Form select wrapper */ -#schedule-drawer swp-form-select { - display: block; - - select { - width: 100%; - padding: 10px 12px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 14px; - font-family: var(--font-family); - color: var(--color-text); - background: var(--color-surface); - cursor: pointer; - - &:focus { - outline: none; - border-color: var(--color-teal); - } - } -} - -/* Text inputs in drawer */ -#schedule-drawer input[type="text"], -#schedule-drawer input[type="date"] { - width: 100%; - padding: 10px 12px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 14px; - font-family: var(--font-family); - color: var(--color-text); - background: var(--color-surface); - - &::placeholder { - color: var(--color-text-muted); - } - - &:focus { - outline: none; - border-color: var(--color-teal); - } -} - -/* Drawer footer with background */ -#schedule-drawer swp-drawer-footer { - display: flex; - gap: 12px; - padding: 20px 24px; - border-top: 1px solid var(--color-border); - background: var(--color-background-alt); - - swp-btn { - flex: 1; - } -} - /* =========================================== RESPONSIVE =========================================== */ @@ -1409,11 +992,6 @@ swp-employee-display { } } -@media (max-width: 900px) { - swp-detail-grid { - grid-template-columns: 1fr; - } -} @media (max-width: 768px) { swp-employee-table { diff --git a/PlanTempus.Application/wwwroot/css/services.css b/PlanTempus.Application/wwwroot/css/services.css index c7f37ee..f286a49 100644 --- a/PlanTempus.Application/wwwroot/css/services.css +++ b/PlanTempus.Application/wwwroot/css/services.css @@ -1,10 +1,18 @@ /** - * Services List Styles + * Services Styles * * Feature-specific styling only. - * Reuses: swp-stat-card (stats.css), swp-data-table (components.css), - * swp-sticky-header (page.css), swp-row-toggle (employees.css), - * swp-btn (components.css) + * Reuses: + * - swp-stat-card (stats.css) + * - swp-data-table (components.css) + * - swp-sticky-header, swp-page-container (page.css) + * - swp-row-toggle (components.css) + * - swp-btn (components.css) + * - swp-detail-grid, swp-edit-section (components.css) + * - swp-tags-row, swp-tag (components.css) + * - swp-status-indicator (components.css) + * - swp-fact-boxes-inline (components.css) + * - view-fade-out (components.css) */ /* =========================================== @@ -18,6 +26,11 @@ swp-services-header { margin-bottom: var(--section-gap); } +swp-services-header swp-btn-group { + display: flex; + gap: var(--spacing-3); +} + swp-search-input { display: flex; align-items: center; @@ -170,6 +183,29 @@ swp-category-row[data-expanded="false"] swp-category-toggle i { margin-left: var(--spacing-2); } +/* Category edit icon */ +swp-category-edit { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: var(--color-text-tertiary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s ease; + opacity: 0; +} + +swp-category-row:hover swp-category-edit { + opacity: 1; +} + +swp-category-edit:hover { + color: var(--color-blue); + background: var(--bg-blue-medium); +} + /* =========================================== SERVICE ROW (indented under category) =========================================== */ @@ -191,52 +227,48 @@ swp-card.services-list swp-data-table-row:hover swp-row-toggle { } /* =========================================== - CATEGORIES LIST TABLE + VIEW CONTAINERS (List / Detail) =========================================== */ -swp-card.categories-list { - padding: 0; - overflow: hidden; +swp-services-list-view, +swp-service-detail-view { + transition: opacity 100ms ease; } -/* Table columns: Category(1fr) | ServiceCount(120px) | Caret(40px) */ -swp-card.categories-list swp-data-table { - grid-template-columns: 1fr 120px 40px; +swp-service-detail-view { + display: none; } -swp-card.categories-list swp-data-table-header, -swp-card.categories-list swp-data-table-row { - padding: 0 var(--spacing-10); +/* =========================================== + SERVICE DETAIL HEADER + =========================================== */ + +swp-service-detail-header { + display: flex; + gap: var(--spacing-6); + padding: var(--spacing-6) 0; } -swp-card.categories-list swp-data-table-header swp-data-table-cell { - padding-top: var(--spacing-5); - padding-bottom: var(--spacing-5); +swp-service-info { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-2); } -swp-card.categories-list swp-data-table-row { - cursor: pointer; +swp-service-name-row { + display: flex; + align-items: center; + gap: var(--spacing-4); } -swp-card.categories-list swp-data-table-cell { - padding: var(--spacing-5) 0; +swp-service-name { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text); + outline: none; - &:last-child { - display: flex; - align-items: center; - justify-content: center; + &:focus { + border-bottom: 1px dashed var(--color-teal); } } - -/* Mono font for service count */ -swp-card.categories-list swp-data-table-row swp-data-table-cell:nth-child(2) { - font-family: var(--font-mono); -} - -swp-card.categories-list swp-data-table-row:hover { - background: var(--color-background-hover); -} - -swp-card.categories-list swp-data-table-row:hover swp-row-toggle { - color: var(--color-teal); -} diff --git a/PlanTempus.Application/wwwroot/ts/modules/controls.ts b/PlanTempus.Application/wwwroot/ts/modules/controls.ts index d9366fe..03eadc3 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/controls.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/controls.ts @@ -3,6 +3,7 @@ * * Handles generic UI controls functionality: * - Toggle sliders (Ja/Nej switches) + * - Select dropdowns (Popover API) */ /** @@ -11,6 +12,7 @@ export class ControlsController { constructor() { this.initToggleSliders(); + this.initSelectDropdowns(); } /** @@ -33,4 +35,54 @@ export class ControlsController { }); }); } + + /** + * Initialize all select dropdowns on the page + * Uses Popover API for dropdown behavior + */ + private initSelectDropdowns(): void { + document.querySelectorAll('swp-select').forEach(select => { + const trigger = select.querySelector('button'); + const popover = select.querySelector('[popover]') as HTMLElement | null; + const options = select.querySelectorAll('swp-select-option'); + + if (!trigger || !popover) return; + + // Update aria-expanded on toggle + popover.addEventListener('toggle', (e: Event) => { + const event = e as ToggleEvent; + trigger.setAttribute('aria-expanded', event.newState === 'open' ? 'true' : 'false'); + }); + + // Handle option selection + options.forEach(option => { + option.addEventListener('click', () => { + const value = (option as HTMLElement).dataset.value; + const label = option.textContent?.trim() || ''; + + // Update selected state + options.forEach(o => o.classList.remove('selected')); + option.classList.add('selected'); + + // Update trigger display + const valueEl = trigger.querySelector('swp-select-value'); + if (valueEl) { + valueEl.textContent = label; + } + + // Update data-value on select element + (select as HTMLElement).dataset.value = value; + + // Close popover + popover.hidePopover(); + + // Dispatch custom event + select.dispatchEvent(new CustomEvent('change', { + bubbles: true, + detail: { value, label } + })); + }); + }); + }); + } } diff --git a/PlanTempus.Application/wwwroot/ts/modules/services.ts b/PlanTempus.Application/wwwroot/ts/modules/services.ts index 57048f0..d6e637c 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/services.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/services.ts @@ -1,6 +1,12 @@ /** * Services Controller - * Handles category collapse/expand animations and fuzzy search + * + * Handles: + * - Category collapse/expand animations + * - Fuzzy search + * - Content swap between list view and detail view + * - Tab switching within detail view + * - History API for browser back/forward navigation */ import Fuse from 'fuse.js'; @@ -15,14 +21,27 @@ interface ServiceItem { export class ServicesController { private fuse: Fuse | null = null; private services: ServiceItem[] = []; + private listView: HTMLElement | null = null; + private detailView: HTMLElement | null = null; constructor() { + this.listView = document.getElementById('services-list-view'); + this.detailView = document.getElementById('service-detail-view'); + + // Only initialize if we're on the services page + if (!this.listView) return; + this.init(); } private init(): void { this.setupCategoryToggle(); this.setupSearch(); + this.setupDetailTabs(); + this.setupChevronNavigation(); + this.setupBackNavigation(); + this.setupHistoryNavigation(); + this.restoreStateFromUrl(); } private setupSearch(): void { @@ -225,4 +244,180 @@ export class ServicesController { }); }, 200); } + + /** + * Setup popstate listener for browser back/forward + */ + private setupHistoryNavigation(): void { + window.addEventListener('popstate', (e: PopStateEvent) => { + if (e.state?.serviceKey) { + this.showDetailViewInternal(e.state.serviceKey); + } else { + this.showListViewInternal(); + } + }); + } + + /** + * Restore view state from URL on page load + */ + private restoreStateFromUrl(): void { + const hash = window.location.hash; + if (hash.startsWith('#service-')) { + const serviceKey = hash.substring(1); // Remove # + this.showDetailViewInternal(serviceKey); + } + } + + /** + * Setup tab switching for the detail view + */ + private setupDetailTabs(): void { + if (!this.detailView) return; + + const tabs = this.detailView.querySelectorAll('swp-tab-bar > swp-tab[data-tab]'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const targetTab = tab.dataset.tab; + if (targetTab) { + this.switchTab(this.detailView!, targetTab); + } + }); + }); + } + + /** + * Switch to a specific tab within a container + */ + private switchTab(container: HTMLElement, targetTab: string): void { + const tabs = container.querySelectorAll('swp-tab-bar > swp-tab[data-tab]'); + const contents = container.querySelectorAll('swp-tab-content[data-tab]'); + + tabs.forEach(t => { + t.classList.toggle('active', t.dataset.tab === targetTab); + }); + + contents.forEach(content => { + content.classList.toggle('active', content.dataset.tab === targetTab); + }); + } + + /** + * Setup row click to show detail view + * Ignores clicks on category rows + */ + private setupChevronNavigation(): void { + document.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + + // Ignore clicks on category rows + if (target.closest('swp-category-row')) { + return; + } + + const row = target.closest('swp-data-table-row[data-service-detail]'); + + if (row) { + const serviceKey = row.dataset.serviceDetail; + if (serviceKey) { + this.showDetailView(serviceKey); + } + } + }); + } + + /** + * Setup back button to return to list view + */ + private setupBackNavigation(): void { + document.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + const backLink = target.closest('[data-service-back]'); + + if (backLink) { + this.showListView(); + } + }); + } + + /** + * Show the detail view and hide list view (with history push) + */ + private showDetailView(serviceKey: string): void { + // Push state to history + history.pushState( + { serviceKey }, + '', + `#${serviceKey}` + ); + this.showDetailViewInternal(serviceKey); + } + + /** + * Show detail view without modifying history (for popstate) + */ + private showDetailViewInternal(serviceKey: string): void { + if (this.listView && this.detailView) { + // Fade out list view + this.listView.classList.add('view-fade-out'); + + // After fade, switch views + setTimeout(() => { + this.listView!.style.display = 'none'; + this.listView!.classList.remove('view-fade-out'); + + // Show detail view with fade in + this.detailView!.style.display = 'block'; + this.detailView!.classList.add('view-fade-out'); + this.detailView!.dataset.service = serviceKey; + + // Reset to first tab + this.switchTab(this.detailView!, 'general'); + + // Trigger fade in + requestAnimationFrame(() => { + this.detailView!.classList.remove('view-fade-out'); + }); + }, 100); + } + } + + /** + * Show the list view and hide detail view (with history push) + */ + private showListView(): void { + // Push state to history (clear hash) + history.pushState( + {}, + '', + window.location.pathname + ); + this.showListViewInternal(); + } + + /** + * Show list view without modifying history (for popstate) + */ + private showListViewInternal(): void { + if (this.listView && this.detailView) { + // Fade out detail view + this.detailView.classList.add('view-fade-out'); + + // After fade, switch views + setTimeout(() => { + this.detailView!.style.display = 'none'; + this.detailView!.classList.remove('view-fade-out'); + + // Show list view with fade in + this.listView!.style.display = 'block'; + this.listView!.classList.add('view-fade-out'); + + // Trigger fade in + requestAnimationFrame(() => { + this.listView!.classList.remove('view-fade-out'); + }); + }, 100); + } + } }