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:
parent
38e9243bcd
commit
1b25978d9b
26 changed files with 3792 additions and 956 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue