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); + } + } }