diff --git a/PlanTempus.Application/.claude/settings.local.json b/PlanTempus.Application/.claude/settings.local.json index 3cb57dd..825d842 100644 --- a/PlanTempus.Application/.claude/settings.local.json +++ b/PlanTempus.Application/.claude/settings.local.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "WebSearch", - "Bash(npm run build:*)" + "Bash(npm run build:*)", + "Bash(node -e:*)", + "Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\PlanTempus\")", + "Bash(findstr:*)" ] } } diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/CustomerDetailActivityViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/CustomerDetailActivityViewComponent.cs new file mode 100644 index 0000000..523eefd --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/CustomerDetailActivityViewComponent.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; +using System.Globalization; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailActivityViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailActivityViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var culture = new CultureInfo("da-DK"); + var today = DateTime.Today; + + // Group activities by date + var groupedActivities = customer.Activity + .OrderByDescending(a => DateTime.Parse(a.Date)) + .ThenByDescending(a => a.Time) + .GroupBy(a => a.Date) + .Select(g => + { + var date = DateTime.Parse(g.Key); + string dateHeader; + if (date.Date == today) + { + dateHeader = "I dag"; + } + else if (date.Date == today.AddDays(-1)) + { + dateHeader = "I gar"; + } + else + { + dateHeader = date.ToString("d. MMMM yyyy", culture); + } + + return new ActivityDateGroupViewModel + { + DateHeader = dateHeader, + Items = g.Select(a => new ActivityItemViewModel + { + Time = a.Time, + Type = a.Type, + Icon = a.Icon, + Title = a.Title, + Actor = a.Actor, + Badges = a.Badges.Select(b => new ActivityBadgeViewModel + { + Text = char.ToUpper(b[0]) + b[1..], + CssClass = b.ToLowerInvariant() + }).ToList() + }).ToList() + }; + }) + .ToList(); + + var model = new CustomerDetailActivityViewModel + { + DateGroups = groupedActivities, + Filters = new List + { + new() { Label = "Alle", Type = null, Icon = null, IsActive = true }, + new() { Label = "Bookinger", Type = "booking", Icon = "calendar" }, + new() { Label = "Kommunikation", Type = "communication", Icon = "envelope" }, + new() { Label = "Ændringer", Type = "edit", Icon = "pencil-simple" }, + new() { Label = "Betalinger", Type = "payment", Icon = "credit-card" }, + new() { Label = "Advarsler", Type = "warning", Icon = "warning" }, + new() { Label = "Kunde", Type = "customer", Icon = "user" } + } + }; + + return View(model); + } +} + +public class CustomerDetailActivityViewModel +{ + public List DateGroups { get; init; } = new(); + public List Filters { get; init; } = new(); +} + +public class ActivityFilterViewModel +{ + public required string Label { get; init; } + public string? Type { get; init; } + public string? Icon { get; init; } + public bool IsActive { get; init; } +} + +public class ActivityDateGroupViewModel +{ + public required string DateHeader { get; init; } + public List Items { get; init; } = new(); +} + +public class ActivityItemViewModel +{ + public required string Time { get; init; } + public required string Type { get; init; } + public required string Icon { get; init; } + public required string Title { get; init; } + public string? Actor { get; init; } + public List Badges { get; init; } = new(); +} + +public class ActivityBadgeViewModel +{ + public required string Text { get; init; } + public required string CssClass { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/Default.cshtml new file mode 100644 index 0000000..c78d1f5 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailActivity/Default.cshtml @@ -0,0 +1,83 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailActivityViewModel + + + + @foreach (var filter in Model.Filters) + { + + @if (!string.IsNullOrEmpty(filter.Icon)) + { + + } + @filter.Label + + } + + + + + @foreach (var dateGroup in Model.DateGroups) + { + + @dateGroup.DateHeader + + @foreach (var item in dateGroup.Items) + { + + + + + @Html.Raw(item.Title) + @foreach (var badge in item.Badges) + { + @badge.Text + } + + + @item.Time + @if (!string.IsNullOrEmpty(item.Actor)) + { + @item.Actor + } + + + + } + + } + + + + diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/CustomerDetailAppointmentsViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/CustomerDetailAppointmentsViewComponent.cs new file mode 100644 index 0000000..a3790c9 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/CustomerDetailAppointmentsViewComponent.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; +using System.Globalization; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailAppointmentsViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailAppointmentsViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var culture = new CultureInfo("da-DK"); + + var model = new CustomerDetailAppointmentsViewModel + { + UpcomingTitle = "Kommende aftaler", + HistoryTitle = "Tidligere aftaler", + MoveButtonText = "Flyt", + CancelButtonText = "Aflys", + SeeAllText = "Se alle aftaler ->", + DateHeader = "Dato", + ServiceHeader = "Service", + HairdresserHeader = "Frisor", + DurationHeader = "Varighed", + PriceHeader = "Pris", + Upcoming = customer.Appointments.Upcoming.Select(a => + { + var dateText = a.Date; + if (DateTime.TryParse(a.Date, out var date)) + { + dateText = date.ToString("dddd d. MMMM yyyy", culture); + dateText = char.ToUpper(dateText[0]) + dateText[1..]; + } + return new UpcomingAppointmentViewModel + { + FormattedDate = $"{dateText} kl. {a.Time}", + Details = $"{a.Service} - {a.Hairdresser} - {a.Duration}" + }; + }).ToList(), + History = customer.Appointments.History.Select(a => + { + var dateText = a.Date; + if (DateTime.TryParse(a.Date, out var date)) + { + dateText = date.ToString("d. MMM yyyy", culture); + } + return new HistoryAppointmentViewModel + { + FormattedDate = dateText, + Service = a.Service, + Hairdresser = a.Hairdresser, + Duration = a.Duration, + FormattedPrice = $"{a.Price:N0} kr".Replace(",", ".") + }; + }).ToList() + }; + + return View(model); + } +} + +public class CustomerDetailAppointmentsViewModel +{ + public required string UpcomingTitle { get; init; } + public required string HistoryTitle { get; init; } + public required string MoveButtonText { get; init; } + public required string CancelButtonText { get; init; } + public required string SeeAllText { get; init; } + public required string DateHeader { get; init; } + public required string ServiceHeader { get; init; } + public required string HairdresserHeader { get; init; } + public required string DurationHeader { get; init; } + public required string PriceHeader { get; init; } + public List Upcoming { get; init; } = new(); + public List History { get; init; } = new(); +} + +public class UpcomingAppointmentViewModel +{ + public required string FormattedDate { get; init; } + public required string Details { get; init; } +} + +public class HistoryAppointmentViewModel +{ + public required string FormattedDate { get; init; } + public required string Service { get; init; } + public required string Hairdresser { get; init; } + public required string Duration { get; init; } + public required string FormattedPrice { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/Default.cshtml new file mode 100644 index 0000000..af0beda --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailAppointments/Default.cshtml @@ -0,0 +1,64 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailAppointmentsViewModel + + + + + + + + @Model.UpcomingTitle + + @foreach (var appointment in Model.Upcoming) + { + + + @appointment.FormattedDate + + + @appointment.Details + + + @Model.MoveButtonText + @Model.CancelButtonText + + + } + @if (!Model.Upcoming.Any()) + { + +

Ingen kommende aftaler

+
+ } +
+
+ + + + + + + @Model.HistoryTitle + + + + @Model.DateHeader + @Model.ServiceHeader + @Model.HairdresserHeader + @Model.DurationHeader + @Model.PriceHeader + + @foreach (var appointment in Model.History) + { + + @appointment.FormattedDate + @appointment.Service + @appointment.Hairdresser + @appointment.Duration + @appointment.FormattedPrice + + } + + @Model.SeeAllText + + +
diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailCatalog.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailCatalog.cs new file mode 100644 index 0000000..179e088 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailCatalog.cs @@ -0,0 +1,301 @@ +using System.Text.Json; + +namespace PlanTempus.Application.Features.Customers.Components; + +/// +/// Shared catalog for customer detail data. +/// Loads from customerDetailMock.json and used by all CustomerDetail* ViewComponents. +/// +public static class CustomerDetailCatalog +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static Dictionary? _customers; + + private static Dictionary Customers + { + get + { + if (_customers == null) + { + var jsonPath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + "Features", "Customers", "Data", "customerDetailMock.json"); + var json = File.ReadAllText(jsonPath); + _customers = JsonSerializer.Deserialize>(json, JsonOptions) + ?? new Dictionary(); + } + return _customers; + } + } + + public static CustomerDetailRecord Get(string customerId) + { + if (!Customers.TryGetValue(customerId, out var customer)) + throw new KeyNotFoundException($"Customer with id '{customerId}' not found"); + return customer; + } + + public static IEnumerable AllIds => Customers.Keys; +} + +// Root record for customer detail +public record CustomerDetailRecord +{ + public required string Id { get; init; } + public required CustomerHeaderRecord Header { get; init; } + public required CustomerContactRecord Contact { get; init; } + public List Profile { get; init; } = new(); + public required CustomerMarketingRecord Marketing { get; init; } + public required CustomerPaymentRecord Payment { get; init; } + public required CustomerPreferencesRecord Preferences { get; init; } + public List Warnings { get; init; } = new(); + public required CustomerGroupRecord Group { get; init; } + public List Relations { get; init; } = new(); + public required CustomerStatisticsRecord Statistics { get; init; } + public List Journal { get; init; } = new(); + public required CustomerAppointmentsRecord Appointments { get; init; } + public required CustomerGiftcardsRecord Giftcards { get; init; } + public List Activity { get; init; } = new(); + public CustomerEconomyRecord? Economy { get; init; } +} + +// Header section +public record CustomerHeaderRecord +{ + public required string Initials { get; init; } + public required string Name { get; init; } + public required string CustomerSince { get; init; } + public List Tags { get; init; } = new(); + public bool BookingAllowed { get; init; } + public required CustomerFactsRecord Facts { get; init; } +} + +public record CustomerFactsRecord +{ + public int Visits { get; init; } + public int AvgIntervalDays { get; init; } + public required string PreferredHairdresser { get; init; } + public decimal TotalRevenue { get; init; } +} + +// Contact section +public record CustomerContactRecord +{ + public required string Phone { get; init; } + public required string Email { get; init; } + public required string Address { get; init; } + public required string Zip { get; init; } + public required string City { get; init; } +} + +// Profile item (flexible) +public record CustomerProfileItem +{ + public required string Title { get; init; } + public required string Value { get; init; } +} + +// Marketing settings +public record CustomerMarketingRecord +{ + public bool EmailOptIn { get; init; } + public bool SmsOptIn { get; init; } +} + +// Payment settings +public record CustomerPaymentRecord +{ + public bool RequirePrepayment { get; init; } + public bool AllowPartialPayment { get; init; } +} + +// Preferences +public record CustomerPreferencesRecord +{ + public required string PreferredHairdresser { get; init; } + public required string PreferredDays { get; init; } + public required string SpecialRequests { get; init; } +} + +// Warning item (flexible) +public record CustomerWarningItem +{ + public required string Title { get; init; } + public required string Value { get; init; } +} + +// Group +public record CustomerGroupRecord +{ + public required string GroupId { get; init; } + public required string GroupName { get; init; } +} + +// Relation +public record CustomerRelationRecord +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string Initials { get; init; } + public required string Type { get; init; } +} + +// Statistics +public record CustomerStatisticsRecord +{ + public required CustomerAttendanceRecord Attendance { get; init; } + public List TopServices { get; init; } = new(); + public List TopProducts { get; init; } = new(); + public required CustomerBookingBehaviorRecord BookingBehavior { get; init; } + public required CustomerLoyaltyRecord Loyalty { get; init; } +} + +public record CustomerAttendanceRecord +{ + public int Attended { get; init; } + public int Cancelled { get; init; } + public int NoShow { get; init; } + public int ReliabilityPercent { get; init; } +} + +public record CustomerTopItem +{ + public required string Name { get; init; } + public int Count { get; init; } +} + +public record CustomerBookingBehaviorRecord +{ + public int AvgBookingNoticeDays { get; init; } + public required string PreferredDay { get; init; } + public required string PreferredTimeSlot { get; init; } + public int OnlineBookingRate { get; init; } + public int AvgCancellationNoticeDays { get; init; } +} + +public record CustomerLoyaltyRecord +{ + public double CustomerSinceYears { get; init; } + public int DaysSinceLastVisit { get; init; } + public required string ChurnRisk { get; init; } + public int AvgIntervalDays { get; init; } +} + +// Journal +public record CustomerJournalEntry +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Tag { get; init; } + public List Subtags { get; init; } = new(); + public required string Text { get; init; } + public required string Date { get; init; } + public required string Author { get; init; } +} + +// Appointments +public record CustomerAppointmentsRecord +{ + public List Upcoming { get; init; } = new(); + public List History { get; init; } = new(); +} + +public record CustomerUpcomingAppointment +{ + public required string Date { get; init; } + public required string Time { get; init; } + public required string Service { get; init; } + public required string Hairdresser { get; init; } + public required string Duration { get; init; } +} + +public record CustomerHistoryAppointment +{ + public required string Date { get; init; } + public required string Service { get; init; } + public required string Hairdresser { get; init; } + public required string Duration { get; init; } + public decimal Price { get; init; } +} + +// Giftcards +public record CustomerGiftcardsRecord +{ + public List Active { get; init; } = new(); + public List Expired { get; init; } = new(); +} + +public record CustomerGiftcardItem +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Label { get; init; } + public decimal? OriginalValue { get; init; } + public decimal? CurrentBalance { get; init; } + public int? TotalPunches { get; init; } + public int? UsedPunches { get; init; } + public string? ExpiresAt { get; init; } +} + +// Activity +public record CustomerActivityEntry +{ + public required string Date { get; init; } + public required string Time { get; init; } + public required string Type { get; init; } + public required string Icon { get; init; } + public required string Title { get; init; } + public string? Actor { get; init; } + public List Badges { get; init; } = new(); +} + +// Economy +public record CustomerEconomyRecord +{ + public required CustomerYearRevenue CurrentYear { get; init; } + public required CustomerYearRevenue LastYear { get; init; } + public decimal AvgPerVisit { get; init; } + public decimal AvgPerMonth { get; init; } + public CustomerChartData? ChartData { get; init; } + public List Purchases { get; init; } = new(); +} + +public record CustomerChartData +{ + public List Categories { get; init; } = new(); + public List Series { get; init; } = new(); +} + +public record CustomerChartSeries +{ + public required string Name { get; init; } + public required string Color { get; init; } + public List Data { get; init; } = new(); +} + +public record CustomerChartDataPoint +{ + public required string X { get; init; } + public decimal Y { get; init; } +} + +public record CustomerYearRevenue +{ + public int Year { get; init; } + public decimal Total { get; init; } +} + +public record CustomerPurchase +{ + public required string Invoice { get; init; } + public required string Date { get; init; } + public required string Time { get; init; } + public required string Employee { get; init; } + public required string Services { get; init; } + public required string Type { get; init; } + public decimal Amount { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/CustomerDetailEconomyViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/CustomerDetailEconomyViewComponent.cs new file mode 100644 index 0000000..6d24cc6 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/CustomerDetailEconomyViewComponent.cs @@ -0,0 +1,145 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailEconomyViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailEconomyViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var economy = customer.Economy; + + var model = new CustomerDetailEconomyViewModel + { + HasData = economy != null, + + // Stat cards + CurrentYearValue = economy != null ? $"{economy.CurrentYear.Total:N0} kr".Replace(",", ".") : "-", + CurrentYearLabel = string.Format(_localization.Get("customers.detail.economy.thisYear"), economy?.CurrentYear.Year ?? DateTime.Now.Year), + LastYearValue = economy != null ? $"{economy.LastYear.Total:N0} kr".Replace(",", ".") : "-", + LastYearLabel = _localization.Get("customers.detail.economy.lastYear"), + AvgPerVisitValue = economy != null ? $"{economy.AvgPerVisit:N0} kr".Replace(",", ".") : "-", + AvgPerVisitLabel = _localization.Get("customers.detail.economy.avgPerVisit"), + AvgPerMonthValue = economy != null ? $"{economy.AvgPerMonth:N0} kr".Replace(",", ".") : "-", + AvgPerMonthLabel = _localization.Get("customers.detail.economy.avgPerMonth"), + + // Chart card + RevenueOverTimeTitle = _localization.Get("customers.detail.economy.revenueOverTime"), + ServicesLabel = _localization.Get("customers.detail.economy.services"), + ProductsLabel = _localization.Get("customers.detail.economy.products"), + ChartData = economy?.ChartData != null ? new CustomerChartDataViewModel + { + Categories = economy.ChartData.Categories, + Series = economy.ChartData.Series.Select(s => new CustomerChartSeriesViewModel + { + Name = s.Name, + Color = s.Color, + Data = s.Data.Select(d => new CustomerChartDataPointViewModel + { + X = d.X, + Y = d.Y + }).ToList() + }).ToList() + } : null, + + // Purchase history + PurchaseHistoryTitle = _localization.Get("customers.detail.economy.purchaseHistory"), + Purchases = economy?.Purchases.Take(5).Select(p => new CustomerPurchaseViewModel + { + Invoice = p.Invoice, + Date = FormatDate(p.Date), + Time = p.Time, + Employee = p.Employee, + Services = p.Services, + Type = p.Type, + TypeLabel = p.Type == "service" + ? _localization.Get("customers.detail.economy.services") + : _localization.Get("customers.detail.economy.products"), + Amount = $"{p.Amount:N0} kr".Replace(",", ".") + }).ToList() ?? new List(), + SeeAllText = _localization.Get("customers.detail.economy.seeAll"), + + // Empty state + EmptyStateText = _localization.Get("customers.detail.economy.noData") + }; + + return View(model); + } + + private static string FormatDate(string dateStr) + { + if (DateTime.TryParse(dateStr, out var date)) + { + return date.ToString("d. MMM yyyy", new System.Globalization.CultureInfo("da-DK")); + } + return dateStr; + } +} + +public class CustomerDetailEconomyViewModel +{ + public bool HasData { get; init; } + + // Stat cards + public required string CurrentYearValue { get; init; } + public required string CurrentYearLabel { get; init; } + public required string LastYearValue { get; init; } + public required string LastYearLabel { get; init; } + public required string AvgPerVisitValue { get; init; } + public required string AvgPerVisitLabel { get; init; } + public required string AvgPerMonthValue { get; init; } + public required string AvgPerMonthLabel { get; init; } + + // Chart card + public required string RevenueOverTimeTitle { get; init; } + public required string ServicesLabel { get; init; } + public required string ProductsLabel { get; init; } + public CustomerChartDataViewModel? ChartData { get; init; } + + // Purchase history + public required string PurchaseHistoryTitle { get; init; } + public List Purchases { get; init; } = new(); + public required string SeeAllText { get; init; } + + // Empty state + public required string EmptyStateText { get; init; } +} + +public class CustomerPurchaseViewModel +{ + public required string Invoice { get; init; } + public required string Date { get; init; } + public required string Time { get; init; } + public required string Employee { get; init; } + public required string Services { get; init; } + public required string Type { get; init; } + public required string TypeLabel { get; init; } + public required string Amount { get; init; } +} + +public class CustomerChartDataViewModel +{ + public List Categories { get; init; } = new(); + public List Series { get; init; } = new(); +} + +public class CustomerChartSeriesViewModel +{ + public required string Name { get; init; } + public required string Color { get; init; } + public List Data { get; init; } = new(); +} + +public class CustomerChartDataPointViewModel +{ + public required string X { get; init; } + public decimal Y { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/Default.cshtml new file mode 100644 index 0000000..18578a0 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailEconomy/Default.cshtml @@ -0,0 +1,102 @@ +@using System.Text.Json +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailEconomyViewModel + +@if (!Model.HasData) +{ + + + @Model.EmptyStateText + +} +else +{ + + + + + @Model.CurrentYearValue + @Model.CurrentYearLabel + + + @Model.LastYearValue + @Model.LastYearLabel + + + @Model.AvgPerVisitValue + @Model.AvgPerVisitLabel + + + @Model.AvgPerMonthValue + @Model.AvgPerMonthLabel + + + + + + + @Model.RevenueOverTimeTitle + + + + @Model.ServicesLabel + + + + @Model.ProductsLabel + + + +@if (Model.ChartData != null) + { + + + } + else + { + + + + } + + + + + + @Model.PurchaseHistoryTitle + + + + Faktura + Dato/tid + Medarbejder + Ydelser + Type + Beløb + + @foreach (var purchase in Model.Purchases) + { + + + @purchase.Invoice + + + + @purchase.Date + @purchase.Time + + + @purchase.Employee + @purchase.Services + + @purchase.TypeLabel + + + @purchase.Amount + + + } + + @Model.SeeAllText + + +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/CustomerDetailGiftcardsViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/CustomerDetailGiftcardsViewComponent.cs new file mode 100644 index 0000000..694452a --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/CustomerDetailGiftcardsViewComponent.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; +using System.Globalization; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailGiftcardsViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailGiftcardsViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var culture = new CultureInfo("da-DK"); + + // Separate giftcards and punchcards + var giftcards = customer.Giftcards.Active.Where(g => g.Type == "giftcard").ToList(); + var punchcards = customer.Giftcards.Active.Where(g => g.Type == "punchcard").ToList(); + + var model = new CustomerDetailGiftcardsViewModel + { + GiftcardsTitle = "Aktive gavekort", + PunchcardsTitle = "Klippekort", + ExpiredTitle = "Udlobne / Brugte", + NoExpiredText = "Ingen udlobne eller brugte kort", + Giftcards = giftcards.Select(g => + { + var expiresText = "Udlober aldrig"; + if (!string.IsNullOrEmpty(g.ExpiresAt) && DateTime.TryParse(g.ExpiresAt, out var expires)) + { + expiresText = $"Udlober: {expires.ToString("d. MMMM yyyy", culture)}"; + } + var percentage = g.OriginalValue > 0 ? (int)((g.CurrentBalance ?? 0) / g.OriginalValue * 100) : 0; + return new GiftcardItemViewModel + { + Label = g.Label, + BalanceText = $"Saldo: {g.CurrentBalance:N0} kr (af {g.OriginalValue:N0} kr)".Replace(",", "."), + ExpiresText = expiresText, + Percentage = percentage + }; + }).ToList(), + Punchcards = punchcards.Select(p => + { + var expiresText = "Udlober aldrig"; + if (!string.IsNullOrEmpty(p.ExpiresAt) && DateTime.TryParse(p.ExpiresAt, out var expires)) + { + expiresText = $"Udlober: {expires.ToString("d. MMMM yyyy", culture)}"; + } + var percentage = p.TotalPunches > 0 ? (int)((p.UsedPunches ?? 0) * 100 / p.TotalPunches) : 0; + return new GiftcardItemViewModel + { + Label = p.Label, + BalanceText = $"Brugt: {p.UsedPunches} af {p.TotalPunches} klip", + ExpiresText = expiresText, + Percentage = percentage + }; + }).ToList(), + ExpiredCards = customer.Giftcards.Expired.Select(g => new GiftcardItemViewModel + { + Label = g.Label, + BalanceText = "", + ExpiresText = "", + Percentage = 0 + }).ToList() + }; + + return View(model); + } +} + +public class CustomerDetailGiftcardsViewModel +{ + public required string GiftcardsTitle { get; init; } + public required string PunchcardsTitle { get; init; } + public required string ExpiredTitle { get; init; } + public required string NoExpiredText { get; init; } + public List Giftcards { get; init; } = new(); + public List Punchcards { get; init; } = new(); + public List ExpiredCards { get; init; } = new(); +} + +public class GiftcardItemViewModel +{ + public required string Label { get; init; } + public required string BalanceText { get; init; } + public required string ExpiresText { get; init; } + public int Percentage { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/Default.cshtml new file mode 100644 index 0000000..ca55cd3 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailGiftcards/Default.cshtml @@ -0,0 +1,81 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailGiftcardsViewModel + + + + + + @if (Model.Giftcards.Any()) + { + + + @Model.GiftcardsTitle + + @foreach (var giftcard in Model.Giftcards) + { + + + @giftcard.Label + + + @Html.Raw(giftcard.BalanceText) + + + + + @giftcard.ExpiresText + + } + + } + + + @if (Model.Punchcards.Any()) + { + + + @Model.PunchcardsTitle + + @foreach (var punchcard in Model.Punchcards) + { + + + @punchcard.Label + + + @Html.Raw(punchcard.BalanceText) + + + + + @punchcard.ExpiresText + + } + + } + + + + + + + + @Model.ExpiredTitle + + @if (Model.ExpiredCards.Any()) + { + @foreach (var card in Model.ExpiredCards) + { + + @card.Label + + } + } + else + { + +

@Model.NoExpiredText

