diff --git a/PlanTempus.Application/Features/Account/Components/PaymentMethod/Default.cshtml b/PlanTempus.Application/Features/Account/Components/PaymentMethod/Default.cshtml index 1336b2d..db965c6 100644 --- a/PlanTempus.Application/Features/Account/Components/PaymentMethod/Default.cshtml +++ b/PlanTempus.Application/Features/Account/Components/PaymentMethod/Default.cshtml @@ -1,4 +1,4 @@ - + @@ -37,4 +37,4 @@ Skift til årlig betaling (spar 15%) - + diff --git a/PlanTempus.Application/Features/CashRegister/Pages/Index.cshtml b/PlanTempus.Application/Features/CashRegister/Pages/Index.cshtml index 37b4bb2..d1e930d 100644 --- a/PlanTempus.Application/Features/CashRegister/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/CashRegister/Pages/Index.cshtml @@ -10,44 +10,44 @@ - - - 12 - Afstemninger i periode - - - 186.450 kr - Total omsætning - - - 42.340 kr - Kontantsalg - - - -75 kr - Samlet difference - - + + + 12 + Afstemninger i periode + + + 186.450 kr + Total omsætning + + + 42.340 kr + Kontantsalg + + + -75 kr + Samlet difference + + - - - 47 - Transaktioner i dag - - - 18.865 kr - Omsætning i dag - - - 29. dec 17:45 - Sidste afstemning - - - Anna J. - Åbnede kassen 29. dec 09:05 - - + + + 47 + Transaktioner i dag + + + 18.865 kr + Omsætning i dag + + + 29. dec 17:45 + Sidste afstemning + + + Anna J. + Åbnede kassen 29. dec 09:05 + + diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json index 7cbee5b..eea1fb3 100644 --- a/PlanTempus.Application/Features/Localization/Translations/da.json +++ b/PlanTempus.Application/Features/Localization/Translations/da.json @@ -322,6 +322,16 @@ "showPrice": "Vis pris", "showDuration": "Vis varighed" }, + "employees": { + "employeesForService": "Medarbejdere der udfører denne service", + "selectAll": "Vælg alle / Fravælg alle", + "availability": "Tilgængelighed", + "duration": "Varighed" + }, + "addons": { + "addonsForService": "Tilvalg til denne service", + "addExistingAddon": "Tilføj eksisterende tilvalg" + }, "header": { "duration": "min varighed", "fromPrice": "fra pris", diff --git a/PlanTempus.Application/Features/Localization/Translations/en.json b/PlanTempus.Application/Features/Localization/Translations/en.json index 3a2e47a..a59a76d 100644 --- a/PlanTempus.Application/Features/Localization/Translations/en.json +++ b/PlanTempus.Application/Features/Localization/Translations/en.json @@ -322,6 +322,16 @@ "showPrice": "Show price", "showDuration": "Show duration" }, + "employees": { + "employeesForService": "Employees performing this service", + "selectAll": "Select all / Deselect all", + "availability": "Availability", + "duration": "Duration" + }, + "addons": { + "addonsForService": "Add-ons for this service", + "addExistingAddon": "Add existing add-on" + }, "header": { "duration": "min duration", "fromPrice": "from price", diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/Default.cshtml new file mode 100644 index 0000000..f9ac741 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/Default.cshtml @@ -0,0 +1,29 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceDetailAddonsViewModel + + + @Model.LabelAddonsForService + + + @foreach (var addon in Model.Addons) + { + + + + + + @addon.Name + + @addon.Price + @addon.Duration + @addon.Type + + + + } + + + + + @Model.LabelAddExistingAddon + + diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/ServiceDetailAddonsViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/ServiceDetailAddonsViewComponent.cs new file mode 100644 index 0000000..3336795 --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailAddons/ServiceDetailAddonsViewComponent.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Services.Components; + +public class ServiceDetailAddonsViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public ServiceDetailAddonsViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string key) + { + var service = ServiceDetailCatalog.Get(key); + + var model = new ServiceDetailAddonsViewModel + { + // Data + Addons = service.Addons, + + // Labels + LabelAddonsForService = _localization.Get("services.detail.addons.addonsForService"), + LabelAddExistingAddon = _localization.Get("services.detail.addons.addExistingAddon") + }; + + return View(model); + } +} + +public class ServiceDetailAddonsViewModel +{ + // Data + public required List Addons { get; init; } + + // Labels + public required string LabelAddonsForService { get; init; } + public required string LabelAddExistingAddon { get; init; } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs index e0b0156..6ec9d29 100644 --- a/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailCatalog.cs @@ -62,7 +62,35 @@ public static class ServiceDetailCatalog ShowInOnlineBookingRules = true, AllowEmployeeSelection = true, ShowPrice = true, - ShowDuration = true + ShowDuration = true, + // Medarbejdere + Employees = new() + { + new("emp-1", "Anna Sørensen", "AS", "Master Stylist", "master", true, "Standard"), + new("emp-2", "Mette Jensen", "MJ", "Senior Stylist", "senior", true, "+15 min"), + new("emp-3", "Louise Nielsen", "LN", "Senior Stylist", "senior", true, "Standard"), + new("emp-4", "Katrine Pedersen", "KP", "Stylist", "junior", true, "+15 min"), + new("emp-5", "Sofie Andersen", "SA", "Junior Stylist", "junior", false, "—", "Ikke certificeret") + }, + Availability = new() + { + new("Mandag", true, "08:00", "18:00"), + new("Tirsdag", true, "08:00", "18:00"), + new("Onsdag", true, "08:00", "18:00"), + new("Torsdag", true, "08:00", "12:00"), + new("Fredag", true, "08:00", "18:00"), + new("Lørdag", false, "08:00", "18:00"), + new("Søndag", false, "08:00", "18:00") + }, + // Tilvalg + Addons = new() + { + new("addon-1", "Olaplex Behandling", "+250 kr", "+15 min", "Valgfri", true), + new("addon-2", "Kerastase Hårkur", "+195 kr", "+10 min", "Valgfri", true), + new("addon-3", "Styling / Curls", "+150 kr", "+20 min", "Valgfri", true), + new("addon-4", "Hovedbundsmassage", "+75 kr", "+5 min", "Valgfri", true), + new("addon-5", "Patch Test", "Gratis", "48t før", "Påkrævet (nye)", false, true) + } }, ["service-2"] = new ServiceDetailRecord { @@ -233,6 +261,13 @@ public record ServiceDetailRecord public bool AllowEmployeeSelection { get; init; } = true; public bool ShowPrice { get; init; } = true; public bool ShowDuration { get; init; } = true; + + // Medarbejdere tab + public List Employees { get; init; } = new(); + public List Availability { get; init; } = new(); + + // Tilvalg tab + public List Addons { get; init; } = new(); } public enum PriceMode { Simple, Matrix } @@ -242,3 +277,28 @@ public record PriceMatrixRow(string Level, string ShortHair, string MediumHair, public record DurationVariant(string Name, int Minutes); public record ServiceTag(string Text, string CssClass); + +public record ServiceEmployee( + string Id, + string Name, + string Initials, + string Level, + string LevelCssClass, + bool Selected, + string OverrideValue, + string? Warning = null); + +public record ServiceAddon( + string Id, + string Name, + string Price, + string Duration, + string Type, + bool Selected, + bool Required = false); + +public record ServiceAvailability( + string Day, + bool Enabled, + string StartTime, + string EndTime); diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/Default.cshtml new file mode 100644 index 0000000..915c0ee --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/Default.cshtml @@ -0,0 +1,66 @@ +@model PlanTempus.Application.Features.Services.Components.ServiceDetailEmployeesViewModel + + +
+ + @Model.LabelEmployeesForService + + + @foreach (var employee in Model.Employees) + { + + + + + @employee.Initials + + + @employee.Name + @if (employee.Warning != null) + { + @employee.Warning + } + + @employee.Level + + + @Model.LabelDuration: @employee.OverrideValue + + + } + + + @Model.LabelSelectAll + +
+ +
+ + @Model.LabelAvailability + + + @foreach (var day in Model.Availability) + { + + @day.Day + + @Model.ToggleYes + @Model.ToggleNo + + + + + + + + + + @day.StartTime – @day.EndTime + + + + } + + +
+
diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/ServiceDetailEmployeesViewComponent.cs b/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/ServiceDetailEmployeesViewComponent.cs new file mode 100644 index 0000000..c9e76fd --- /dev/null +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailEmployees/ServiceDetailEmployeesViewComponent.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Services.Components; + +public class ServiceDetailEmployeesViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public ServiceDetailEmployeesViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string key) + { + var service = ServiceDetailCatalog.Get(key); + + var model = new ServiceDetailEmployeesViewModel + { + // Data + Employees = service.Employees, + Availability = service.Availability, + + // Labels + LabelEmployeesForService = _localization.Get("services.detail.employees.employeesForService"), + LabelSelectAll = _localization.Get("services.detail.employees.selectAll"), + LabelAvailability = _localization.Get("services.detail.employees.availability"), + LabelDuration = _localization.Get("services.detail.employees.duration"), + ToggleYes = _localization.Get("common.yes"), + ToggleNo = _localization.Get("common.no") + }; + + return View(model); + } +} + +public class ServiceDetailEmployeesViewModel +{ + // Data + public required List Employees { get; init; } + public required List Availability { get; init; } + + // Labels + public required string LabelEmployeesForService { get; init; } + public required string LabelSelectAll { get; init; } + public required string LabelAvailability { get; init; } + public required string LabelDuration { get; init; } + + // Toggle labels + public required string ToggleYes { get; init; } + public required string ToggleNo { get; init; } +} diff --git a/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml index 51110ea..d724cc5 100644 --- a/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml +++ b/PlanTempus.Application/Features/Services/Components/ServiceDetailView/Default.cshtml @@ -56,19 +56,13 @@ - - Medarbejdere -

Medarbejdere-tab kommer snart...

-
+ @await Component.InvokeAsync("ServiceDetailEmployees", Model.ServiceKey)
- - Tilvalg -

Tilvalg-tab kommer snart...

-
+ @await Component.InvokeAsync("ServiceDetailAddons", Model.ServiceKey)
diff --git a/PlanTempus.Application/wwwroot/css/account.css b/PlanTempus.Application/wwwroot/css/account.css index 7c587f6..9fb14bd 100644 --- a/PlanTempus.Application/wwwroot/css/account.css +++ b/PlanTempus.Application/wwwroot/css/account.css @@ -4,7 +4,11 @@ * For logged-in users to manage their subscription plan, * payment method, and view invoice history. * - * Reuses: swp-btn, swp-plan-card, swp-plan-badge, swp-plan-action (components.css) + * Reuses: + * - swp-card (components.css) + * - swp-btn (components.css) + * - swp-plan-card, swp-plan-badge, swp-plan-action (components.css) + * - swp-data-table (components.css) */ /* =========================================== @@ -65,20 +69,12 @@ swp-billing-grid { } /* =========================================== - PAYMENT CARD + PAYMENT CARD (extends swp-card) =========================================== */ -swp-payment-card { +swp-card.payment { display: flex; flex-direction: column; gap: var(--card-padding); - background: var(--color-surface); - border-radius: var(--radius-lg); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - padding: var(--card-padding); - - @media (max-width: 768px) { - padding: var(--spacing-5); - } } swp-payment-method { diff --git a/PlanTempus.Application/wwwroot/css/cash.css b/PlanTempus.Application/wwwroot/css/cash.css index e75a52f..b425c71 100644 --- a/PlanTempus.Application/wwwroot/css/cash.css +++ b/PlanTempus.Application/wwwroot/css/cash.css @@ -1,9 +1,11 @@ /** * Cash Register - Page Styling * - * Filter bar, stats, table, forms, and difference box - * Reuses: swp-sticky-header, swp-header-content (page.css) - * Reuses: swp-btn, swp-status-badge, swp-card-footer (components.css) + * Filter bar, table, forms, and difference box + * Reuses: + * - swp-sticky-header, swp-header-content (page.css) + * - swp-stats-row, swp-stat-card (stats.css) + * - swp-btn, swp-status-badge, swp-card-footer (components.css) */ /* =========================================== @@ -17,6 +19,7 @@ swp-filter-bar { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); + margin-top: var(--spacing-10); margin-bottom: var(--spacing-10); flex-wrap: wrap; @@ -51,58 +54,6 @@ swp-filter-spacer { flex: 1; } -/* =========================================== - KASSE STATS BAR - =========================================== */ -swp-cash-stats { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--spacing-8); - max-width: var(--page-max-width); - margin: 0 auto; - - &:not(.active) { - display: none; - } -} - -swp-cash-stat { - background: var(--color-background-alt); - border-radius: var(--radius-lg); - padding: var(--spacing-6) var(--spacing-8); - - swp-cash-stat-value { - display: block; - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-semibold); - font-family: var(--font-mono); - color: var(--color-text); - } - - swp-cash-stat-label { - display: block; - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - margin-top: var(--spacing-2); - } - - &.highlight swp-cash-stat-value { - color: var(--color-teal); - } - - &.warning swp-cash-stat-value { - color: var(--color-amber); - } - - &.negative swp-cash-stat-value { - color: var(--color-red); - } - - &.user swp-cash-stat-value { - color: var(--color-blue); - } -} - /* =========================================== ACTION BAR (Table Header) =========================================== */ @@ -340,7 +291,6 @@ swp-cash-grid { swp-cash-column { display: grid; - gap: var(--spacing-10); align-content: start; } @@ -729,10 +679,6 @@ swp-system-note { RESPONSIVE =========================================== */ @media (max-width: 1000px) { - swp-cash-stats { - grid-template-columns: repeat(2, 1fr); - } - swp-cash-table { grid-template-columns: 50px 80px 1fr 100px 110px 120px 40px; } diff --git a/PlanTempus.Application/wwwroot/css/components.css b/PlanTempus.Application/wwwroot/css/components.css index 1aa6172..62c7b89 100644 --- a/PlanTempus.Application/wwwroot/css/components.css +++ b/PlanTempus.Application/wwwroot/css/components.css @@ -544,6 +544,7 @@ swp-card { border: 1px solid var(--color-border); border-radius: var(--border-radius-lg); padding: var(--card-padding); + margin-top: var(--section-gap); } /* Section label - simple card header */ @@ -1295,7 +1296,6 @@ swp-detail-grid { > div { display: flex; flex-direction: column; - gap: var(--spacing-8); } } @@ -1304,3 +1304,142 @@ swp-detail-grid { grid-template-columns: 1fr; } } + +/* =========================================== + SELECTABLE LIST (Checkbox + Info pattern) + Used for employee lists, addon lists, etc. + =========================================== */ +swp-selectable-list { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +swp-selectable-item { + display: grid; + grid-template-columns: 24px auto 1fr auto; + gap: var(--spacing-4); + align-items: center; + padding: var(--spacing-4); + background: var(--color-background-alt); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { + background: var(--color-background-hover); + } + + &.selected { + background: var(--bg-teal-subtle); + } + + &.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Variant without avatar (for addons) */ + &.no-avatar { + grid-template-columns: 24px 1fr auto; + } +} + +swp-selectable-checkbox { + width: 20px; + height: 20px; + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); + + svg { + width: 14px; + height: 14px; + fill: white; + opacity: 0; + transition: opacity var(--transition-fast); + } + + .selected & { + background: var(--color-teal); + border-color: var(--color-teal); + + svg { + opacity: 1; + } + } +} + +swp-selectable-info { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + min-width: 0; +} + +swp-selectable-name { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +swp-selectable-warning { + font-size: var(--font-size-xs); + color: var(--color-amber); + font-weight: var(--font-weight-normal); +} + +swp-selectable-meta { + display: flex; + align-items: center; + gap: var(--spacing-3); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + + span { + font-family: var(--font-mono); + } +} + +swp-selectable-type { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + + &.required { + color: var(--color-amber); + font-weight: var(--font-weight-medium); + } +} + +swp-selectable-override { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + text-align: right; +} + +swp-selectable-override-value { + font-family: var(--font-mono); + color: var(--color-text); +} + +/* See all / Select all link */ +swp-see-all { + display: block; + margin-top: var(--spacing-4); + padding-top: var(--spacing-4); + border-top: 1px solid var(--color-border); + font-size: var(--font-size-sm); + color: var(--color-teal); + cursor: pointer; + text-align: center; + + &:hover { + text-decoration: underline; + } +} diff --git a/PlanTempus.Application/wwwroot/css/controls.css b/PlanTempus.Application/wwwroot/css/controls.css index b146e3a..dee789b 100644 --- a/PlanTempus.Application/wwwroot/css/controls.css +++ b/PlanTempus.Application/wwwroot/css/controls.css @@ -291,3 +291,105 @@ swp-color-dot { flex-shrink: 0; background: var(--b-primary); } + +/* =========================================== + TIME RANGE SLIDER (Dual-handle range input) + =========================================== */ +swp-time-range { + display: flex; + align-items: center; + gap: 12px; +} + +swp-time-range-slider { + position: relative; + flex: 1; + height: 20px; + display: flex; + align-items: center; +} + +swp-time-range-track { + position: absolute; + width: 100%; + height: 4px; + background: var(--color-border); + border-radius: 2px; +} + +swp-time-range-fill { + position: absolute; + height: 4px; + background: var(--color-teal); + border-radius: 2px; + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +swp-time-range-slider input[type="range"] { + position: absolute; + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: transparent; + pointer-events: none; + margin: 0; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: var(--color-teal); + border: 2px solid white; + border-radius: 50%; + cursor: pointer; + pointer-events: auto; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + background: var(--color-teal); + border: 2px solid white; + border-radius: 50%; + cursor: pointer; + pointer-events: auto; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } +} + +swp-time-range-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 100px; + text-align: center; + background: var(--color-background-alt); + padding: 6px 12px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--color-text); +} + +swp-time-range-times { + font-size: 13px; + font-family: var(--font-mono); + font-weight: var(--font-weight-medium); + color: var(--color-text); + white-space: nowrap; +} + +swp-time-range-duration { + font-size: 11px; + font-family: var(--font-mono); + color: var(--color-text-secondary); + white-space: nowrap; +} diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css index b2df4e5..f29bceb 100644 --- a/PlanTempus.Application/wwwroot/css/employees.css +++ b/PlanTempus.Application/wwwroot/css/employees.css @@ -855,104 +855,6 @@ swp-status-option { } } -/* Time range slider */ -swp-time-range { - display: flex; - align-items: center; - gap: 12px; -} - -swp-time-range-slider { - position: relative; - flex: 1; - height: 20px; - display: flex; - align-items: center; -} - -swp-time-range-track { - position: absolute; - width: 100%; - height: 4px; - background: var(--color-border); - border-radius: 2px; -} - -swp-time-range-fill { - position: absolute; - height: 4px; - background: var(--color-teal); - border-radius: 2px; - cursor: grab; - - &:active { - cursor: grabbing; - } -} - -swp-time-range-slider input[type="range"] { - position: absolute; - width: 100%; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: transparent; - pointer-events: none; - margin: 0; - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - background: var(--color-teal); - border: 2px solid white; - border-radius: 50%; - cursor: pointer; - pointer-events: auto; - box-shadow: 0 1px 3px rgba(0,0,0,0.2); - } - - &::-moz-range-thumb { - width: 14px; - height: 14px; - background: var(--color-teal); - border: 2px solid white; - border-radius: 50%; - cursor: pointer; - pointer-events: auto; - box-shadow: 0 1px 3px rgba(0,0,0,0.2); - } -} - -swp-time-range-label { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - min-width: 100px; - text-align: center; - background: var(--color-background-alt); - padding: 6px 12px; - border-radius: 4px; -} - -swp-time-range-times { - font-size: 13px; - font-family: var(--font-mono); - font-weight: var(--font-weight-medium); - color: var(--color-text); - white-space: nowrap; -} - -swp-time-range-duration { - font-size: 11px; - font-family: var(--font-mono); - color: var(--color-text-secondary); - white-space: nowrap; -} - - /* Schedule drawer employee display */ swp-employee-display { display: flex; diff --git a/PlanTempus.Application/wwwroot/css/page.css b/PlanTempus.Application/wwwroot/css/page.css index bef4e95..a9eb551 100644 --- a/PlanTempus.Application/wwwroot/css/page.css +++ b/PlanTempus.Application/wwwroot/css/page.css @@ -11,7 +11,8 @@ swp-page-container { display: block; max-width: var(--page-max-width); margin: 0 auto; - padding: var(--page-padding); + padding-left: var(--page-padding); + padding-right: var(--page-padding); } /* =========================================== diff --git a/PlanTempus.Application/wwwroot/css/services.css b/PlanTempus.Application/wwwroot/css/services.css index 5a83a46..9106073 100644 --- a/PlanTempus.Application/wwwroot/css/services.css +++ b/PlanTempus.Application/wwwroot/css/services.css @@ -426,3 +426,40 @@ swp-duration-delete { swp-duration-item:hover swp-duration-delete { opacity: 1; } + +/* =========================================== + AVAILABILITY LIST (Services - Tilgængelighed) + =========================================== */ +swp-availability-list { + display: flex; + flex-direction: column; +} + +swp-availability-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-2) var(--spacing-4); + padding: var(--spacing-3) 0; + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + + &[data-enabled="false"] { + opacity: 0.5; + } +} + +swp-availability-day { + width: 100%; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +swp-availability-time { + flex: 1; + transition: opacity var(--transition-fast); +} diff --git a/PlanTempus.Application/wwwroot/css/stats.css b/PlanTempus.Application/wwwroot/css/stats.css index 1289d8b..1a4e993 100644 --- a/PlanTempus.Application/wwwroot/css/stats.css +++ b/PlanTempus.Application/wwwroot/css/stats.css @@ -12,7 +12,6 @@ swp-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--card-gap); - margin-bottom: var(--section-gap); } swp-stats-row { @@ -20,6 +19,20 @@ swp-stats-row { grid-template-columns: repeat(3, 1fr); gap: var(--card-gap); margin-bottom: var(--section-gap); + + &.cols-4 { + grid-template-columns: repeat(4, 1fr); + } + + /* Reset margin when stat-card is inside stats-row */ + & swp-stat-card { + margin-top: 0; + } + + /* Tab-based visibility for multi-stat rows */ + &[data-for-tab]:not(.active) { + display: none; + } } /* =========================================== @@ -31,6 +44,7 @@ swp-stat-card { background: var(--color-surface); border-radius: var(--border-radius-lg); padding: var(--card-padding); + margin-top: var(--section-gap); border: 1px solid var(--color-border); } @@ -238,7 +252,8 @@ swp-quick-stat swp-stat-label { =========================================== */ @media (max-width: 1200px) { swp-stats-bar, - swp-stats-grid { + swp-stats-grid, + swp-stats-row.cols-4 { grid-template-columns: repeat(2, 1fr); } } diff --git a/PlanTempus.Application/wwwroot/ts/modules/controls.ts b/PlanTempus.Application/wwwroot/ts/modules/controls.ts index 010d007..cff9967 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/controls.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/controls.ts @@ -4,6 +4,7 @@ * Handles generic UI controls functionality: * - Toggle sliders (Ja/Nej switches) * - Select dropdowns + * - Time range sliders */ /** @@ -13,6 +14,7 @@ export class ControlsController { constructor() { this.initToggleSliders(); this.initSelectDropdowns(); + this.initTimeRangeSliders(); } /** @@ -141,4 +143,197 @@ export class ControlsController { select.querySelector('button')?.setAttribute('aria-expanded', 'false'); }); } + + /** + * Initialize all time range sliders on the page + * Each slider gets fill bar positioning, input handling, and drag behavior + * Dispatches 'timerange:change' event when values change + */ + private initTimeRangeSliders(): void { + document.querySelectorAll('swp-time-range-slider').forEach(slider => { + new TimeRangeSlider(slider as HTMLElement); + }); + } +} + +/** + * TimeRangeSlider - Reusable dual-handle time range slider + * + * Features: + * - Dual handle (start/end) range inputs + * - Fill bar that shows selected range + * - Drag fill bar to move entire range + * - Label updates with times and duration + * + * Dispatches 'timerange:change' event with detail: + * { start, end, startTime, endTime } + */ +class TimeRangeSlider { + private static readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals + + private slider: HTMLElement; + private startInput: HTMLInputElement | null; + private endInput: HTMLInputElement | null; + private fill: HTMLElement | null; + private track: HTMLElement | null; + + constructor(slider: HTMLElement) { + this.slider = slider; + this.startInput = slider.querySelector('.range-start'); + this.endInput = slider.querySelector('.range-end'); + this.fill = slider.querySelector('swp-time-range-fill'); + this.track = slider.querySelector('swp-time-range-track'); + + this.updateDisplay(); + this.setupEventListeners(); + this.setupDragBehavior(); + } + + /** + * Update the visual display (fill bar position, label text) + */ + private updateDisplay(): void { + if (!this.startInput || !this.endInput || !this.fill) return; + + let startVal = parseInt(this.startInput.value); + let endVal = parseInt(this.endInput.value); + + // Ensure start doesn't exceed end + if (startVal > endVal) { + if (this.startInput === document.activeElement) { + this.startInput.value = String(endVal); + startVal = endVal; + } else { + this.endInput.value = String(startVal); + endVal = startVal; + } + } + + // Update fill bar position + const startPercent = (startVal / TimeRangeSlider.TIME_RANGE_MAX) * 100; + const endPercent = (endVal / TimeRangeSlider.TIME_RANGE_MAX) * 100; + this.fill.style.left = startPercent + '%'; + this.fill.style.width = (endPercent - startPercent) + '%'; + + // Update label if exists (inside parent swp-time-range) + // Supports two patterns: + // 1. Nested: swp-time-range-label > swp-time-range-times + swp-time-range-duration + // 2. Simple: swp-time-range-label (direct text content) + const parent = this.slider.closest('swp-time-range'); + const labelEl = parent?.querySelector('swp-time-range-label'); + const timesEl = parent?.querySelector('swp-time-range-times'); + const durationEl = parent?.querySelector('swp-time-range-duration'); + + const timeText = `${this.valueToTime(startVal)} – ${this.valueToTime(endVal)}`; + + if (timesEl) { + // Nested pattern (employee drawer) + timesEl.textContent = timeText; + } else if (labelEl) { + // Simple pattern (services availability) + labelEl.textContent = timeText; + } + + if (durationEl) { + const durationMinutes = (endVal - startVal) * 15; + const durationHours = durationMinutes / 60; + durationEl.textContent = durationHours % 1 === 0 + ? `${durationHours} timer` + : `${durationHours.toFixed(1).replace('.', ',')} timer`; + } + } + + /** + * Dispatch change event for consumers + */ + private dispatchChange(): void { + if (!this.startInput || !this.endInput) return; + + const startVal = parseInt(this.startInput.value); + const endVal = parseInt(this.endInput.value); + + this.slider.dispatchEvent(new CustomEvent('timerange:change', { + bubbles: true, + detail: { + start: startVal, + end: endVal, + startTime: this.valueToTime(startVal), + endTime: this.valueToTime(endVal) + } + })); + } + + /** + * Setup input change listeners + */ + private setupEventListeners(): void { + this.startInput?.addEventListener('input', () => { + this.updateDisplay(); + this.dispatchChange(); + }); + + this.endInput?.addEventListener('input', () => { + this.updateDisplay(); + this.dispatchChange(); + }); + } + + /** + * Setup drag behavior on fill bar to move entire range + */ + private setupDragBehavior(): void { + if (!this.fill || !this.track || !this.startInput || !this.endInput) return; + + let isDragging = false; + let dragStartX = 0; + let dragStartValues = { start: 0, end: 0 }; + + this.fill.addEventListener('mousedown', (e: MouseEvent) => { + isDragging = true; + dragStartX = e.clientX; + dragStartValues.start = parseInt(this.startInput!.value); + dragStartValues.end = parseInt(this.endInput!.value); + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e: MouseEvent) => { + if (!isDragging) return; + + const sliderWidth = this.track!.offsetWidth; + const deltaX = e.clientX - dragStartX; + const deltaValue = Math.round((deltaX / sliderWidth) * TimeRangeSlider.TIME_RANGE_MAX); + + const duration = dragStartValues.end - dragStartValues.start; + let newStart = dragStartValues.start + deltaValue; + let newEnd = dragStartValues.end + deltaValue; + + if (newStart < 0) { + newStart = 0; + newEnd = duration; + } + if (newEnd > TimeRangeSlider.TIME_RANGE_MAX) { + newEnd = TimeRangeSlider.TIME_RANGE_MAX; + newStart = TimeRangeSlider.TIME_RANGE_MAX - duration; + } + + this.startInput!.value = String(newStart); + this.endInput!.value = String(newEnd); + this.updateDisplay(); + this.dispatchChange(); + }); + + document.addEventListener('mouseup', () => { + isDragging = false; + }); + } + + /** + * Convert slider value to time string (e.g., 12 → "09:00") + */ + private valueToTime(value: number): string { + const totalMinutes = value * 15 + 6 * 60; // Start at 06:00 + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } } diff --git a/PlanTempus.Application/wwwroot/ts/modules/employees.ts b/PlanTempus.Application/wwwroot/ts/modules/employees.ts index 4fa8007..ee40bd9 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/employees.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/employees.ts @@ -418,9 +418,6 @@ class ScheduleController { private editBtn: HTMLElement | null = null; private scheduleTable: HTMLElement | null = null; - // Time range constants - private readonly TIME_RANGE_MAX = 60; // 15 hours (06:00-21:00) * 4 intervals - constructor() { this.drawer = document.getElementById('schedule-drawer'); this.editBtn = document.getElementById('scheduleEditBtn'); @@ -435,7 +432,7 @@ class ScheduleController { this.setupCellSelection(); this.setupStatusOptions(); this.setupTypeToggle(); - this.setupTimeRangeSlider(); + this.setupTimeRangeEvents(); this.setupDrawerSave(); } @@ -882,6 +879,7 @@ class ScheduleController { /** * Set time range slider values + * Triggers 'input' event to let ControlsController update the display */ private setTimeRange(startTime: string, endTime: string): void { const timeRange = document.getElementById('scheduleTimeRange'); @@ -890,125 +888,25 @@ class ScheduleController { const startInput = timeRange.querySelector('.range-start'); const endInput = timeRange.querySelector('.range-end'); - if (startInput) startInput.value = String(this.timeToValue(startTime)); - if (endInput) endInput.value = String(this.timeToValue(endTime)); - - this.updateTimeRangeDisplay(timeRange); + if (startInput) { + startInput.value = String(this.timeToValue(startTime)); + startInput.dispatchEvent(new Event('input', { bubbles: true })); + } + if (endInput) { + endInput.value = String(this.timeToValue(endTime)); + endInput.dispatchEvent(new Event('input', { bubbles: true })); + } } /** - * Update time range display + * Setup time range event listener + * ControlsController handles the slider UI; we just listen for changes */ - private updateTimeRangeDisplay(container: HTMLElement): void { - const startInput = container.querySelector('.range-start'); - const endInput = container.querySelector('.range-end'); - const fill = container.querySelector('swp-time-range-fill'); - const timesEl = container.querySelector('swp-time-range-times'); - const durationEl = container.querySelector('swp-time-range-duration'); - - if (!startInput || !endInput) return; - - let startVal = parseInt(startInput.value); - let endVal = parseInt(endInput.value); - - // Ensure start doesn't exceed end - if (startVal > endVal) { - if (startInput === document.activeElement) { - startInput.value = String(endVal); - startVal = endVal; - } else { - endInput.value = String(startVal); - endVal = startVal; - } - } - - // Update fill bar - if (fill) { - const startPercent = (startVal / this.TIME_RANGE_MAX) * 100; - const endPercent = (endVal / this.TIME_RANGE_MAX) * 100; - fill.style.left = startPercent + '%'; - fill.style.width = (endPercent - startPercent) + '%'; - } - - // Calculate duration - const durationMinutes = (endVal - startVal) * 15; - const durationHours = durationMinutes / 60; - const durationText = durationHours % 1 === 0 - ? `${durationHours} timer` - : `${durationHours.toFixed(1).replace('.', ',')} timer`; - - if (timesEl) timesEl.textContent = `${this.valueToTime(startVal)} – ${this.valueToTime(endVal)}`; - if (durationEl) durationEl.textContent = durationText; - } - - /** - * Setup time range slider - */ - private setupTimeRangeSlider(): void { + private setupTimeRangeEvents(): void { const timeRange = document.getElementById('scheduleTimeRange'); - if (!timeRange) return; - - const startInput = timeRange.querySelector('.range-start'); - const endInput = timeRange.querySelector('.range-end'); - const fill = timeRange.querySelector('swp-time-range-fill'); - const track = timeRange.querySelector('swp-time-range-track'); - - this.updateTimeRangeDisplay(timeRange); - - startInput?.addEventListener('input', () => { - this.updateTimeRangeDisplay(timeRange); + timeRange?.addEventListener('timerange:change', () => { this.updateSelectedCellsTime(); }); - - endInput?.addEventListener('input', () => { - this.updateTimeRangeDisplay(timeRange); - this.updateSelectedCellsTime(); - }); - - // Drag fill bar to move entire range - if (fill && track && startInput && endInput) { - let isDragging = false; - let dragStartX = 0; - let dragStartValues = { start: 0, end: 0 }; - - fill.addEventListener('mousedown', (e: MouseEvent) => { - isDragging = true; - dragStartX = e.clientX; - dragStartValues.start = parseInt(startInput.value); - dragStartValues.end = parseInt(endInput.value); - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e: MouseEvent) => { - if (!isDragging) return; - - const sliderWidth = track.offsetWidth; - const deltaX = e.clientX - dragStartX; - const deltaValue = Math.round((deltaX / sliderWidth) * this.TIME_RANGE_MAX); - - const duration = dragStartValues.end - dragStartValues.start; - let newStart = dragStartValues.start + deltaValue; - let newEnd = dragStartValues.end + deltaValue; - - if (newStart < 0) { - newStart = 0; - newEnd = duration; - } - if (newEnd > this.TIME_RANGE_MAX) { - newEnd = this.TIME_RANGE_MAX; - newStart = this.TIME_RANGE_MAX - duration; - } - - startInput.value = String(newStart); - endInput.value = String(newEnd); - this.updateTimeRangeDisplay(timeRange); - this.updateSelectedCellsTime(); - }); - - document.addEventListener('mouseup', () => { - isDragging = false; - }); - } } /**