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,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<CustomerTagViewModel> 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; }
}

View file

@ -0,0 +1,53 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailHeaderViewModel
<swp-customer-detail-header>
<swp-customer-avatar-large>@Model.Initials</swp-customer-avatar-large>
<swp-customer-detail-info>
<swp-customer-name-row>
<swp-customer-detail-name>@Model.Name</swp-customer-detail-name>
<swp-customer-detail-tags>
@foreach (var tag in Model.Tags)
{
<swp-tag class="@tag.CssClass">@tag.Text</swp-tag>
}
</swp-customer-detail-tags>
<swp-booking-exclusion data-excluded="@(Model.BookingAllowed ? "false" : "true")">
@if (Model.BookingAllowed)
{
<i class="ph ph-check icon"></i>
<span>@Model.BookingAllowedText</span>
}
else
{
<i class="ph ph-x icon"></i>
<span>@Model.BookingBlockedText</span>
}
</swp-booking-exclusion>
</swp-customer-name-row>
<swp-contact-line>
<a href="@Model.PhoneHref">@Model.Phone</a>
<span class="separator">|</span>
<a href="@Model.EmailHref">@Model.Email</a>
<span class="separator">|</span>
<span>@Model.CustomerSinceText</span>
</swp-contact-line>
<swp-fact-boxes-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.FactVisits</swp-fact-inline-value>
<swp-fact-inline-label>@Model.FactVisitsLabel</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.FactInterval</swp-fact-inline-value>
<swp-fact-inline-label>@Model.FactIntervalLabel</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.FactHairdresser</swp-fact-inline-value>
<swp-fact-inline-label>@Model.FactHairdresserLabel</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>@Model.FactRevenue</swp-fact-inline-value>
<swp-fact-inline-label>@Model.FactRevenueLabel</swp-fact-inline-label>
</swp-fact-inline>
</swp-fact-boxes-inline>
</swp-customer-detail-info>
</swp-customer-detail-header>