+
+ } +
+
+
diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/CustomerDetailHeaderViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/CustomerDetailHeaderViewComponent.cs new file mode 100644 index 0000000..9222364 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/CustomerDetailHeaderViewComponent.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailHeaderViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailHeaderViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var header = customer.Header; + var contact = customer.Contact; + + // Format customer since date + var customerSince = DateTime.TryParse(header.CustomerSince, out var date) + ? date.ToString("MMMM yyyy", new System.Globalization.CultureInfo("da-DK")) + : header.CustomerSince; + + var model = new CustomerDetailHeaderViewModel + { + Initials = header.Initials, + Name = header.Name, + Tags = header.Tags.Select(t => new CustomerTagViewModel + { + Text = t.ToUpper(), + CssClass = t.ToLowerInvariant() + }).ToList(), + BookingAllowed = header.BookingAllowed, + Phone = contact.Phone, + PhoneHref = $"tel:{contact.Phone.Replace(" ", "")}", + Email = contact.Email, + EmailHref = $"mailto:{contact.Email}", + CustomerSinceText = $"Kunde siden {customerSince}", + FactVisits = header.Facts.Visits.ToString(), + FactVisitsLabel = _localization.Get("customers.detail.visits"), + FactInterval = header.Facts.AvgIntervalDays.ToString(), + FactIntervalLabel = _localization.Get("customers.detail.interval"), + FactHairdresser = header.Facts.PreferredHairdresser, + FactHairdresserLabel = _localization.Get("customers.detail.preferredHairdresser"), + FactRevenue = $"{header.Facts.TotalRevenue:N0} kr".Replace(",", "."), + FactRevenueLabel = _localization.Get("customers.detail.totalRevenue"), + BookingAllowedText = _localization.Get("customers.detail.bookingAllowed"), + BookingBlockedText = _localization.Get("customers.detail.bookingBlocked") + }; + + return View(model); + } +} + +public class CustomerDetailHeaderViewModel +{ + public required string Initials { get; init; } + public required string Name { get; init; } + public List Tags { get; init; } = new(); + public bool BookingAllowed { get; init; } + public required string Phone { get; init; } + public required string PhoneHref { get; init; } + public required string Email { get; init; } + public required string EmailHref { get; init; } + public required string CustomerSinceText { get; init; } + public required string FactVisits { get; init; } + public required string FactVisitsLabel { get; init; } + public required string FactInterval { get; init; } + public required string FactIntervalLabel { get; init; } + public required string FactHairdresser { get; init; } + public required string FactHairdresserLabel { get; init; } + public required string FactRevenue { get; init; } + public required string FactRevenueLabel { get; init; } + public required string BookingAllowedText { get; init; } + public required string BookingBlockedText { get; init; } +} + +public class CustomerTagViewModel +{ + public required string Text { get; init; } + public required string CssClass { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/Default.cshtml new file mode 100644 index 0000000..cc13436 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailHeader/Default.cshtml @@ -0,0 +1,53 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailHeaderViewModel + + + @Model.Initials + + + @Model.Name + + @foreach (var tag in Model.Tags) + { + @tag.Text + } + + + @if (Model.BookingAllowed) + { + + @Model.BookingAllowedText + } + else + { + + @Model.BookingBlockedText + } + + + + @Model.Phone + | + @Model.Email + | + @Model.CustomerSinceText + + + + @Model.FactVisits + @Model.FactVisitsLabel + + + @Model.FactInterval + @Model.FactIntervalLabel + + + @Model.FactHairdresser + @Model.FactHairdresserLabel + + + @Model.FactRevenue + @Model.FactRevenueLabel + + + + diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/CustomerDetailJournalViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/CustomerDetailJournalViewComponent.cs new file mode 100644 index 0000000..ce5f7bb --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/CustomerDetailJournalViewComponent.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailJournalViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailJournalViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + + // Group entries by type + var notes = customer.Journal.Where(j => j.Type == "note").ToList(); + var colorFormulas = customer.Journal.Where(j => j.Type == "colorFormula").ToList(); + var analyses = customer.Journal.Where(j => j.Type == "analysis").ToList(); + + var model = new CustomerDetailJournalViewModel + { + AllCount = customer.Journal.Count, + NotesCount = notes.Count, + ColorFormulasCount = colorFormulas.Count, + AnalysesCount = analyses.Count, + Notes = notes.Select(MapEntry).ToList(), + ColorFormulas = colorFormulas.Select(MapEntry).ToList(), + Analyses = analyses.Select(MapEntry).ToList(), + NotesTitle = "Noter", + ColorFormulasTitle = "Farveformler", + AnalysesTitle = "Analyser", + AddNoteText = "+ Tilføj note", + AddColorFormulaText = "+ Tilføj", + AddAnalysisText = "+ Tilføj" + }; + + return View(model); + } + + private JournalEntryViewModel MapEntry(CustomerJournalEntry entry) + { + // Format the date (e.g., "2025-12-09" -> "9. dec 2025") + var formattedDate = entry.Date; + if (DateTime.TryParse(entry.Date, out var date)) + { + formattedDate = date.ToString("d. MMM yyyy", new System.Globalization.CultureInfo("da-DK")); + } + + return new JournalEntryViewModel + { + Id = entry.Id, + Type = entry.Type, + Tag = entry.Tag, + Subtags = entry.Subtags, + Text = entry.Text, + FormattedDate = $"{formattedDate} - Af: {entry.Author}", + Author = entry.Author, + TypeClass = GetTypeClass(entry.Tag) + }; + } + + private string GetTypeClass(string tag) + { + return tag.ToLowerInvariant() switch + { + "note" => "note", + "advarsel" => "advarsel", + "farveformel" => "farveformel", + "haranalyse" or "analyse" => "analyse", + _ => "note" + }; + } +} + +public class CustomerDetailJournalViewModel +{ + public int AllCount { get; init; } + public int NotesCount { get; init; } + public int ColorFormulasCount { get; init; } + public int AnalysesCount { get; init; } + public List Notes { get; init; } = new(); + public List ColorFormulas { get; init; } = new(); + public List Analyses { get; init; } = new(); + public required string NotesTitle { get; init; } + public required string ColorFormulasTitle { get; init; } + public required string AnalysesTitle { get; init; } + public required string AddNoteText { get; init; } + public required string AddColorFormulaText { get; init; } + public required string AddAnalysisText { get; init; } +} + +public class JournalEntryViewModel +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Tag { get; init; } + public List Subtags { get; init; } = new(); + public required string Text { get; init; } + public required string FormattedDate { get; init; } + public required string Author { get; init; } + public required string TypeClass { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/Default.cshtml new file mode 100644 index 0000000..9f5eeb5 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailJournal/Default.cshtml @@ -0,0 +1,168 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailJournalViewModel + + + + + + Alle + @Model.AllCount + + + + Noter + @Model.NotesCount + + + + Farveformler + @Model.ColorFormulasCount + + + + Analyser + @Model.AnalysesCount + + + + + + + + + + + + @Model.NotesTitle + + @Model.AddNoteText + + @foreach (var entry in Model.Notes) + { + + + @entry.Tag + @if (entry.Subtags.Any()) + { + + @foreach (var subtag in entry.Subtags) + { + @subtag + } + + } + + + @Html.Raw(entry.Text.Replace("\n", "
"))
+ + @entry.FormattedDate + @if (entry.Tag == "Advarsel") + { + + + Advarsel + + } + else + { + + + Alle + + } + +
+ } +
+ + + + + + + @Model.ColorFormulasTitle + + @Model.AddColorFormulaText + + @foreach (var entry in Model.ColorFormulas) + { + + + @entry.Tag + + + + @{ + var lines = entry.Text.Split('\n'); + foreach (var line in lines) + { + if (line.Contains(':')) + { + var parts = line.Split(':', 2); + @parts[0]: @parts[1].Trim()
+ } + else if (!string.IsNullOrWhiteSpace(line)) + { + @line
+ } + else + { +
+ } + } + } +
+ + @entry.FormattedDate + +
+ } +
+
+ + + + + + + + + @Model.AnalysesTitle + + @Model.AddAnalysisText + + @foreach (var entry in Model.Analyses) + { + + + @entry.Tag + + + + @{ + var analysisLines = entry.Text.Split('\n'); + foreach (var line in analysisLines) + { + if (line.Contains(':')) + { + var parts = line.Split(':', 2); + @parts[0]: @parts[1].Trim()
+ } + else if (!string.IsNullOrWhiteSpace(line)) + { + @line
+ } + else + { +
+ } + } + } +
+ + @entry.FormattedDate + +
+ } +
+
+
diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/CustomerDetailOverviewViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/CustomerDetailOverviewViewComponent.cs new file mode 100644 index 0000000..f257e22 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/CustomerDetailOverviewViewComponent.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailOverviewViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailOverviewViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + + var model = new CustomerDetailOverviewViewModel + { + // Contact + ContactTitle = _localization.Get("customers.detail.contactInfo"), + Phone = customer.Contact.Phone, + PhoneLabel = _localization.Get("customers.detail.phone"), + Email = customer.Contact.Email, + EmailLabel = _localization.Get("customers.detail.email"), + Address = customer.Contact.Address, + AddressLabel = _localization.Get("customers.detail.address"), + ZipCity = $"{customer.Contact.Zip} {customer.Contact.City}", + ZipCityLabel = _localization.Get("customers.detail.zipCity"), + + // Profile + ProfileTitle = _localization.Get("customers.detail.profile"), + ProfileItems = customer.Profile.Select(p => new ProfileItemViewModel + { + Title = p.Title, + Value = p.Value + }).ToList(), + + // Marketing + MarketingTitle = _localization.Get("customers.detail.marketing"), + EmailMarketingLabel = _localization.Get("customers.detail.emailMarketing"), + EmailOptIn = customer.Marketing.EmailOptIn, + SmsMarketingLabel = _localization.Get("customers.detail.smsMarketing"), + SmsOptIn = customer.Marketing.SmsOptIn, + YesLabel = _localization.Get("common.yes"), + NoLabel = _localization.Get("common.no"), + + // Payment + PaymentTitle = _localization.Get("customers.detail.paymentSettings"), + RequirePrepaymentLabel = _localization.Get("customers.detail.requirePrepayment"), + RequirePrepaymentDesc = "Kunden skal betale fuldt beløb ved booking", + RequirePrepayment = customer.Payment.RequirePrepayment, + AllowPartialPaymentLabel = _localization.Get("customers.detail.allowPartialPayment"), + AllowPartialPaymentDesc = "Kunden kan vælge at betale et depositum", + AllowPartialPayment = customer.Payment.AllowPartialPayment, + + // Preferences + PreferencesTitle = _localization.Get("customers.detail.preferences"), + PreferredHairdresser = customer.Preferences.PreferredHairdresser, + PreferredHairdresserLabel = _localization.Get("customers.detail.preferredHairdresser"), + PreferredDays = customer.Preferences.PreferredDays, + PreferredDaysLabel = _localization.Get("customers.detail.preferredDay"), + SpecialRequests = customer.Preferences.SpecialRequests, + SpecialRequestsLabel = _localization.Get("customers.detail.specialRequests"), + + // Warnings + WarningsTitle = _localization.Get("customers.detail.warnings"), + Warnings = customer.Warnings.Select(w => new WarningItemViewModel + { + Title = w.Title, + Value = w.Value + }).ToList(), + + // Group & Relations + GroupRelationsTitle = _localization.Get("customers.detail.groupAndRelations"), + GroupLabel = "Kundegruppe:", + GroupId = customer.Group.GroupId, + GroupName = customer.Group.GroupName, + Relations = customer.Relations.Select(r => new RelationItemViewModel + { + Id = r.Id, + Name = r.Name, + Initials = r.Initials, + Type = r.Type + }).ToList(), + AddRelationText = "Tilføj relation" + }; + + return View(model); + } +} + +public class CustomerDetailOverviewViewModel +{ + // Contact + public required string ContactTitle { get; init; } + public required string Phone { get; init; } + public required string PhoneLabel { get; init; } + public required string Email { get; init; } + public required string EmailLabel { get; init; } + public required string Address { get; init; } + public required string AddressLabel { get; init; } + public required string ZipCity { get; init; } + public required string ZipCityLabel { get; init; } + + // Profile + public required string ProfileTitle { get; init; } + public List ProfileItems { get; init; } = new(); + + // Marketing + public required string MarketingTitle { get; init; } + public required string EmailMarketingLabel { get; init; } + public bool EmailOptIn { get; init; } + public required string SmsMarketingLabel { get; init; } + public bool SmsOptIn { get; init; } + public required string YesLabel { get; init; } + public required string NoLabel { get; init; } + + // Payment + public required string PaymentTitle { get; init; } + public required string RequirePrepaymentLabel { get; init; } + public required string RequirePrepaymentDesc { get; init; } + public bool RequirePrepayment { get; init; } + public required string AllowPartialPaymentLabel { get; init; } + public required string AllowPartialPaymentDesc { get; init; } + public bool AllowPartialPayment { get; init; } + + // Preferences + public required string PreferencesTitle { get; init; } + public required string PreferredHairdresser { get; init; } + public required string PreferredHairdresserLabel { get; init; } + public required string PreferredDays { get; init; } + public required string PreferredDaysLabel { get; init; } + public required string SpecialRequests { get; init; } + public required string SpecialRequestsLabel { get; init; } + + // Warnings + public required string WarningsTitle { get; init; } + public List Warnings { get; init; } = new(); + + // Group & Relations + public required string GroupRelationsTitle { get; init; } + public required string GroupLabel { get; init; } + public required string GroupId { get; init; } + public required string GroupName { get; init; } + public List Relations { get; init; } = new(); + public required string AddRelationText { get; init; } +} + +public class ProfileItemViewModel +{ + public required string Title { get; init; } + public required string Value { get; init; } +} + +public class WarningItemViewModel +{ + public required string Title { get; init; } + public required string Value { get; init; } +} + +public class RelationItemViewModel +{ + public required string Id { get; init; } + public required string Name { get; init; } + public required string Initials { get; init; } + public required string Type { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/Default.cshtml new file mode 100644 index 0000000..a023e28 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailOverview/Default.cshtml @@ -0,0 +1,201 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailOverviewViewModel + + + + + + + + + + @Model.ContactTitle + + + + + @Model.PhoneLabel + @Model.Phone + + + @Model.EmailLabel + @Model.Email + + + @Model.AddressLabel + @Model.Address + + + @Model.ZipCityLabel + @Model.ZipCity + + + + + + + + + + @Model.ProfileTitle + + + + @foreach (var item in Model.ProfileItems) + { + + @item.Title + @item.Value + + } + + + + + + + + + + + + @Model.MarketingTitle + + + + @Model.EmailMarketingLabel + + @Model.YesLabel + @Model.NoLabel + + + + @Model.SmsMarketingLabel + + @Model.YesLabel + @Model.NoLabel + + + + + + + + + + @Model.PaymentTitle + + + + + @Model.RequirePrepaymentLabel + @Model.RequirePrepaymentDesc + + + @Model.YesLabel + @Model.NoLabel + + + + + @Model.AllowPartialPaymentLabel + @Model.AllowPartialPaymentDesc + + + @Model.YesLabel + @Model.NoLabel + + + + + + + + + + @Model.PreferencesTitle + + + + + @Model.PreferredHairdresserLabel + @Model.PreferredHairdresser + + + @Model.PreferredDaysLabel + @Model.PreferredDays + + + @Model.SpecialRequestsLabel + @Model.SpecialRequests + + + + + + + + + + @Model.WarningsTitle + + + + @foreach (var warning in Model.Warnings) + { + + @warning.Title + @warning.Value + + } + + + + + + + + + @Model.GroupRelationsTitle + + + + @Model.GroupLabel + + + + Standard + Premium + Erhverv + Medarbejder + Familie & Venner + + + + + + @foreach (var relation in Model.Relations) + { + + @relation.Initials + + @relation.Name + @relation.Type + + + Abn + × + + + } + + + + @Model.AddRelationText + + + + + diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/CustomerDetailStatisticsViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/CustomerDetailStatisticsViewComponent.cs new file mode 100644 index 0000000..eff8e93 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/CustomerDetailStatisticsViewComponent.cs @@ -0,0 +1,162 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailStatisticsViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailStatisticsViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + var stats = customer.Statistics; + + // Calculate widths for the attendance bar + var total = stats.Attendance.Attended + stats.Attendance.Cancelled + stats.Attendance.NoShow; + + string attendedWidth, cancelledWidth, noShowWidth; + bool allZero = total == 0; + + if (allZero) + { + // All zero: gray bar with equal segments + attendedWidth = "33.33%"; + cancelledWidth = "33.33%"; + noShowWidth = "33.34%"; + } + else + { + // Calculate percentages, but use 12px minimum for zero values + var attendedPercent = stats.Attendance.Attended * 100 / total; + var cancelledPercent = stats.Attendance.Cancelled * 100 / total; + var noShowPercent = stats.Attendance.NoShow * 100 / total; + + attendedWidth = stats.Attendance.Attended == 0 ? "12px" : $"{attendedPercent}%"; + cancelledWidth = stats.Attendance.Cancelled == 0 ? "12px" : $"{cancelledPercent}%"; + noShowWidth = stats.Attendance.NoShow == 0 ? "12px" : $"{noShowPercent}%"; + } + + var model = new CustomerDetailStatisticsViewModel + { + // Attendance + AttendanceTitle = "Fremmøde & Pålidelighed", + Attended = stats.Attendance.Attended, + AttendedLabel = "Fremmøder", + Cancelled = stats.Attendance.Cancelled, + CancelledLabel = "Aflysninger", + NoShow = stats.Attendance.NoShow, + NoShowLabel = "No-shows", + ReliabilityPercent = stats.Attendance.ReliabilityPercent, + ReliabilityLabel = "Pålidelighed", + AttendedWidth = attendedWidth, + CancelledWidth = cancelledWidth, + NoShowWidth = noShowWidth, + AllZero = allZero, + + // Service patterns + ServicePatternsTitle = "Service-mønstre", + TopServicesLabel = "Top 3 Services", + TopServices = stats.TopServices.Select((s, i) => new TopItemViewModel + { + Rank = i + 1, + Name = s.Name, + Count = s.Count + }).ToList(), + TopProductsLabel = "Top 3 Produkter", + TopProducts = stats.TopProducts.Select((p, i) => new TopItemViewModel + { + Rank = i + 1, + Name = p.Name, + Count = p.Count + }).ToList(), + + // Booking behavior + BookingBehaviorTitle = "Booking-adfærd", + AvgBookingNotice = $"{stats.BookingBehavior.AvgBookingNoticeDays} dage", + AvgBookingNoticeLabel = "Gns. bookingvarsel", + PreferredDay = stats.BookingBehavior.PreferredDay, + PreferredDayLabel = "Foretrukken dag", + PreferredTimeSlot = stats.BookingBehavior.PreferredTimeSlot, + PreferredTimeSlotLabel = "Foretrukken tid", + OnlineBookingRate = $"{stats.BookingBehavior.OnlineBookingRate}%", + OnlineBookingRateLabel = "Online booking rate", + AvgCancellationNotice = $"{stats.BookingBehavior.AvgCancellationNoticeDays} dage", + AvgCancellationNoticeLabel = "Gns. aflysningsvarsel", + + // Loyalty + LoyaltyTitle = "Loyalitet", + CustomerSinceYears = $"{stats.Loyalty.CustomerSinceYears:0.0} ar".Replace(".", ","), + CustomerSinceYearsLabel = "Kunde siden", + DaysSinceLastVisit = stats.Loyalty.DaysSinceLastVisit, + DaysSinceLastVisitLabel = "Dage siden sidst", + ChurnRisk = stats.Loyalty.ChurnRisk, + ChurnRiskLabel = "Churn-risiko", + AvgIntervalDays = $"{stats.Loyalty.AvgIntervalDays} dage", + AvgIntervalDaysLabel = "Gns. interval" + }; + + return View(model); + } +} + +public class CustomerDetailStatisticsViewModel +{ + // Attendance + public required string AttendanceTitle { get; init; } + public int Attended { get; init; } + public required string AttendedLabel { get; init; } + public int Cancelled { get; init; } + public required string CancelledLabel { get; init; } + public int NoShow { get; init; } + public required string NoShowLabel { get; init; } + public int ReliabilityPercent { get; init; } + public required string ReliabilityLabel { get; init; } + public required string AttendedWidth { get; init; } + public required string CancelledWidth { get; init; } + public required string NoShowWidth { get; init; } + public bool AllZero { get; init; } + + // Service patterns + public required string ServicePatternsTitle { get; init; } + public required string TopServicesLabel { get; init; } + public List TopServices { get; init; } = new(); + public required string TopProductsLabel { get; init; } + public List TopProducts { get; init; } = new(); + + // Booking behavior + public required string BookingBehaviorTitle { get; init; } + public required string AvgBookingNotice { get; init; } + public required string AvgBookingNoticeLabel { get; init; } + public required string PreferredDay { get; init; } + public required string PreferredDayLabel { get; init; } + public required string PreferredTimeSlot { get; init; } + public required string PreferredTimeSlotLabel { get; init; } + public required string OnlineBookingRate { get; init; } + public required string OnlineBookingRateLabel { get; init; } + public required string AvgCancellationNotice { get; init; } + public required string AvgCancellationNoticeLabel { get; init; } + + // Loyalty + public required string LoyaltyTitle { get; init; } + public required string CustomerSinceYears { get; init; } + public required string CustomerSinceYearsLabel { get; init; } + public int DaysSinceLastVisit { get; init; } + public required string DaysSinceLastVisitLabel { get; init; } + public required string ChurnRisk { get; init; } + public required string ChurnRiskLabel { get; init; } + public required string AvgIntervalDays { get; init; } + public required string AvgIntervalDaysLabel { get; init; } +} + +public class TopItemViewModel +{ + public int Rank { get; init; } + public required string Name { get; init; } + public int Count { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/Default.cshtml new file mode 100644 index 0000000..0484d28 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailStatistics/Default.cshtml @@ -0,0 +1,142 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailStatisticsViewModel + + + + + + @Model.AttendanceTitle + +
+ + @Model.Attended + @Model.AttendedLabel + + + @Model.Cancelled + @Model.CancelledLabel + + + @Model.NoShow + @Model.NoShowLabel + + + @Model.ReliabilityPercent% + @Model.ReliabilityLabel + +
+ + @Model.Attended + @Model.Cancelled + @Model.NoShow + +
+ + + + + + + @Model.ServicePatternsTitle + +
+
+ @Model.TopServicesLabel + + @foreach (var service in Model.TopServices) + { + + @service.Rank + @service.Name + @(service.Count)x + + } + +
+
+ @Model.TopProductsLabel + + @foreach (var product in Model.TopProducts) + { + + @product.Rank + @product.Name + @(product.Count)x + + } + +
+
+
+
+ + + + + + + @Model.BookingBehaviorTitle + + + + @Model.AvgBookingNoticeLabel + @Model.AvgBookingNotice + + + @Model.PreferredDayLabel + @Model.PreferredDay + + + @Model.PreferredTimeSlotLabel + @Model.PreferredTimeSlot + + + @Model.OnlineBookingRateLabel + @Model.OnlineBookingRate + + + @Model.AvgCancellationNoticeLabel + @Model.AvgCancellationNotice + + + + + + + + @Model.LoyaltyTitle + +
+ + @Model.CustomerSinceYears + @Model.CustomerSinceYearsLabel + + + @Model.DaysSinceLastVisit + @Model.DaysSinceLastVisitLabel + + + + + + @{ + var riskText = Model.ChurnRisk switch + { + "low" => "Lav", + "medium" => "Medium", + "high" => "Hoj", + _ => Model.ChurnRisk + }; + } + @riskText + + + @Model.ChurnRiskLabel + + + @Model.AvgIntervalDays + @Model.AvgIntervalDaysLabel + +
+
+
+
diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/CustomerDetailViewViewComponent.cs b/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/CustomerDetailViewViewComponent.cs new file mode 100644 index 0000000..5b41bbf --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/CustomerDetailViewViewComponent.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using PlanTempus.Application.Features.Localization.Services; + +namespace PlanTempus.Application.Features.Customers.Components; + +public class CustomerDetailViewViewComponent : ViewComponent +{ + private readonly ILocalizationService _localization; + + public CustomerDetailViewViewComponent(ILocalizationService localization) + { + _localization = localization; + } + + public IViewComponentResult Invoke(string customerId) + { + var customer = CustomerDetailCatalog.Get(customerId); + + var model = new CustomerDetailViewViewModel + { + CustomerId = customer.Id, + CustomerName = customer.Header.Name, + BackText = _localization.Get("customers.detail.back"), + DeleteButtonText = _localization.Get("customers.detail.delete"), + SaveButtonText = _localization.Get("customers.detail.save"), + TabOverview = _localization.Get("customers.detail.tabs.overview"), + TabEconomy = _localization.Get("customers.detail.tabs.economy"), + TabStatistics = _localization.Get("customers.detail.tabs.statistics"), + TabJournal = _localization.Get("customers.detail.tabs.journal"), + TabAppointments = _localization.Get("customers.detail.tabs.appointments"), + TabGiftcards = _localization.Get("customers.detail.tabs.giftcards"), + TabActivity = _localization.Get("customers.detail.tabs.activitylog") + }; + + return View(model); + } +} + +public class CustomerDetailViewViewModel +{ + public required string CustomerId { get; init; } + public required string CustomerName { get; init; } + public required string BackText { get; init; } + public required string DeleteButtonText { get; init; } + public required string SaveButtonText { get; init; } + public required string TabOverview { get; init; } + public required string TabEconomy { get; init; } + public required string TabStatistics { get; init; } + public required string TabJournal { get; init; } + public required string TabAppointments { get; init; } + public required string TabGiftcards { get; init; } + public required string TabActivity { get; init; } +} diff --git a/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/Default.cshtml b/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/Default.cshtml new file mode 100644 index 0000000..f5c0ea0 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Components/CustomerDetailView/Default.cshtml @@ -0,0 +1,123 @@ +@model PlanTempus.Application.Features.Customers.Components.CustomerDetailViewViewModel + + + + + + + + + + @Model.BackText + + + + + @Model.DeleteButtonText + + + + @Model.SaveButtonText + + + + + + @await Component.InvokeAsync("CustomerDetailHeader", Model.CustomerId) + + + + + @Model.TabOverview + @Model.TabEconomy + @Model.TabStatistics + @Model.TabJournal + @Model.TabAppointments + @Model.TabGiftcards + @Model.TabActivity + + + + + + + @await Component.InvokeAsync("CustomerDetailOverview", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailEconomy", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailStatistics", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailJournal", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailAppointments", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailGiftcards", Model.CustomerId) + + + + + + @await Component.InvokeAsync("CustomerDetailActivity", Model.CustomerId) + + + + + diff --git a/PlanTempus.Application/Features/Customers/Data/customerDetailMock.json b/PlanTempus.Application/Features/Customers/Data/customerDetailMock.json new file mode 100644 index 0000000..0f66107 --- /dev/null +++ b/PlanTempus.Application/Features/Customers/Data/customerDetailMock.json @@ -0,0 +1,1228 @@ +{ + "anna-jensen": { + "id": "anna-jensen", + "header": { + "initials": "AJ", + "name": "Anna Jensen", + "customerSince": "2023-05-15", + "tags": ["stamkunde", "farve"], + "bookingAllowed": true, + "facts": { + "visits": 24, + "avgIntervalDays": 28, + "preferredHairdresser": "Nina K.", + "totalRevenue": 18750 + } + }, + "contact": { + "phone": "+45 22 33 44 55", + "email": "anna.j@hotmail.dk", + "address": "Skovvej 8", + "zip": "2800", + "city": "Lyngby" + }, + "profile": [ + { "title": "Hårtype", "value": "Tykt - Glat" }, + { "title": "Porøsitet", "value": "Lav" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Mørkebrun (4)" }, + { "title": "Nuværende farve", "value": "Varm brun med highlights" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": true }, + "preferences": { + "preferredHairdresser": "Nina K.", + "preferredDays": "Fredag", + "specialRequests": "Ønsker altid en kop te under behandling. Foretrækker varme toner." + }, + "warnings": [ + { "title": "Hårskade", "value": "Let skadet i spidserne fra tidligere afblegning - vær forsigtig med kemiske behandlinger" } + ], + "group": { "groupId": "premium", "groupName": "Premium" }, + "relations": [ + { "id": "peter-jensen", "name": "Peter Jensen", "initials": "PJ", "type": "Ægtefælle" }, + { "id": "mille-jensen", "name": "Mille Jensen", "initials": "MJ", "type": "Datter" } + ], + "statistics": { + "attendance": { "attended": 24, "cancelled": 2, "noShow": 0, "reliabilityPercent": 92 }, + "topServices": [ + { "name": "Klip + Farve", "count": 14 }, + { "name": "Highlights", "count": 6 }, + { "name": "Klip", "count": 4 } + ], + "topProducts": [ + { "name": "Olaplex No. 3", "count": 8 }, + { "name": "Kerastase Elixir", "count": 4 }, + { "name": "Hårkur", "count": 3 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 10, + "preferredDay": "Fredag", + "preferredTimeSlot": "14:00 - 16:00", + "onlineBookingRate": 85, + "avgCancellationNoticeDays": 3 + }, + "loyalty": { "customerSinceYears": 1.7, "daysSinceLastVisit": 18, "churnRisk": "low", "avgIntervalDays": 28 } + }, + "journal": [ + { + "id": "aj-entry-1", + "type": "note", + "tag": "Note", + "subtags": [], + "text": "Anna er meget tilfreds med den nye varme farvetone. Hun ønsker at holde denne stil fremover. Husk te med mælk!", + "date": "2026-01-06", + "author": "Nina" + }, + { + "id": "aj-entry-2", + "type": "colorFormula", + "tag": "Farveformel", + "subtags": [], + "text": "Måltone: Varm brun med kobber-highlights\nBund: 5/7 + 5/3 (2:1)\nHighlights: 8/43\nOxidant: 6% (bund), 9% (highlights)\nVirketid: 40 min\nPlacering: Bund hele håret, highlights i ansigtsindrammende partier\n\nResultat: Perfekt! Anna elskede det varme skær.", + "date": "2026-01-06", + "author": "Nina" + }, + { + "id": "aj-entry-3", + "type": "note", + "tag": "Advarsel", + "subtags": ["Hårskade"], + "text": "OBS: Let hårskade i spidserne fra gammel afblegning (før hun kom til os). Undgå aggressive behandlinger og tilbyd altid Olaplex.", + "date": "2023-06-20", + "author": "Emma" + }, + { + "id": "aj-entry-4", + "type": "analysis", + "tag": "Håranalyse", + "subtags": [], + "text": "Tilstand: God i rod og midterlængder, spidser let tørre\nPorøsitet: Lav til medium\nElasticitet: God\nHovedbudstilstand: Sund, ingen tørhed\n\nAnbefaling: Fortsat Olaplex hver behandling. Klip 1-2 cm af for at fjerne mest skadet område.", + "date": "2025-09-15", + "author": "Nina" + }, + { + "id": "aj-entry-5", + "type": "colorFormula", + "tag": "Farveformel", + "subtags": ["Tidligere"], + "text": "Måltone: Varm chokoladebrun\nFormel: 4/7 + 5/3 (1:1)\nOxidant: 6%\nVirketid: 35 min\nPlacering: Hele håret\n\nResultat: God dækning, kunde ønsker lidt lysere næste gang.", + "date": "2025-06-12", + "author": "Nina" + }, + { + "id": "aj-entry-6", + "type": "note", + "tag": "Note", + "subtags": ["Produkt"], + "text": "Anbefalet Kerastase Elixir til hjemmebrug. Anna købte og er meget glad for resultatet. Fortsæt anbefaling.", + "date": "2025-04-18", + "author": "Nina" + } + ], + "appointments": { + "upcoming": [ + { + "date": "2026-02-07", + "time": "14:00", + "service": "Klip + Farve + Olaplex", + "hairdresser": "Nina K.", + "duration": "2t 30m" + }, + { + "date": "2026-03-21", + "time": "15:00", + "service": "Klip + Highlights", + "hairdresser": "Nina K.", + "duration": "2t 45m" + } + ], + "history": [ + { "date": "2026-01-06", "service": "Klip + Farve + Olaplex", "hairdresser": "Nina K.", "duration": "2t 30m", "price": 1850 }, + { "date": "2025-12-06", "service": "Klip", "hairdresser": "Nina K.", "duration": "45 min", "price": 550 }, + { "date": "2025-10-25", "service": "Klip + Farve", "hairdresser": "Nina K.", "duration": "2 timer", "price": 1450 }, + { "date": "2025-09-15", "service": "Håranalyse + Behandling", "hairdresser": "Nina K.", "duration": "1 time", "price": 650 }, + { "date": "2025-08-08", "service": "Highlights + Klip", "hairdresser": "Nina K.", "duration": "2t 45m", "price": 1950 }, + { "date": "2025-06-12", "service": "Klip + Farve", "hairdresser": "Nina K.", "duration": "2 timer", "price": 1450 }, + { "date": "2025-04-18", "service": "Klip + Olaplex", "hairdresser": "Nina K.", "duration": "1t 15m", "price": 850 }, + { "date": "2025-02-28", "service": "Klip + Farve", "hairdresser": "Nina K.", "duration": "2 timer", "price": 1450 }, + { "date": "2025-01-10", "service": "Highlights", "hairdresser": "Emma L.", "duration": "2t 30m", "price": 1750 }, + { "date": "2024-11-15", "service": "Klip + Farve", "hairdresser": "Nina K.", "duration": "2 timer", "price": 1400 } + ] + }, + "giftcards": { + "active": [ + { + "id": "GK-2025-1234", + "type": "giftcard", + "label": "Gavekort #GK-2025-1234", + "originalValue": 1000, + "currentBalance": 650, + "expiresAt": "2026-06-15" + }, + { + "id": "klip-5", + "type": "punchcard", + "label": "5-klip kort", + "totalPunches": 5, + "usedPunches": 3, + "expiresAt": "2026-12-31" + } + ], + "expired": [ + { + "id": "GK-2023-0567", + "type": "giftcard", + "label": "Gavekort #GK-2023-0567", + "originalValue": 500, + "currentBalance": 0, + "expiresAt": "2024-12-01" + } + ] + }, + "economy": { + "currentYear": { "year": 2025, "total": 12450 }, + "lastYear": { "year": 2024, "total": 9800 }, + "avgPerVisit": 889, + "avgPerMonth": 1038, + "chartData": { + "categories": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], + "series": [ + { + "name": "Services", + "color": "#00897b", + "data": [ + { "x": "Feb", "y": 1450 }, + { "x": "Apr", "y": 1200 }, + { "x": "Jun", "y": 1450 }, + { "x": "Aug", "y": 1950 }, + { "x": "Okt", "y": 550 }, + { "x": "Nov", "y": 1200 }, + { "x": "Dec", "y": 1450 } + ] + }, + { + "name": "Produkter", + "color": "#1976d2", + "data": [ + { "x": "Mar", "y": 349 }, + { "x": "Maj", "y": 180 }, + { "x": "Sep", "y": 425 }, + { "x": "Dec", "y": 349 } + ] + } + ] + }, + "purchases": [ + { "invoice": "#1892", "date": "2025-12-09", "time": "14:30", "employee": "Nina K.", "services": "Klip + Farve", "type": "service", "amount": 1450 }, + { "invoice": "#1892", "date": "2025-12-09", "time": "14:30", "employee": "Nina K.", "services": "Olaplex No. 3", "type": "product", "amount": 349 }, + { "invoice": "#1845", "date": "2025-11-12", "time": "10:00", "employee": "Nina K.", "services": "Farve", "type": "service", "amount": 1200 }, + { "invoice": "#1798", "date": "2025-10-15", "time": "15:45", "employee": "Nina K.", "services": "Klip", "type": "service", "amount": 550 }, + { "invoice": "#1756", "date": "2025-09-20", "time": "11:15", "employee": "Emma L.", "services": "Kerastase Elixir", "type": "product", "amount": 425 }, + { "invoice": "#1701", "date": "2025-08-08", "time": "13:00", "employee": "Nina K.", "services": "Highlights + Klip", "type": "service", "amount": 1950 } + ] + }, + "activity": [ + { + "date": "2026-01-22", + "time": "10:30", + "type": "communication", + "icon": "chat-text", + "title": "SMS påmindelse sendt om aftale d. 7. februar", + "actor": "System", + "badges": ["auto"] + }, + { + "date": "2026-01-06", + "time": "16:40", + "type": "payment", + "icon": "credit-card", + "title": "Betaling modtaget — 1.850 kr", + "actor": "Dankort ****4521", + "badges": [] + }, + { + "date": "2026-01-06", + "time": "16:35", + "type": "edit", + "icon": "note-pencil", + "title": "Farveformel opdateret med ny varm tone", + "actor": "Nina K.", + "badges": [] + }, + { + "date": "2026-01-06", + "time": "16:30", + "type": "booking", + "icon": "check-circle", + "title": "Booking gennemført", + "actor": "Klip + Farve + Olaplex · Nina K.", + "badges": [] + }, + { + "date": "2026-01-06", + "time": "14:00", + "type": "customer", + "icon": "key", + "title": "Kunde ankom til aftale", + "actor": null, + "badges": [] + }, + { + "date": "2026-01-03", + "time": "19:22", + "type": "booking", + "icon": "calendar-plus", + "title": "Ny booking oprettet via online booking", + "actor": "7. feb 2026 kl. 14:00", + "badges": ["online"] + }, + { + "date": "2025-12-20", + "time": "11:15", + "type": "communication", + "icon": "envelope", + "title": "Julehilsen email sendt", + "actor": "System", + "badges": ["auto", "marketing"] + }, + { + "date": "2025-12-06", + "time": "15:50", + "type": "payment", + "icon": "credit-card", + "title": "Betaling modtaget — 550 kr", + "actor": "MobilePay", + "badges": [] + }, + { + "date": "2025-12-06", + "time": "15:45", + "type": "booking", + "icon": "check-circle", + "title": "Booking gennemført", + "actor": "Klip · Nina K.", + "badges": [] + }, + { + "date": "2025-10-25", + "time": "17:05", + "type": "payment", + "icon": "credit-card", + "title": "Betaling modtaget — 1.100 kr (350 kr fra gavekort)", + "actor": "Dankort ****4521", + "badges": ["gavekort"] + }, + { + "date": "2025-10-25", + "time": "17:00", + "type": "edit", + "icon": "gift", + "title": "Gavekort #GK-2025-1234 tilføjet - Gave fra ægtefælle", + "actor": "Reception", + "badges": [] + }, + { + "date": "2025-09-15", + "time": "11:30", + "type": "edit", + "icon": "clipboard-text", + "title": "Håranalyse udført og gemt", + "actor": "Nina K.", + "badges": [] + }, + { + "date": "2025-08-01", + "time": "09:00", + "type": "customer", + "icon": "star", + "title": "Opgraderet til Premium kundegruppe", + "actor": "System", + "badges": [] + }, + { + "date": "2023-06-20", + "time": "10:45", + "type": "warning", + "icon": "warning", + "title": "Advarsel registreret — Hårskade noteret", + "actor": "Emma L.", + "badges": [] + }, + { + "date": "2023-05-15", + "time": "14:00", + "type": "customer", + "icon": "user-plus", + "title": "Kunde oprettet via telefon", + "actor": "Reception", + "badges": [] + } + ] + }, + + "camilla-holm": { + "id": "camilla-holm", + "header": { + "initials": "CH", + "name": "Camilla Holm", + "customerSince": "2022-12-01", + "tags": ["vip"], + "bookingAllowed": true, + "facts": { + "visits": 25, + "avgIntervalDays": 35, + "preferredHairdresser": "Emma L.", + "totalRevenue": 28500 + } + }, + "contact": { + "phone": "+45 66 77 88 99", + "email": "camilla.h@outlook.dk", + "address": "Strandvejen 45", + "zip": "2900", + "city": "Hellerup" + }, + "profile": [ + { "title": "Hartype", "value": "Fint - Bolget" }, + { "title": "Porositet", "value": "Hoj" }, + { "title": "Hovedbund", "value": "Tor" }, + { "title": "Naturlig farve", "value": "Lysblond (8)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Emma L.", + "preferredDays": "Tirsdag", + "specialRequests": "Onsker altid en kop kaffe" + }, + "warnings": [], + "group": { "groupId": "premium", "groupName": "Premium" }, + "relations": [], + "statistics": { + "attendance": { "attended": 25, "cancelled": 1, "noShow": 0, "reliabilityPercent": 96 }, + "topServices": [ + { "name": "Klip + Farve", "count": 15 }, + { "name": "Behandling", "count": 8 }, + { "name": "Klip", "count": 2 } + ], + "topProducts": [ + { "name": "Olaplex No. 3", "count": 4 }, + { "name": "Harkur", "count": 3 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 14, + "preferredDay": "Tirsdag", + "preferredTimeSlot": "10:00 - 12:00", + "onlineBookingRate": 60, + "avgCancellationNoticeDays": 5 + }, + "loyalty": { "customerSinceYears": 2.1, "daysSinceLastVisit": 88, "churnRisk": "low", "avgIntervalDays": 35 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "emma-larsen": { + "id": "emma-larsen", + "header": { + "initials": "EL", + "name": "Emma Larsen", + "customerSince": "2024-06-01", + "tags": [], + "bookingAllowed": true, + "facts": { + "visits": 8, + "avgIntervalDays": 30, + "preferredHairdresser": "Nina K.", + "totalRevenue": 5600 + } + }, + "contact": { + "phone": "+45 12 34 56 78", + "email": "emma.l@gmail.com", + "address": "Norrebrogade 100", + "zip": "2200", + "city": "Kobenhavn N" + }, + "profile": [ + { "title": "Hartype", "value": "Medium - Glat" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Morkbrun (3)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": false }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Nina K.", + "preferredDays": "Lordag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 8, "cancelled": 0, "noShow": 0, "reliabilityPercent": 100 }, + "topServices": [ + { "name": "Klip", "count": 6 }, + { "name": "Fon", "count": 2 } + ], + "topProducts": [], + "bookingBehavior": { + "avgBookingNoticeDays": 5, + "preferredDay": "Lordag", + "preferredTimeSlot": "11:00 - 13:00", + "onlineBookingRate": 100, + "avgCancellationNoticeDays": 0 + }, + "loyalty": { "customerSinceYears": 0.6, "daysSinceLastVisit": 50, "churnRisk": "low", "avgIntervalDays": 30 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "freja-christensen": { + "id": "freja-christensen", + "header": { + "initials": "FC", + "name": "Freja Christensen", + "customerSince": "2022-08-01", + "tags": ["vip", "allergi"], + "bookingAllowed": true, + "facts": { + "visits": 31, + "avgIntervalDays": 28, + "preferredHairdresser": "Emma L.", + "totalRevenue": 42000 + } + }, + "contact": { + "phone": "+45 55 66 77 88", + "email": "freja.c@outlook.dk", + "address": "Frederiksberggade 50", + "zip": "1459", + "city": "Kobenhavn K" + }, + "profile": [ + { "title": "Hartype", "value": "Tykt - Krollet" }, + { "title": "Porositet", "value": "Hoj" }, + { "title": "Hovedbund", "value": "Sensitiv" }, + { "title": "Naturlig farve", "value": "Rod (7)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Emma L.", + "preferredDays": "Onsdag", + "specialRequests": "Skal altid have allergitjek for farve" + }, + "warnings": [ + { "title": "Allergi", "value": "Allergisk over for PPD - brug kun PPD-fri farver" } + ], + "group": { "groupId": "premium", "groupName": "Premium" }, + "relations": [], + "statistics": { + "attendance": { "attended": 31, "cancelled": 2, "noShow": 0, "reliabilityPercent": 94 }, + "topServices": [ + { "name": "Klip + Farve", "count": 20 }, + { "name": "Behandling", "count": 8 }, + { "name": "Klip", "count": 3 } + ], + "topProducts": [ + { "name": "Sensitiv Shampoo", "count": 6 }, + { "name": "Harkur", "count": 4 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 10, + "preferredDay": "Onsdag", + "preferredTimeSlot": "09:00 - 11:00", + "onlineBookingRate": 80, + "avgCancellationNoticeDays": 3 + }, + "loyalty": { "customerSinceYears": 2.4, "daysSinceLastVisit": 65, "churnRisk": "low", "avgIntervalDays": 28 } + }, + "journal": [ + { + "id": "entry-1", + "type": "note", + "tag": "Advarsel", + "subtags": ["Allergi"], + "text": "PPD ALLERGI - Brug KUN PPD-fri farver. Kunden havde allergisk reaktion i 2021.", + "date": "2022-08-15", + "author": "Emma" + } + ], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "ida-andersen": { + "id": "ida-andersen", + "header": { + "initials": "IA", + "name": "Ida Andersen", + "customerSince": "2025-10-01", + "tags": ["ny"], + "bookingAllowed": true, + "facts": { + "visits": 3, + "avgIntervalDays": 21, + "preferredHairdresser": "Sofie M.", + "totalRevenue": 1650 + } + }, + "contact": { + "phone": "+45 11 22 33 44", + "email": "ida@firma.dk", + "address": "Osterbrogade 80", + "zip": "2100", + "city": "Kobenhavn O" + }, + "profile": [ + { "title": "Hartype", "value": "Fint - Glat" }, + { "title": "Porositet", "value": "Lav" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Blond (7)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": false }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Sofie M.", + "preferredDays": "Torsdag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 3, "cancelled": 0, "noShow": 0, "reliabilityPercent": 100 }, + "topServices": [ + { "name": "Klip", "count": 3 } + ], + "topProducts": [], + "bookingBehavior": { + "avgBookingNoticeDays": 3, + "preferredDay": "Torsdag", + "preferredTimeSlot": "16:00 - 18:00", + "onlineBookingRate": 100, + "avgCancellationNoticeDays": 0 + }, + "loyalty": { "customerSinceYears": 0.2, "daysSinceLastVisit": 57, "churnRisk": "medium", "avgIntervalDays": 21 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "katrine-berg": { + "id": "katrine-berg", + "header": { + "initials": "KB", + "name": "Katrine Berg", + "customerSince": "2024-04-01", + "tags": [], + "bookingAllowed": true, + "facts": { + "visits": 12, + "avgIntervalDays": 28, + "preferredHairdresser": "Nina K.", + "totalRevenue": 9600 + } + }, + "contact": { + "phone": "+45 55 66 77 88", + "email": "katrine.b@firma.dk", + "address": "Vesterbrogade 120", + "zip": "1620", + "city": "Kobenhavn V" + }, + "profile": [ + { "title": "Hartype", "value": "Medium - Bolget" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Morkblond (6)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Nina K.", + "preferredDays": "Mandag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 12, "cancelled": 1, "noShow": 0, "reliabilityPercent": 92 }, + "topServices": [ + { "name": "Klip", "count": 8 }, + { "name": "Farve", "count": 4 } + ], + "topProducts": [], + "bookingBehavior": { + "avgBookingNoticeDays": 7, + "preferredDay": "Mandag", + "preferredTimeSlot": "10:00 - 12:00", + "onlineBookingRate": 75, + "avgCancellationNoticeDays": 2 + }, + "loyalty": { "customerSinceYears": 0.8, "daysSinceLastVisit": 84, "churnRisk": "medium", "avgIntervalDays": 28 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "line-frost": { + "id": "line-frost", + "header": { + "initials": "LF", + "name": "Line Frost", + "customerSince": "2024-05-01", + "tags": ["sensitiv"], + "bookingAllowed": true, + "facts": { + "visits": 9, + "avgIntervalDays": 35, + "preferredHairdresser": "Nina K.", + "totalRevenue": 7200 + } + }, + "contact": { + "phone": "+45 88 99 00 11", + "email": "line.f@mail.dk", + "address": "Amagerbrogade 55", + "zip": "2300", + "city": "Kobenhavn S" + }, + "profile": [ + { "title": "Hartype", "value": "Fint - Glat" }, + { "title": "Porositet", "value": "Hoj" }, + { "title": "Hovedbund", "value": "Sensitiv" }, + { "title": "Naturlig farve", "value": "Lysblond (8)" } + ], + "marketing": { "emailOptIn": false, "smsOptIn": false }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Nina K.", + "preferredDays": "Tirsdag", + "specialRequests": "Sensitiv hovedbund - brug milde produkter" + }, + "warnings": [ + { "title": "Sensitiv hovedbund", "value": "Brug kun milde, parfumefri produkter" } + ], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 9, "cancelled": 0, "noShow": 0, "reliabilityPercent": 100 }, + "topServices": [ + { "name": "Klip", "count": 7 }, + { "name": "Behandling", "count": 2 } + ], + "topProducts": [ + { "name": "Sensitiv Shampoo", "count": 2 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 10, + "preferredDay": "Tirsdag", + "preferredTimeSlot": "13:00 - 15:00", + "onlineBookingRate": 90, + "avgCancellationNoticeDays": 0 + }, + "loyalty": { "customerSinceYears": 0.7, "daysSinceLastVisit": 101, "churnRisk": "medium", "avgIntervalDays": 35 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "louise-hansen": { + "id": "louise-hansen", + "header": { + "initials": "LH", + "name": "Louise Hansen", + "customerSince": "2023-02-01", + "tags": ["stamkunde"], + "bookingAllowed": true, + "facts": { + "visits": 18, + "avgIntervalDays": 42, + "preferredHairdresser": "Emma L.", + "totalRevenue": 18900 + } + }, + "contact": { + "phone": "+45 33 44 55 66", + "email": "louise.h@gmail.com", + "address": "Gammel Kongevej 80", + "zip": "1850", + "city": "Frederiksberg C" + }, + "profile": [ + { "title": "Hartype", "value": "Tykt - Bolget" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Brun (5)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Emma L.", + "preferredDays": "Onsdag/Torsdag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 18, "cancelled": 1, "noShow": 0, "reliabilityPercent": 95 }, + "topServices": [ + { "name": "Klip + Farve", "count": 10 }, + { "name": "Klip", "count": 6 }, + { "name": "Fon", "count": 2 } + ], + "topProducts": [ + { "name": "Harkur", "count": 3 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 14, + "preferredDay": "Onsdag", + "preferredTimeSlot": "10:00 - 12:00", + "onlineBookingRate": 50, + "avgCancellationNoticeDays": 4 + }, + "loyalty": { "customerSinceYears": 1.9, "daysSinceLastVisit": 75, "churnRisk": "low", "avgIntervalDays": 42 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "maja-petersen": { + "id": "maja-petersen", + "header": { + "initials": "MP", + "name": "Maja Petersen", + "customerSince": "2023-01-01", + "tags": ["stamkunde"], + "bookingAllowed": true, + "facts": { + "visits": 22, + "avgIntervalDays": 35, + "preferredHairdresser": "Emma L.", + "totalRevenue": 24200 + } + }, + "contact": { + "phone": "+45 98 76 54 32", + "email": "maja.p@mail.dk", + "address": "Jagtvej 150", + "zip": "2200", + "city": "Kobenhavn N" + }, + "profile": [ + { "title": "Hartype", "value": "Medium - Glat" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Morkblond (6)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Emma L.", + "preferredDays": "Fredag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 22, "cancelled": 2, "noShow": 0, "reliabilityPercent": 92 }, + "topServices": [ + { "name": "Klip + Farve", "count": 12 }, + { "name": "Klip", "count": 8 }, + { "name": "Behandling", "count": 2 } + ], + "topProducts": [ + { "name": "Olaplex No. 3", "count": 4 }, + { "name": "Shampoo", "count": 2 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 10, + "preferredDay": "Fredag", + "preferredTimeSlot": "14:00 - 16:00", + "onlineBookingRate": 70, + "avgCancellationNoticeDays": 3 + }, + "loyalty": { "customerSinceYears": 2.0, "daysSinceLastVisit": 54, "churnRisk": "low", "avgIntervalDays": 35 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "maria-olsen": { + "id": "maria-olsen", + "header": { + "initials": "MO", + "name": "Maria Olsen", + "customerSince": "2025-11-01", + "tags": ["ny"], + "bookingAllowed": true, + "facts": { + "visits": 2, + "avgIntervalDays": 14, + "preferredHairdresser": "Sofie M.", + "totalRevenue": 1100 + } + }, + "contact": { + "phone": "+45 44 55 66 77", + "email": "maria.o@mail.dk", + "address": "Istedgade 30", + "zip": "1650", + "city": "Kobenhavn V" + }, + "profile": [ + { "title": "Hartype", "value": "Fint - Bolget" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Blond (7)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": false }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Sofie M.", + "preferredDays": "Lordag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 2, "cancelled": 0, "noShow": 0, "reliabilityPercent": 100 }, + "topServices": [ + { "name": "Klip", "count": 2 } + ], + "topProducts": [], + "bookingBehavior": { + "avgBookingNoticeDays": 2, + "preferredDay": "Lordag", + "preferredTimeSlot": "10:00 - 12:00", + "onlineBookingRate": 100, + "avgCancellationNoticeDays": 0 + }, + "loyalty": { "customerSinceYears": 0.1, "daysSinceLastVisit": 80, "churnRisk": "high", "avgIntervalDays": 14 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "rikke-skov": { + "id": "rikke-skov", + "header": { + "initials": "RS", + "name": "Rikke Skov", + "customerSince": "2025-08-01", + "tags": [], + "bookingAllowed": true, + "facts": { + "visits": 4, + "avgIntervalDays": 28, + "preferredHairdresser": "Sofie M.", + "totalRevenue": 2800 + } + }, + "contact": { + "phone": "+45 77 88 99 00", + "email": "rikke.s@gmail.com", + "address": "Valby Langgade 100", + "zip": "2500", + "city": "Valby" + }, + "profile": [ + { "title": "Hartype", "value": "Medium - Glat" }, + { "title": "Porositet", "value": "Lav" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Brun (4)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": true }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Sofie M.", + "preferredDays": "Torsdag", + "specialRequests": "" + }, + "warnings": [], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [], + "statistics": { + "attendance": { "attended": 4, "cancelled": 0, "noShow": 0, "reliabilityPercent": 100 }, + "topServices": [ + { "name": "Klip", "count": 4 } + ], + "topProducts": [], + "bookingBehavior": { + "avgBookingNoticeDays": 5, + "preferredDay": "Torsdag", + "preferredTimeSlot": "15:00 - 17:00", + "onlineBookingRate": 100, + "avgCancellationNoticeDays": 0 + }, + "loyalty": { "customerSinceYears": 0.4, "daysSinceLastVisit": 96, "churnRisk": "medium", "avgIntervalDays": 28 } + }, + "journal": [], + "appointments": { "upcoming": [], "history": [] }, + "giftcards": { "active": [], "expired": [] }, + "activity": [] + }, + + "sofie-nielsen": { + "id": "sofie-nielsen", + "header": { + "initials": "SN", + "name": "Sofie Nielsen", + "customerSince": "2024-03-01", + "tags": ["vip"], + "bookingAllowed": true, + "facts": { + "visits": 14, + "avgIntervalDays": 32, + "preferredHairdresser": "Emma L.", + "totalRevenue": 12450 + } + }, + "economy": { + "currentYear": { "year": 2025, "total": 8450 }, + "lastYear": { "year": 2024, "total": 4000 }, + "avgPerVisit": 604, + "avgPerMonth": 704, + "chartData": { + "categories": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"], + "series": [ + { + "name": "Services", + "color": "#00897b", + "data": [ + { "x": "Mar", "y": 550 }, + { "x": "Jun", "y": 1200 }, + { "x": "Aug", "y": 1600 }, + { "x": "Okt", "y": 550 }, + { "x": "Nov", "y": 1200 }, + { "x": "Dec", "y": 1450 } + ] + }, + { + "name": "Produkter", + "color": "#1976d2", + "data": [ + { "x": "Apr", "y": 250 }, + { "x": "Sep", "y": 349 } + ] + } + ] + }, + "purchases": [ + { "invoice": "#1893", "date": "2025-12-09", "time": "10:30", "employee": "Emma L.", "services": "Klip + Farve", "type": "service", "amount": 1450 }, + { "invoice": "#1846", "date": "2025-11-12", "time": "14:00", "employee": "Emma L.", "services": "Farve", "type": "service", "amount": 1200 }, + { "invoice": "#1799", "date": "2025-10-15", "time": "11:45", "employee": "Emma L.", "services": "Klip", "type": "service", "amount": 550 }, + { "invoice": "#1757", "date": "2025-09-20", "time": "16:00", "employee": "Nina K.", "services": "Olaplex No. 3", "type": "product", "amount": 349 }, + { "invoice": "#1702", "date": "2025-08-15", "time": "09:30", "employee": "Emma L.", "services": "Farve + Klip", "type": "service", "amount": 1600 } + ] + }, + "contact": { + "phone": "+45 23 45 67 89", + "email": "sofie@email.dk", + "address": "Hovedgaden 12", + "zip": "2100", + "city": "Kobenhavn O" + }, + "profile": [ + { "title": "Hartype", "value": "Medium - Bolget" }, + { "title": "Porositet", "value": "Medium" }, + { "title": "Hovedbund", "value": "Normal" }, + { "title": "Naturlig farve", "value": "Morkblond (6)" } + ], + "marketing": { "emailOptIn": true, "smsOptIn": false }, + "payment": { "requirePrepayment": false, "allowPartialPayment": false }, + "preferences": { + "preferredHairdresser": "Emma L.", + "preferredDays": "Tirsdag/Torsdag", + "specialRequests": "Foretraekker kold tone, ikke for mork" + }, + "warnings": [ + { "title": "Allergier / Folsomhed", "value": "Parfumeallergi - brug uparfumerede produkter" } + ], + "group": { "groupId": "standard", "groupName": "Standard" }, + "relations": [ + { "id": "emil-nielsen", "name": "Emil Nielsen", "initials": "EN", "type": "Barn" }, + { "id": "luna-nielsen", "name": "Luna Nielsen", "initials": "LN", "type": "Barn" } + ], + "statistics": { + "attendance": { "attended": 47, "cancelled": 3, "noShow": 1, "reliabilityPercent": 92 }, + "topServices": [ + { "name": "Klip + Farve", "count": 12 }, + { "name": "Farve", "count": 8 }, + { "name": "Klip", "count": 6 } + ], + "topProducts": [ + { "name": "Olaplex No. 3", "count": 5 }, + { "name": "Shampoo", "count": 3 }, + { "name": "Harkur", "count": 2 } + ], + "bookingBehavior": { + "avgBookingNoticeDays": 5, + "preferredDay": "Tirsdag", + "preferredTimeSlot": "10:00 - 12:00", + "onlineBookingRate": 78, + "avgCancellationNoticeDays": 2 + }, + "loyalty": { "customerSinceYears": 1.8, "daysSinceLastVisit": 13, "churnRisk": "low", "avgIntervalDays": 32 } + }, + "journal": [ + { + "id": "entry-1", + "type": "note", + "tag": "Note", + "subtags": [], + "text": "Kunden foretraekker naturlige farver og onsker lidt ekstra tid til konsultation. Husk at tjekke allergistatus inden farvebehandling.", + "date": "2025-12-09", + "author": "Emma" + }, + { + "id": "entry-2", + "type": "note", + "tag": "Advarsel", + "subtags": ["Allergi"], + "text": "PARFUMEALLERGI - Brug kun uparfumerede produkter. Havde reaktion pa standard shampoo ved forste besog.", + "date": "2024-03-15", + "author": "Nina" + }, + { + "id": "entry-3", + "type": "colorFormula", + "tag": "Farveformel", + "subtags": [], + "text": "Maltone: Kold\nOxidant: 6%\nFormel: 7/1 + 7/0 (1:1)\nVirketid: 35 min\nPlacering: Hele haret\n\nResultat: Flot ensartet farve, kunden meget tilfreds", + "date": "2025-11-12", + "author": "Emma" + }, + { + "id": "entry-4", + "type": "analysis", + "tag": "Haranalyse", + "subtags": [], + "text": "Tilstand: God, let tort i spidserne\nPorositet: Medium\nElasticitet: Normal\n\nAnbefaling: Olaplex behandling hver 6. uge", + "date": "2025-10-01", + "author": "Maria" + } + ], + "appointments": { + "upcoming": [ + { + "date": "2026-01-14", + "time": "10:00", + "service": "Klip + Farve", + "hairdresser": "Emma L.", + "duration": "2 timer" + } + ], + "history": [ + { "date": "2025-12-09", "service": "Klip + Farve", "hairdresser": "Emma L.", "duration": "2 timer", "price": 1450 }, + { "date": "2025-11-12", "service": "Farve", "hairdresser": "Emma L.", "duration": "1t 30m", "price": 1200 }, + { "date": "2025-10-15", "service": "Klip", "hairdresser": "Emma L.", "duration": "45 min", "price": 550 }, + { "date": "2025-09-20", "service": "Klip + Behandling", "hairdresser": "Nina K.", "duration": "1t 15m", "price": 750 }, + { "date": "2025-08-15", "service": "Farve + Klip", "hairdresser": "Emma L.", "duration": "2t 15m", "price": 1600 } + ] + }, + "giftcards": { + "active": [ + { + "id": "GK-2024-0892", + "type": "giftcard", + "label": "Gavekort #GK-2024-0892", + "originalValue": 500, + "currentBalance": 350, + "expiresAt": "2026-03-15" + }, + { + "id": "10-klip", + "type": "punchcard", + "label": "10-klip kort", + "totalPunches": 10, + "usedPunches": 7, + "expiresAt": null + } + ], + "expired": [] + }, + "activity": [ + { + "date": "2025-12-10", + "time": "14:00", + "type": "communication", + "icon": "chat-text", + "title": "SMS pamindelse sendt om aftale i morgen", + "actor": "System", + "badges": ["auto"] + }, + { + "date": "2025-12-10", + "time": "09:15", + "type": "customer", + "icon": "key", + "title": "Kunde loggede ind via online booking", + "actor": null, + "badges": ["online"] + }, + { + "date": "2025-12-09", + "time": "12:30", + "type": "booking", + "icon": "check-circle", + "title": "Booking gennemfort", + "actor": "Farve + Behandling - Emma L.", + "badges": [] + }, + { + "date": "2025-12-09", + "time": "12:45", + "type": "edit", + "icon": "note-pencil", + "title": "Note tilfojet - Farveformel opdateret", + "actor": "Emma L.", + "badges": [] + }, + { + "date": "2025-11-15", + "time": "10:00", + "type": "warning", + "icon": "warning", + "title": "Allergi registreret - Parfumeallergi tilfojet til profil", + "actor": "Nina K.", + "badges": [] + }, + { + "date": "2024-03-01", + "time": "14:22", + "type": "customer", + "icon": "user-plus", + "title": "Kunde oprettet via online booking", + "actor": "System", + "badges": [] + } + ] + } +} diff --git a/PlanTempus.Application/Features/Customers/Pages/Detail.cshtml b/PlanTempus.Application/Features/Customers/Pages/Detail.cshtml index c7a389d..c172626 100644 --- a/PlanTempus.Application/Features/Customers/Pages/Detail.cshtml +++ b/PlanTempus.Application/Features/Customers/Pages/Detail.cshtml @@ -1,898 +1,7 @@ @page "/kunder/{id}" @model PlanTempus.Application.Features.Customers.Pages.DetailModel @{ - ViewData["Title"] = "Kundedetaljer - Sofie Nielsen"; + ViewData["Title"] = "Kundedetaljer"; } - - - - - - Tilbage til kunder - - - - - Slet kunde - - - - Gem - - - - - - SN - - - Sofie Nielsen - - VIP - - - - Booking tilladt - - - - +45 23 45 67 89 - | - sofie@email.dk - | - Kunde siden marts 2024 - - - - 14 - besog - - - 32 - dage interval - - - Emma L. - foretrukken frisør - - - 12.450 kr - total omsætning - - - - - - - - Oversigt - Økonomi - Statistik - Journal - Aftaler - Gavekort - Aktivitetslog - - - - - - - - - - - - - - - Kontaktoplysninger - - - - - Telefon - +45 23 45 67 89 - - - Email - sofie@email.dk - - - Adresse - Hovedgaden 12 - - - Postnr + By - 2100 København Ø - - - - - - - - - - Profil - - - - - Hårtype - Medium - Bolget - - - Porøsitet - Medium - - - Hovedbund - Normal - - - Naturlig farve - Mørkblond (6) - - - - - - - - - - - - - Marketing - - - - Email marketing - - Ja - Nej - - - - SMS marketing - - Ja - Nej - - - - - - - - - - Betalingsindstillinger - - - - - Kræv forudbetaling - Kunden skal betale fuldt belob ved booking - - - Ja - Nej - - - - - Tillad delvis betaling - Kunden kan vaelge at betale et depositum - - - Ja - Nej - - - - - - - - - - Præferencer - - - - - Foretrukken frisør - Emma L. - - - Foretrukken dag - Tirsdag/Torsdag - - - Specielle ønsker - Foretraekker kold tone, ikke for mørk - - - - - - - - - - Advarsler - - - - - Allergier / Følsomhed - Parfumeallergi - brug uparfumerede produkter - - - - - - - - - - Kundegruppe & Relationer - - - - Kundegruppe: - - - - Standard - Premium - Erhverv - Medarbejder - Familie & Venner - - - - - - - EN - - Emil Nielsen - Barn - - - Åbn - × - - - - - LN - - Luna Nielsen - Barn - - - Åbn - × - - - - - - Tilføj relation - - - - - - - - - - - - - - Økonomi tab - kommer snart - - - - - - - - - - - Fremmøde & Pålidelighed - -
- - 47 - Fremmøder - - - 3 - Aflysninger - - - 1 - No-shows - - - 92% - Pålidelighed - -
- - 47 - 3 - 1 - -
- - - - - - - Service-mønstre - -
-
- Top 3 Services - - - 1 - Klip + Farve - 12× - - - 2 - Farve - - - - 3 - Klip - - - -
-
- Top 3 Produkter - - - 1 - Olaplex No. 3 - - - - 2 - Shampoo - - - - 3 - Hårkur - - - -
-
-
-
- - - - - - - Booking-adfærd - - - - Gns. bookingvarsel - 5 dage - - - Foretrukken dag - Tirsdag - - - Foretrukken tid - 10:00 - 12:00 - - - Online booking rate - 78% - - - Gns. aflysningsvarsel - 2 dage - - - - - - - - Loyalitet - -
- - 1,8 år - Kunde siden - - - 13 - Dage siden sidst - - - - - - Lav - - - Churn-risiko - - - 32 dage - Gns. interval - -
-
-
-
-
-
- - - - - - - - Alle - 5 - - - - Noter - 2 - - - - Farveformler - 2 - - - - Analyser - 1 - - - - - - - - - - - - Noter - - + Tilføj note - - - - Note - - - - Kunden foretrækker naturlige farver og ønsker lidt ekstra tid til konsultation. Husk at tjekke allergistatus inden farvebehandling. - - - 9. dec 2025 · Af: Emma - - - Alle - - - - - - - - - - Advarsel - - Allergi - - - - - PARFUMEALLERGI — Brug kun uparfumerede produkter. Havde reaktion på standard shampoo ved første besøg. - - - 15. mar 2024 · Af: Nina - - - Advarsel - - - - - - - - - - - Farveformler - - + Tilføj - - - - Farveformel - - - - Måltone: Kold
- Oxidant: 6%
- Formel: 7/1 + 7/0 (1:1)
- Virketid: 35 min
- Placering: Hele håret

- Resultat: Flot ensartet farve, kunden meget tilfreds -
- - 12. nov 2025 · Af: Emma - -
-
-
- - - - - - - - - Analyser - - + Tilføj - - - - Håranalyse - - - - Tilstand: God, let tørt i spidserne
- Porøsitet: Medium
- Elasticitet: Normal

- Anbefaling: Olaplex behandling hver 6. uge -
- - 1. okt 2025 · Af: Maria - -
-
-
-
-
-
- - - - - - - - - - Kommende aftaler - - - - Tirsdag 14. januar 2026 kl. 10:00 - - - Klip + Farve · Emma L. · 2 timer - - - Flyt - Aflys - - - - - - - - - - - Tidligere aftaler - - - - Dato - Service - Frisør - Varighed - Pris - - - 9. dec 2025 - Klip + Farve - Emma L. - 2 timer - 1.450 kr - - - 12. nov 2025 - Farve - Emma L. - 1t 30m - 1.200 kr - - - 15. okt 2025 - Klip - Emma L. - 45 min - 550 kr - - - 20. sep 2025 - Klip + Behandling - Nina K. - 1t 15m - 750 kr - - - 15. aug 2025 - Farve + Klip - Emma L. - 2t 15m - 1.600 kr - - - Se alle aftaler → - - - - - - - - - - - - - - - Aktive gavekort - - - - Gavekort #GK-2024-0892 - - - Saldo: 350 kr (af 500 kr) - - - - - Udløber: 15. marts 2026 - - - - - - - Klippekort - - - - 10-klip kort - - - Brugt: 7 af 10 klip - - - - - Udløber aldrig - - - - - - - - - - Udløbne / Brugte - - -

Ingen udløbne eller brugte kort

-
-
-
-
-
-
- - - - - - Alle - Bookinger - Kommunikation - Ændringer - Betalinger - Login - - - - - - - I dag - - - - - - SMS påmindelse sendt om aftale i morgen - Auto - - - 14:00 - System - - - - - - - - - Kunde loggede ind via online booking - Online - - - 09:15 - - - - - - - - 9. december 2025 - - - - - - Booking gennemført - - - 12:30 - Farve + Behandling · Emma L. - - - - - - - - - Note tilføjet — Farveformel opdateret - - - 12:45 - Emma L. - - - - - - - - 15. november 2025 - - - - - - Allergi registreret — Parfumeallergi tilføjet til profil - - - 10:00 - Nina K. - - - - - - - - 1. marts 2024 - - - - - - Kunde oprettet via online booking - - - 14:22 - System - - - - - - - - - -@section Scripts { - -} +@await Component.InvokeAsync("CustomerDetailView", Model.Id) diff --git a/PlanTempus.Application/Features/Localization/Translations/da.json b/PlanTempus.Application/Features/Localization/Translations/da.json index f1b0232..337bce5 100644 --- a/PlanTempus.Application/Features/Localization/Translations/da.json +++ b/PlanTempus.Application/Features/Localization/Translations/da.json @@ -656,8 +656,52 @@ "seeAllNotes": "Se alle noter →" }, "detail": { + "back": "Tilbage til kunder", + "delete": "Slet kunde", + "save": "Gem", "tabs": { + "overview": "Oversigt", + "economy": "Økonomi", + "statistics": "Statistik", + "journal": "Journal", + "appointments": "Aftaler", + "giftcards": "Gavekort", "activitylog": "Aktivitetslog" + }, + "visits": "besøg", + "interval": "dage interval", + "preferredHairdresser": "foretrukken frisør", + "totalRevenue": "total omsætning", + "bookingAllowed": "Booking tilladt", + "bookingBlocked": "Booking blokeret", + "contactInfo": "Kontaktoplysninger", + "phone": "Telefon", + "email": "Email", + "address": "Adresse", + "zipCity": "Postnr + By", + "profile": "Profil", + "marketing": "Marketing", + "emailMarketing": "Email marketing", + "smsMarketing": "SMS marketing", + "paymentSettings": "Betalingsindstillinger", + "requirePrepayment": "Kræv forudbetaling", + "allowPartialPayment": "Tillad delvis betaling", + "preferences": "Præferencer", + "preferredDay": "Foretrukken dag", + "specialRequests": "Specielle ønsker", + "warnings": "Advarsler", + "groupAndRelations": "Kundegruppe & Relationer", + "economy": { + "thisYear": "I år ({0})", + "lastYear": "Sidste år", + "avgPerVisit": "Gns. pr. besøg", + "avgPerMonth": "Gns. pr. måned", + "revenueOverTime": "Omsætning over tid", + "services": "Services", + "products": "Produkter", + "purchaseHistory": "Købshistorik", + "seeAll": "Se alle transaktioner", + "noData": "Ingen økonomiske data tilgængelige" } } } diff --git a/PlanTempus.Application/Features/Localization/Translations/en.json b/PlanTempus.Application/Features/Localization/Translations/en.json index e9ef387..23853e3 100644 --- a/PlanTempus.Application/Features/Localization/Translations/en.json +++ b/PlanTempus.Application/Features/Localization/Translations/en.json @@ -656,8 +656,52 @@ "seeAllNotes": "See all notes →" }, "detail": { + "back": "Back to customers", + "delete": "Delete customer", + "save": "Save", "tabs": { + "overview": "Overview", + "economy": "Economy", + "statistics": "Statistics", + "journal": "Journal", + "appointments": "Appointments", + "giftcards": "Gift cards", "activitylog": "Activity log" + }, + "visits": "visits", + "interval": "days interval", + "preferredHairdresser": "preferred hairdresser", + "totalRevenue": "total revenue", + "bookingAllowed": "Booking allowed", + "bookingBlocked": "Booking blocked", + "contactInfo": "Contact information", + "phone": "Phone", + "email": "Email", + "address": "Address", + "zipCity": "Zip + City", + "profile": "Profile", + "marketing": "Marketing", + "emailMarketing": "Email marketing", + "smsMarketing": "SMS marketing", + "paymentSettings": "Payment settings", + "requirePrepayment": "Require prepayment", + "allowPartialPayment": "Allow partial payment", + "preferences": "Preferences", + "preferredDay": "Preferred day", + "specialRequests": "Special requests", + "warnings": "Warnings", + "groupAndRelations": "Customer group & Relations", + "economy": { + "thisYear": "This year ({0})", + "lastYear": "Last year", + "avgPerVisit": "Avg. per visit", + "avgPerMonth": "Avg. per month", + "revenueOverTime": "Revenue over time", + "services": "Services", + "products": "Products", + "purchaseHistory": "Purchase history", + "seeAll": "See all transactions", + "noData": "No economic data available" } } } diff --git a/PlanTempus.Application/wwwroot/css/customers.css b/PlanTempus.Application/wwwroot/css/customers.css index 1cacbb5..6e50864 100644 --- a/PlanTempus.Application/wwwroot/css/customers.css +++ b/PlanTempus.Application/wwwroot/css/customers.css @@ -617,68 +617,6 @@ swp-profile-box.full-width { grid-column: span 2; } -/* =========================================== - CUSTOMER DETAIL - APPOINTMENTS TABLE - =========================================== */ -swp-card.customer-appointments { - padding: 0; - overflow: hidden; -} - -swp-card.customer-appointments swp-card-header { - padding: var(--spacing-6); -} - -swp-card.customer-appointments swp-data-table { - grid-template-columns: 80px 60px 1fr 100px 110px; -} - -/* =========================================== - CUSTOMER DETAIL - GIFTCARDS TABLE - =========================================== */ -swp-card.customer-giftcards { - padding: 0; - overflow: hidden; -} - -swp-card.customer-giftcards swp-card-header { - padding: var(--spacing-6); -} - -swp-card.customer-giftcards swp-data-table { - grid-template-columns: 140px 100px 80px 80px 100px; -} - -/* =========================================== - CUSTOMER DETAIL - ACTIVITY LIST - =========================================== */ -swp-card.customer-activity { - padding: 0; - overflow: hidden; -} - -swp-card.customer-activity swp-card-header { - padding: var(--spacing-6); - border-bottom: 1px solid var(--color-border); -} - -swp-card.customer-activity swp-attention-list { - display: grid; - grid-template-columns: 56px 1fr 100px; - padding: var(--spacing-4); - gap: var(--spacing-3); -} - -swp-card.customer-activity swp-attention-item { - border-radius: var(--radius-md); - padding: var(--spacing-4); -} - -swp-card.customer-activity swp-attention-action { - font-size: var(--font-size-xs); - color: var(--color-text-tertiary); -} - /* =========================================== CUSTOMER DETAIL - JOURNAL NOTES Override notes-section when inside a card @@ -798,6 +736,12 @@ swp-attendance-segment.noshow { background: var(--color-red); } +/* Empty state: all segments gray */ +swp-attendance-bar.empty swp-attendance-segment { + background: var(--color-gray-300); + color: var(--color-gray-500); +} + /* Top List */ swp-top-list { display: flex; @@ -1468,3 +1412,29 @@ swp-activity-actor { margin-top: var(--spacing-6); margin-bottom: 0; } + +/* =========================================== + CUSTOMER DETAIL - ECONOMY TAB + =========================================== */ +.customer-economy { + swp-data-table { + grid-template-columns: 110px 110px 155px 1fr 110px 150px + + } + + swp-data-table-header swp-data-table-cell:last-child, + swp-data-table-row swp-data-table-cell:last-child { + text-align: right; + } +} + +/* Purchase type tags - matches chart legend colors (services=teal, products=blue) */ +swp-tag.service { + background: var(--bg-teal-strong); + color: var(--color-teal); +} + +swp-tag.product { + background: var(--bg-blue-strong); + color: var(--color-blue); +} diff --git a/PlanTempus.Application/wwwroot/ts/modules/customers.ts b/PlanTempus.Application/wwwroot/ts/modules/customers.ts index 8373d31..b149413 100644 --- a/PlanTempus.Application/wwwroot/ts/modules/customers.ts +++ b/PlanTempus.Application/wwwroot/ts/modules/customers.ts @@ -4,9 +4,11 @@ * Handles: * - Fuzzy search with Fuse.js * - Customer drawer population + * - Customer detail economy chart */ import Fuse from 'fuse.js'; +import { createChart } from '@sevenweirdpeople/swp-charting'; interface CustomerItem { name: string; @@ -233,3 +235,94 @@ export class CustomersController { return tag.charAt(0).toUpperCase() + tag.slice(1); } } + +/** + * Customer Economy Controller + * + * Handles the economy chart on customer detail page. + * Initializes chart lazily when economy tab is shown. + */ +interface ChartDataPoint { + x: string; + y: number; +} + +interface ChartSeries { + name: string; + color: string; + data: ChartDataPoint[]; +} + +interface CustomerChartData { + categories: string[]; + series: ChartSeries[]; +} + +class CustomerEconomyController { + private chartInitialized = false; + private chart: ReturnType | null = null; + + constructor() { + this.setupTabListener(); + // Check if economy tab is already active on page load + this.checkInitialTab(); + } + + private setupTabListener(): void { + document.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + const tab = target.closest('swp-tab[data-tab="economy"]'); + + if (tab) { + // Small delay to let tab content become visible + setTimeout(() => this.initializeChart(), 50); + } + }); + } + + private checkInitialTab(): void { + const activeTab = document.querySelector('swp-tab[data-tab="economy"].active'); + if (activeTab) { + this.initializeChart(); + } + } + + private initializeChart(): void { + if (this.chartInitialized) return; + + const container = document.getElementById('customerRevenueChart'); + if (!container) return; + + const dataScript = document.getElementById('customerRevenueChartData'); + if (!dataScript) return; + + try { + const data = JSON.parse(dataScript.textContent || '') as CustomerChartData; + this.createRevenueChart(container, data); + this.chartInitialized = true; + } catch (err) { + console.error('Failed to parse chart data:', err); + } + } + + private createRevenueChart(container: HTMLElement, data: CustomerChartData): void { + this.chart = createChart(container, { + deferRender: true, + height: 200, + xAxis: { + categories: data.categories + }, + series: data.series.map(s => ({ + name: s.name, + color: s.color, + data: s.data + })), + legend: false + }); + } +} + +// Initialize economy controller if on customer detail page +if (document.getElementById('customerRevenueChart') || document.querySelector('swp-tab[data-tab="economy"]')) { + new CustomerEconomyController(); +}