Adds comprehensive customer detail view components

Implements full customer detail page with multiple feature-rich components including overview, economy, statistics, journal, appointments, giftcards, and activity sections

Creates reusable ViewComponents for different customer detail aspects with robust data modeling and presentation logic
This commit is contained in:
Janus C. H. Knudsen 2026-01-25 01:55:41 +01:00
parent 38e9243bcd
commit 1b25978d9b
26 changed files with 3792 additions and 956 deletions

View file

@ -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<TopItemViewModel> TopServices { get; init; } = new();
public required string TopProductsLabel { get; init; }
public List<TopItemViewModel> 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; }
}

View file

@ -0,0 +1,142 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailStatisticsViewModel
<swp-detail-grid>
<!-- Fremmøde & Pålidelighed - spans both columns -->
<swp-card class="full-width">
<swp-card-header>
<swp-card-title>@Model.AttendanceTitle</swp-card-title>
</swp-card-header>
<div class="grid-4">
<swp-stat-card class="highlight">
<swp-stat-value>@Model.Attended</swp-stat-value>
<swp-stat-label>@Model.AttendedLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="warning">
<swp-stat-value>@Model.Cancelled</swp-stat-value>
<swp-stat-label>@Model.CancelledLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="danger">
<swp-stat-value>@Model.NoShow</swp-stat-value>
<swp-stat-label>@Model.NoShowLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="success">
<swp-stat-value>@Model.ReliabilityPercent%</swp-stat-value>
<swp-stat-label>@Model.ReliabilityLabel</swp-stat-label>
</swp-stat-card>
</div>
<swp-attendance-bar class="@(Model.AllZero ? "empty" : "")">
<swp-attendance-segment class="attended" style="width: @Model.AttendedWidth;">@Model.Attended</swp-attendance-segment>
<swp-attendance-segment class="cancelled" style="width: @Model.CancelledWidth;">@Model.Cancelled</swp-attendance-segment>
<swp-attendance-segment class="noshow" style="width: @Model.NoShowWidth;">@Model.NoShow</swp-attendance-segment>
</swp-attendance-bar>
</swp-card>
<!-- Left Column -->
<swp-card-column>
<!-- Service-mønstre -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.ServicePatternsTitle</swp-card-title>
</swp-card-header>
<div class="grid-2">
<div>
<swp-section-label class="small">@Model.TopServicesLabel</swp-section-label>
<swp-top-list>
@foreach (var service in Model.TopServices)
{
<swp-top-item>
<swp-top-rank>@service.Rank</swp-top-rank>
<swp-top-name>@service.Name</swp-top-name>
<swp-top-count>@(service.Count)x</swp-top-count>
</swp-top-item>
}
</swp-top-list>
</div>
<div>
<swp-section-label class="small">@Model.TopProductsLabel</swp-section-label>
<swp-top-list>
@foreach (var product in Model.TopProducts)
{
<swp-top-item>
<swp-top-rank>@product.Rank</swp-top-rank>
<swp-top-name>@product.Name</swp-top-name>
<swp-top-count>@(product.Count)x</swp-top-count>
</swp-top-item>
}
</swp-top-list>
</div>
</div>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Booking-adfærd -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.BookingBehaviorTitle</swp-card-title>
</swp-card-header>
<swp-kv-list>
<swp-kv-row>
<swp-kv-label>@Model.AvgBookingNoticeLabel</swp-kv-label>
<swp-kv-value>@Model.AvgBookingNotice</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>@Model.PreferredDayLabel</swp-kv-label>
<swp-kv-value>@Model.PreferredDay</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>@Model.PreferredTimeSlotLabel</swp-kv-label>
<swp-kv-value>@Model.PreferredTimeSlot</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>@Model.OnlineBookingRateLabel</swp-kv-label>
<swp-kv-value>@Model.OnlineBookingRate</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>@Model.AvgCancellationNoticeLabel</swp-kv-label>
<swp-kv-value>@Model.AvgCancellationNotice</swp-kv-value>
</swp-kv-row>
</swp-kv-list>
</swp-card>
<!-- Loyalitet -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.LoyaltyTitle</swp-card-title>
</swp-card-header>
<div class="grid-2 compact">
<swp-stat-card>
<swp-stat-value class="small">@Model.CustomerSinceYears</swp-stat-value>
<swp-stat-label>@Model.CustomerSinceYearsLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="success">
<swp-stat-value class="small">@Model.DaysSinceLastVisit</swp-stat-value>
<swp-stat-label>@Model.DaysSinceLastVisitLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value class="small">
<swp-risk-indicator class="@Model.ChurnRisk">
<swp-risk-dot></swp-risk-dot>
@{
var riskText = Model.ChurnRisk switch
{
"low" => "Lav",
"medium" => "Medium",
"high" => "Hoj",
_ => Model.ChurnRisk
};
}
<span>@riskText</span>
</swp-risk-indicator>
</swp-stat-value>
<swp-stat-label>@Model.ChurnRiskLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value class="small">@Model.AvgIntervalDays</swp-stat-value>
<swp-stat-label>@Model.AvgIntervalDaysLabel</swp-stat-label>
</swp-stat-card>
</div>
</swp-card>
</swp-card-column>
</swp-detail-grid>