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,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<ActivityFilterViewModel>
{
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<ActivityDateGroupViewModel> DateGroups { get; init; } = new();
public List<ActivityFilterViewModel> 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<ActivityItemViewModel> 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<ActivityBadgeViewModel> Badges { get; init; } = new();
}
public class ActivityBadgeViewModel
{
public required string Text { get; init; }
public required string CssClass { get; init; }
}

View file

@ -0,0 +1,83 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailActivityViewModel
<!-- Filters -->
<swp-activity-filters>
@foreach (var filter in Model.Filters)
{
<swp-activity-filter class="@(filter.IsActive ? "active" : "")" data-type="@(filter.Type ?? "all")">
@if (!string.IsNullOrEmpty(filter.Icon))
{
<i class="ph ph-@filter.Icon"></i>
}
@filter.Label
</swp-activity-filter>
}
</swp-activity-filters>
<swp-card>
<swp-activity-timeline>
@foreach (var dateGroup in Model.DateGroups)
{
<swp-activity-date-group>
<swp-activity-date-header>@dateGroup.DateHeader</swp-activity-date-header>
@foreach (var item in dateGroup.Items)
{
<swp-activity-item data-type="@item.Type">
<swp-activity-icon class="@item.Type"><i class="ph ph-@item.Icon"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
@Html.Raw(item.Title)
@foreach (var badge in item.Badges)
{
<swp-activity-badge class="@badge.CssClass">@badge.Text</swp-activity-badge>
}
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>@item.Time</swp-activity-time>
@if (!string.IsNullOrEmpty(item.Actor))
{
<swp-activity-actor>@item.Actor</swp-activity-actor>
}
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
}
</swp-activity-date-group>
}
</swp-activity-timeline>
</swp-card>
<script>
(function() {
const filters = document.querySelectorAll('swp-activity-filter');
const timeline = document.querySelector('swp-activity-timeline');
filters.forEach(filter => {
filter.addEventListener('click', () => {
// Update active state
filters.forEach(f => f.classList.remove('active'));
filter.classList.add('active');
const filterType = filter.dataset.type;
// Filter items
const items = timeline.querySelectorAll('swp-activity-item');
items.forEach(item => {
if (filterType === 'all' || item.dataset.type === filterType) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
// Hide date groups with no visible items
const dateGroups = timeline.querySelectorAll('swp-activity-date-group');
dateGroups.forEach(group => {
const visibleItems = group.querySelectorAll('swp-activity-item:not([style*="display: none"])');
group.style.display = visibleItems.length > 0 ? '' : 'none';
});
});
});
})();
</script>

View file

@ -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<UpcomingAppointmentViewModel> Upcoming { get; init; } = new();
public List<HistoryAppointmentViewModel> 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; }
}

View file

@ -0,0 +1,64 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailAppointmentsViewModel
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Kommende aftaler -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.UpcomingTitle</swp-card-title>
</swp-card-header>
@foreach (var appointment in Model.Upcoming)
{
<swp-appointment-card>
<swp-appointment-date>
@appointment.FormattedDate
</swp-appointment-date>
<swp-appointment-details>
@appointment.Details
</swp-appointment-details>
<swp-appointment-actions>
<swp-btn class="secondary">@Model.MoveButtonText</swp-btn>
<swp-btn class="secondary">@Model.CancelButtonText</swp-btn>
</swp-appointment-actions>
</swp-appointment-card>
}
@if (!Model.Upcoming.Any())
{
<swp-empty-state>
<p>Ingen kommende aftaler</p>
</swp-empty-state>
}
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Tidligere aftaler -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.HistoryTitle</swp-card-title>
</swp-card-header>
<swp-table>
<swp-table-header>
<span>@Model.DateHeader</span>
<span>@Model.ServiceHeader</span>
<span>@Model.HairdresserHeader</span>
<span>@Model.DurationHeader</span>
<span>@Model.PriceHeader</span>
</swp-table-header>
@foreach (var appointment in Model.History)
{
<swp-table-row>
<span>@appointment.FormattedDate</span>
<span>@appointment.Service</span>
<span>@appointment.Hairdresser</span>
<span>@appointment.Duration</span>
<span class="mono">@appointment.FormattedPrice</span>
</swp-table-row>
}
</swp-table>
<swp-see-all>@Model.SeeAllText</swp-see-all>
</swp-card>
</swp-card-column>
</swp-detail-grid>

View file

@ -0,0 +1,301 @@
using System.Text.Json;
namespace PlanTempus.Application.Features.Customers.Components;
/// <summary>
/// Shared catalog for customer detail data.
/// Loads from customerDetailMock.json and used by all CustomerDetail* ViewComponents.
/// </summary>
public static class CustomerDetailCatalog
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private static Dictionary<string, CustomerDetailRecord>? _customers;
private static Dictionary<string, CustomerDetailRecord> 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<Dictionary<string, CustomerDetailRecord>>(json, JsonOptions)
?? new Dictionary<string, CustomerDetailRecord>();
}
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<string> 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<CustomerProfileItem> Profile { get; init; } = new();
public required CustomerMarketingRecord Marketing { get; init; }
public required CustomerPaymentRecord Payment { get; init; }
public required CustomerPreferencesRecord Preferences { get; init; }
public List<CustomerWarningItem> Warnings { get; init; } = new();
public required CustomerGroupRecord Group { get; init; }
public List<CustomerRelationRecord> Relations { get; init; } = new();
public required CustomerStatisticsRecord Statistics { get; init; }
public List<CustomerJournalEntry> Journal { get; init; } = new();
public required CustomerAppointmentsRecord Appointments { get; init; }
public required CustomerGiftcardsRecord Giftcards { get; init; }
public List<CustomerActivityEntry> 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<string> 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<CustomerTopItem> TopServices { get; init; } = new();
public List<CustomerTopItem> 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<string> 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<CustomerUpcomingAppointment> Upcoming { get; init; } = new();
public List<CustomerHistoryAppointment> 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<CustomerGiftcardItem> Active { get; init; } = new();
public List<CustomerGiftcardItem> 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<string> 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<CustomerPurchase> Purchases { get; init; } = new();
}
public record CustomerChartData
{
public List<string> Categories { get; init; } = new();
public List<CustomerChartSeries> Series { get; init; } = new();
}
public record CustomerChartSeries
{
public required string Name { get; init; }
public required string Color { get; init; }
public List<CustomerChartDataPoint> 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; }
}

View file

@ -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<CustomerPurchaseViewModel>(),
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<CustomerPurchaseViewModel> 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<string> Categories { get; init; } = new();
public List<CustomerChartSeriesViewModel> Series { get; init; } = new();
}
public class CustomerChartSeriesViewModel
{
public required string Name { get; init; }
public required string Color { get; init; }
public List<CustomerChartDataPointViewModel> Data { get; init; } = new();
}
public class CustomerChartDataPointViewModel
{
public required string X { get; init; }
public decimal Y { get; init; }
}

View file

@ -0,0 +1,102 @@
@using System.Text.Json
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailEconomyViewModel
@if (!Model.HasData)
{
<swp-empty-state>
<i class="ph ph-currency-circle-dollar"></i>
<span>@Model.EmptyStateText</span>
</swp-empty-state>
}
else
{
<swp-detail-grid>
<!-- Stat Cards -->
<swp-stats-row class="cols-4 full-width">
<swp-stat-card class="highlight">
<swp-stat-value>@Model.CurrentYearValue</swp-stat-value>
<swp-stat-label>@Model.CurrentYearLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>@Model.LastYearValue</swp-stat-value>
<swp-stat-label>@Model.LastYearLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>@Model.AvgPerVisitValue</swp-stat-value>
<swp-stat-label>@Model.AvgPerVisitLabel</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value>@Model.AvgPerMonthValue</swp-stat-value>
<swp-stat-label>@Model.AvgPerMonthLabel</swp-stat-label>
</swp-stat-card>
</swp-stats-row>
<!-- Revenue Chart Card -->
<swp-card class="full-width">
<swp-card-header>
<swp-card-title>@Model.RevenueOverTimeTitle</swp-card-title>
<swp-chart-legend>
<swp-chart-legend-item>
<swp-chart-legend-dot class="services"></swp-chart-legend-dot>
<span>@Model.ServicesLabel</span>
</swp-chart-legend-item>
<swp-chart-legend-item>
<swp-chart-legend-dot class="products"></swp-chart-legend-dot>
<span>@Model.ProductsLabel</span>
</swp-chart-legend-item>
</swp-chart-legend>
</swp-card-header>
@if (Model.ChartData != null)
{
<swp-chart-container id="customerRevenueChart"></swp-chart-container>
<script id="customerRevenueChartData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model.ChartData, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }))</script>
}
else
{
<swp-chart-container>
<!-- No chart data available -->
</swp-chart-container>
}
</swp-card>
<!-- Purchase History Card -->
<swp-card class="customer-economy full-width">
<swp-card-header>
<swp-card-title>@Model.PurchaseHistoryTitle</swp-card-title>
</swp-card-header>
<swp-data-table>
<swp-data-table-header>
<swp-data-table-cell>Faktura</swp-data-table-cell>
<swp-data-table-cell>Dato/tid</swp-data-table-cell>
<swp-data-table-cell>Medarbejder</swp-data-table-cell>
<swp-data-table-cell>Ydelser</swp-data-table-cell>
<swp-data-table-cell>Type</swp-data-table-cell>
<swp-data-table-cell>Beløb</swp-data-table-cell>
</swp-data-table-header>
@foreach (var purchase in Model.Purchases)
{
<swp-data-table-row>
<swp-data-table-cell>
<swp-invoice-cell>@purchase.Invoice</swp-invoice-cell>
</swp-data-table-cell>
<swp-data-table-cell>
<swp-datetime-cell>
<span class="date">@purchase.Date</span>
<span class="time">@purchase.Time</span>
</swp-datetime-cell>
</swp-data-table-cell>
<swp-data-table-cell class="muted">@purchase.Employee</swp-data-table-cell>
<swp-data-table-cell>@purchase.Services</swp-data-table-cell>
<swp-data-table-cell>
<swp-tag class="@purchase.Type">@purchase.TypeLabel</swp-tag>
</swp-data-table-cell>
<swp-data-table-cell>
<swp-amount-cell>@purchase.Amount</swp-amount-cell>
</swp-data-table-cell>
</swp-data-table-row>
}
</swp-data-table>
<swp-see-all>@Model.SeeAllText</swp-see-all>
</swp-card>
</swp-detail-grid>
}

View file

@ -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: <strong>{g.CurrentBalance:N0} kr</strong> (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: <strong>{p.UsedPunches} af {p.TotalPunches}</strong> 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<GiftcardItemViewModel> Giftcards { get; init; } = new();
public List<GiftcardItemViewModel> Punchcards { get; init; } = new();
public List<GiftcardItemViewModel> 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; }
}

View file

@ -0,0 +1,81 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailGiftcardsViewModel
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Aktive gavekort -->
@if (Model.Giftcards.Any())
{
<swp-card>
<swp-card-header>
<swp-card-title>@Model.GiftcardsTitle</swp-card-title>
</swp-card-header>
@foreach (var giftcard in Model.Giftcards)
{
<swp-giftcard>
<swp-giftcard-header>
@giftcard.Label
</swp-giftcard-header>
<swp-giftcard-balance>
@Html.Raw(giftcard.BalanceText)
</swp-giftcard-balance>
<swp-progress-bar>
<swp-progress-fill style="width: @giftcard.Percentage%;"></swp-progress-fill>
</swp-progress-bar>
<swp-giftcard-expires>@giftcard.ExpiresText</swp-giftcard-expires>
</swp-giftcard>
}
</swp-card>
}
<!-- Klippekort -->
@if (Model.Punchcards.Any())
{
<swp-card>
<swp-card-header>
<swp-card-title>@Model.PunchcardsTitle</swp-card-title>
</swp-card-header>
@foreach (var punchcard in Model.Punchcards)
{
<swp-giftcard>
<swp-giftcard-header>
@punchcard.Label
</swp-giftcard-header>
<swp-giftcard-balance>
@Html.Raw(punchcard.BalanceText)
</swp-giftcard-balance>
<swp-progress-bar>
<swp-progress-fill style="width: @punchcard.Percentage%;"></swp-progress-fill>
</swp-progress-bar>
<swp-giftcard-expires>@punchcard.ExpiresText</swp-giftcard-expires>
</swp-giftcard>
}
</swp-card>
}
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Udlobne / Brugte -->
<swp-card>
<swp-card-header>
<swp-card-title>@Model.ExpiredTitle</swp-card-title>
</swp-card-header>
@if (Model.ExpiredCards.Any())
{
@foreach (var card in Model.ExpiredCards)
{
<swp-giftcard class="expired">
<swp-giftcard-header>@card.Label</swp-giftcard-header>
</swp-giftcard>
}
}
else
{
<swp-empty-state>
<p>@Model.NoExpiredText</p>
</swp-empty-state>
}
</swp-card>
</swp-card-column>
</swp-detail-grid>

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>

View file

@ -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<JournalEntryViewModel> Notes { get; init; } = new();
public List<JournalEntryViewModel> ColorFormulas { get; init; } = new();
public List<JournalEntryViewModel> 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<string> 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; }
}

View file

@ -0,0 +1,168 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailJournalViewModel
<!-- Mini Tabs for quick filter -->
<swp-journal-mini-tabs>
<swp-journal-mini-tab class="active" data-filter="all">
<span class="tab-dot blue"></span>
Alle
<span class="tab-count">@Model.AllCount</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab data-filter="note">
<span class="tab-dot blue"></span>
Noter
<span class="tab-count">@Model.NotesCount</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab data-filter="colorFormula">
<span class="tab-dot amber"></span>
Farveformler
<span class="tab-count">@Model.ColorFormulasCount</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab data-filter="analysis">
<span class="tab-dot purple"></span>
Analyser
<span class="tab-count">@Model.AnalysesCount</span>
</swp-journal-mini-tab>
</swp-journal-mini-tabs>
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Noter header card -->
<swp-card data-journal-type="note">
<swp-card-header>
<swp-card-title>
<span class="col-dot blue"></span>
<span>@Model.NotesTitle</span>
</swp-card-title>
<swp-section-action>@Model.AddNoteText</swp-section-action>
</swp-card-header>
@foreach (var entry in Model.Notes)
{
<swp-journal-entry data-entry-id="@entry.Id">
<swp-journal-entry-header>
<swp-journal-entry-type class="@entry.TypeClass">@entry.Tag</swp-journal-entry-type>
@if (entry.Subtags.Any())
{
<swp-journal-entry-tags>
@foreach (var subtag in entry.Subtags)
{
<swp-journal-tag class="@subtag.ToLowerInvariant()">@subtag</swp-journal-tag>
}
</swp-journal-entry-tags>
}
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>@Html.Raw(entry.Text.Replace("\n", "<br>"))</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>@entry.FormattedDate</swp-journal-entry-date>
@if (entry.Tag == "Advarsel")
{
<swp-journal-entry-visibility class="warning">
<i class="ph ph-warning"></i>
<span>Advarsel</span>
</swp-journal-entry-visibility>
}
else
{
<swp-journal-entry-visibility>
<i class="ph ph-eye"></i>
<span>Alle</span>
</swp-journal-entry-visibility>
}
</swp-journal-entry-footer>
</swp-journal-entry>
}
</swp-card>
<!-- Farveformler card -->
<swp-card data-journal-type="colorFormula">
<swp-card-header>
<swp-card-title>
<span class="col-dot amber"></span>
<span>@Model.ColorFormulasTitle</span>
</swp-card-title>
<swp-section-action>@Model.AddColorFormulaText</swp-section-action>
</swp-card-header>
@foreach (var entry in Model.ColorFormulas)
{
<swp-journal-entry data-entry-id="@entry.Id">
<swp-journal-entry-header>
<swp-journal-entry-type class="@entry.TypeClass">@entry.Tag</swp-journal-entry-type>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
@{
var lines = entry.Text.Split('\n');
foreach (var line in lines)
{
if (line.Contains(':'))
{
var parts = line.Split(':', 2);
<text><span class="label">@parts[0]:</span> <span class="mono">@parts[1].Trim()</span><br></text>
}
else if (!string.IsNullOrWhiteSpace(line))
{
@line<br>
}
else
{
<br>
}
}
}
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>@entry.FormattedDate</swp-journal-entry-date>
</swp-journal-entry-footer>
</swp-journal-entry>
}
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Analyser card -->
<swp-card data-journal-type="analysis">
<swp-card-header>
<swp-card-title>
<span class="col-dot purple"></span>
<span>@Model.AnalysesTitle</span>
</swp-card-title>
<swp-section-action>@Model.AddAnalysisText</swp-section-action>
</swp-card-header>
@foreach (var entry in Model.Analyses)
{
<swp-journal-entry data-entry-id="@entry.Id">
<swp-journal-entry-header>
<swp-journal-entry-type class="@entry.TypeClass">@entry.Tag</swp-journal-entry-type>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
@{
var analysisLines = entry.Text.Split('\n');
foreach (var line in analysisLines)
{
if (line.Contains(':'))
{
var parts = line.Split(':', 2);
<text><span class="label">@parts[0]:</span> @parts[1].Trim()<br></text>
}
else if (!string.IsNullOrWhiteSpace(line))
{
@line<br>
}
else
{
<br>
}
}
}
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>@entry.FormattedDate</swp-journal-entry-date>
</swp-journal-entry-footer>
</swp-journal-entry>
}
</swp-card>
</swp-card-column>
</swp-detail-grid>

View file

@ -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<ProfileItemViewModel> 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<WarningItemViewModel> 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<RelationItemViewModel> 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; }
}

View file

@ -0,0 +1,201 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailOverviewViewModel
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Kontaktoplysninger -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-address-book"></i>
<span>@Model.ContactTitle</span>
</swp-card-title>
</swp-card-header>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label>@Model.PhoneLabel</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Phone</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.EmailLabel</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Email</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.AddressLabel</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.Address</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label>@Model.ZipCityLabel</swp-edit-label>
<swp-edit-value contenteditable="true">@Model.ZipCity</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Profil -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-user-circle"></i>
<span>@Model.ProfileTitle</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
@foreach (var item in Model.ProfileItems)
{
<swp-profile-box>
<swp-profile-box-label>@item.Title</swp-profile-box-label>
<swp-profile-box-value>@item.Value</swp-profile-box-value>
</swp-profile-box>
}
</swp-profile-boxes>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Marketing -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-megaphone"></i>
<span>@Model.MarketingTitle</span>
</swp-card-title>
</swp-card-header>
<swp-toggle-row>
<swp-toggle-label>@Model.EmailMarketingLabel</swp-toggle-label>
<swp-toggle-slider data-value="@(Model.EmailOptIn ? "yes" : "no")">
<swp-toggle-option>@Model.YesLabel</swp-toggle-option>
<swp-toggle-option>@Model.NoLabel</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<swp-toggle-label>@Model.SmsMarketingLabel</swp-toggle-label>
<swp-toggle-slider data-value="@(Model.SmsOptIn ? "yes" : "no")">
<swp-toggle-option>@Model.YesLabel</swp-toggle-option>
<swp-toggle-option>@Model.NoLabel</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Betalingsindstillinger -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-credit-card"></i>
<span>@Model.PaymentTitle</span>
</swp-card-title>
</swp-card-header>
<swp-toggle-row>
<swp-toggle-info>
<swp-toggle-label>@Model.RequirePrepaymentLabel</swp-toggle-label>
<swp-toggle-desc>@Model.RequirePrepaymentDesc</swp-toggle-desc>
</swp-toggle-info>
<swp-toggle-slider data-value="@(Model.RequirePrepayment ? "yes" : "no")">
<swp-toggle-option>@Model.YesLabel</swp-toggle-option>
<swp-toggle-option>@Model.NoLabel</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<swp-toggle-info>
<swp-toggle-label>@Model.AllowPartialPaymentLabel</swp-toggle-label>
<swp-toggle-desc>@Model.AllowPartialPaymentDesc</swp-toggle-desc>
</swp-toggle-info>
<swp-toggle-slider data-value="@(Model.AllowPartialPayment ? "yes" : "no")">
<swp-toggle-option>@Model.YesLabel</swp-toggle-option>
<swp-toggle-option>@Model.NoLabel</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Praeferencer -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-sliders-horizontal"></i>
<span>@Model.PreferencesTitle</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
<swp-profile-box>
<swp-profile-box-label>@Model.PreferredHairdresserLabel</swp-profile-box-label>
<swp-profile-box-value>@Model.PreferredHairdresser</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box>
<swp-profile-box-label>@Model.PreferredDaysLabel</swp-profile-box-label>
<swp-profile-box-value>@Model.PreferredDays</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box class="full-width">
<swp-profile-box-label>@Model.SpecialRequestsLabel</swp-profile-box-label>
<swp-profile-box-value>@Model.SpecialRequests</swp-profile-box-value>
</swp-profile-box>
</swp-profile-boxes>
</swp-card>
<!-- Advarsler -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-warning"></i>
<span>@Model.WarningsTitle</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
@foreach (var warning in Model.Warnings)
{
<swp-profile-box class="warning full-width">
<swp-profile-box-label>@warning.Title</swp-profile-box-label>
<swp-profile-box-value>@warning.Value</swp-profile-box-value>
</swp-profile-box>
}
</swp-profile-boxes>
</swp-card>
<!-- Kundegruppe & Relationer -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-users-three"></i>
<span>@Model.GroupRelationsTitle</span>
</swp-card-title>
</swp-card-header>
<swp-customer-group-row>
<swp-customer-group-label>@Model.GroupLabel</swp-customer-group-label>
<swp-select>
<button type="button">
<swp-select-value>@Model.GroupName</swp-select-value>
<i class="ph ph-caret-down"></i>
</button>
<swp-select-dropdown>
<swp-select-option class="@(Model.GroupId == "standard" ? "selected" : "")" data-value="standard">Standard</swp-select-option>
<swp-select-option class="@(Model.GroupId == "premium" ? "selected" : "")" data-value="premium">Premium</swp-select-option>
<swp-select-option class="@(Model.GroupId == "erhverv" ? "selected" : "")" data-value="erhverv">Erhverv</swp-select-option>
<swp-select-option class="@(Model.GroupId == "medarbejder" ? "selected" : "")" data-value="medarbejder">Medarbejder</swp-select-option>
<swp-select-option class="@(Model.GroupId == "familie" ? "selected" : "")" data-value="familie">Familie & Venner</swp-select-option>
</swp-select-dropdown>
</swp-select>
</swp-customer-group-row>
<swp-relations-list>
@foreach (var relation in Model.Relations)
{
<swp-relation-item>
<swp-relation-avatar>@relation.Initials</swp-relation-avatar>
<swp-relation-info>
<swp-relation-name>@relation.Name</swp-relation-name>
<swp-relation-type>@relation.Type</swp-relation-type>
</swp-relation-info>
<swp-relation-actions>
<swp-relation-link>Abn</swp-relation-link>
<swp-relation-remove>&times;</swp-relation-remove>
</swp-relation-actions>
</swp-relation-item>
}
<swp-add-relation>
<i class="ph ph-plus"></i>
<span>@Model.AddRelationText</span>
</swp-add-relation>
</swp-relations-list>
</swp-card>
</swp-card-column>
</swp-detail-grid>

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>

View file

@ -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; }
}

View file

@ -0,0 +1,123 @@
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailViewViewModel
<swp-customer-detail-view id="customer-detail-view" data-customer="@Model.CustomerId">
<!-- Sticky Header (generic from page.css) -->
<swp-sticky-header>
<swp-header-content>
<!-- Page Header with Back Button -->
<swp-page-header>
<swp-back-link href="/kunder">
<i class="ph ph-arrow-left"></i>
<span>@Model.BackText</span>
</swp-back-link>
<swp-page-actions>
<swp-btn class="secondary">
<i class="ph ph-trash"></i>
<span>@Model.DeleteButtonText</span>
</swp-btn>
<swp-btn class="primary">
<i class="ph ph-floppy-disk"></i>
<span>@Model.SaveButtonText</span>
</swp-btn>
</swp-page-actions>
</swp-page-header>
<!-- Customer Header -->
@await Component.InvokeAsync("CustomerDetailHeader", Model.CustomerId)
</swp-header-content>
<!-- Tabs (outside header-content, inside sticky-header) -->
<swp-tab-bar>
<swp-tab class="active" data-tab="overview">@Model.TabOverview</swp-tab>
<swp-tab data-tab="economy">@Model.TabEconomy</swp-tab>
<swp-tab data-tab="statistics">@Model.TabStatistics</swp-tab>
<swp-tab data-tab="journal">@Model.TabJournal</swp-tab>
<swp-tab data-tab="appointments">@Model.TabAppointments</swp-tab>
<swp-tab data-tab="giftcards">@Model.TabGiftcards</swp-tab>
<swp-tab data-tab="activity">@Model.TabActivity</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- Tab Contents -->
<swp-tab-content data-tab="overview" class="active">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailOverview", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="economy">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailEconomy", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="statistics">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailStatistics", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="journal">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailJournal", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="appointments">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailAppointments", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="giftcards">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailGiftcards", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="activity">
<swp-page-container>
@await Component.InvokeAsync("CustomerDetailActivity", Model.CustomerId)
</swp-page-container>
</swp-tab-content>
</swp-customer-detail-view>
<script>
// Tab switching
document.querySelectorAll('#customer-detail-view swp-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
const container = document.getElementById('customer-detail-view');
// Update tab active state
container.querySelectorAll('swp-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update content visibility
container.querySelectorAll('swp-tab-content').forEach(content => {
content.classList.remove('active');
if (content.dataset.tab === tabName) {
content.classList.add('active');
}
});
});
});
// Booking exclusion toggle (feature-specific)
const bookingExclusion = document.querySelector('swp-booking-exclusion');
if (bookingExclusion) {
bookingExclusion.addEventListener('click', () => {
const isExcluded = bookingExclusion.dataset.excluded === 'true';
bookingExclusion.dataset.excluded = isExcluded ? 'false' : 'true';
const icon = bookingExclusion.querySelector('.icon');
const text = bookingExclusion.querySelector('span:not(.icon)');
if (isExcluded) {
icon.className = 'ph ph-check icon';
text.textContent = 'Booking tilladt';
} else {
icon.className = 'ph ph-x icon';
text.textContent = 'Booking blokeret';
}
});
}
</script>

File diff suppressed because it is too large Load diff

View file

@ -1,898 +1,7 @@
@page "/kunder/{id}"
@model PlanTempus.Application.Features.Customers.Pages.DetailModel
@{
ViewData["Title"] = "Kundedetaljer - Sofie Nielsen";
ViewData["Title"] = "Kundedetaljer";
}
<swp-sticky-header>
<swp-header-content>
<swp-page-header>
<swp-back-link href="/kunder">
<i class="ph ph-arrow-left"></i>
<span>Tilbage til kunder</span>
</swp-back-link>
<swp-page-actions>
<swp-btn class="secondary">
<i class="ph ph-trash"></i>
<span localize="customers.detail.delete">Slet kunde</span>
</swp-btn>
<swp-btn class="primary">
<i class="ph ph-floppy-disk"></i>
<span localize="customers.detail.save">Gem</span>
</swp-btn>
</swp-page-actions>
</swp-page-header>
<swp-customer-detail-header>
<swp-customer-avatar-large>SN</swp-customer-avatar-large>
<swp-customer-detail-info>
<swp-customer-name-row>
<swp-customer-detail-name>Sofie Nielsen</swp-customer-detail-name>
<swp-customer-detail-tags>
<swp-tag class="vip">VIP</swp-tag>
</swp-customer-detail-tags>
<swp-booking-exclusion data-excluded="false">
<i class="ph ph-check icon"></i>
<span>Booking tilladt</span>
</swp-booking-exclusion>
</swp-customer-name-row>
<swp-contact-line>
<a href="tel:+4523456789">+45 23 45 67 89</a>
<span class="separator">|</span>
<a href="mailto:sofie@email.dk">sofie@email.dk</a>
<span class="separator">|</span>
<span>Kunde siden marts 2024</span>
</swp-contact-line>
<swp-fact-boxes-inline>
<swp-fact-inline>
<swp-fact-inline-value>14</swp-fact-inline-value>
<swp-fact-inline-label>besog</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>32</swp-fact-inline-value>
<swp-fact-inline-label>dage interval</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>Emma L.</swp-fact-inline-value>
<swp-fact-inline-label>foretrukken frisør</swp-fact-inline-label>
</swp-fact-inline>
<swp-fact-inline>
<swp-fact-inline-value>12.450 kr</swp-fact-inline-value>
<swp-fact-inline-label>total omsætning</swp-fact-inline-label>
</swp-fact-inline>
</swp-fact-boxes-inline>
</swp-customer-detail-info>
</swp-customer-detail-header>
</swp-header-content>
<swp-tab-bar>
<swp-tab class="active" data-tab="overview">Oversigt</swp-tab>
<swp-tab data-tab="economy">Økonomi</swp-tab>
<swp-tab data-tab="statistics">Statistik</swp-tab>
<swp-tab data-tab="journal">Journal</swp-tab>
<swp-tab data-tab="appointments">Aftaler</swp-tab>
<swp-tab data-tab="giftcards">Gavekort</swp-tab>
<swp-tab data-tab="activity" localize="customers.detail.tabs.activitylog">Aktivitetslog</swp-tab>
</swp-tab-bar>
</swp-sticky-header>
<!-- OVERSIGT TAB -->
<swp-tab-content class="active" data-tab="overview">
<swp-page-container>
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Kontaktoplysninger -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-address-book"></i>
<span localize="customers.detail.contactInfo">Kontaktoplysninger</span>
</swp-card-title>
</swp-card-header>
<swp-edit-section>
<swp-edit-row>
<swp-edit-label localize="customers.detail.phone">Telefon</swp-edit-label>
<swp-edit-value contenteditable="true">+45 23 45 67 89</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label localize="customers.detail.email">Email</swp-edit-label>
<swp-edit-value contenteditable="true">sofie@email.dk</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label localize="customers.detail.address">Adresse</swp-edit-label>
<swp-edit-value contenteditable="true">Hovedgaden 12</swp-edit-value>
</swp-edit-row>
<swp-edit-row>
<swp-edit-label localize="customers.detail.zipCity">Postnr + By</swp-edit-label>
<swp-edit-value contenteditable="true">2100 København Ø</swp-edit-value>
</swp-edit-row>
</swp-edit-section>
</swp-card>
<!-- Profil -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-user-circle"></i>
<span localize="customers.detail.profile">Profil</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.hairType">Hårtype</swp-profile-box-label>
<swp-profile-box-value>Medium - Bolget</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.porosity">Porøsitet</swp-profile-box-label>
<swp-profile-box-value>Medium</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.scalp">Hovedbund</swp-profile-box-label>
<swp-profile-box-value>Normal</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.naturalColor">Naturlig farve</swp-profile-box-label>
<swp-profile-box-value>Mørkblond (6)</swp-profile-box-value>
</swp-profile-box>
</swp-profile-boxes>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Marketing -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-megaphone"></i>
<span localize="customers.detail.marketing">Marketing</span>
</swp-card-title>
</swp-card-header>
<swp-toggle-row>
<swp-toggle-label localize="customers.detail.emailMarketing">Email marketing</swp-toggle-label>
<swp-toggle-slider data-value="yes">
<swp-toggle-option localize="common.yes">Ja</swp-toggle-option>
<swp-toggle-option localize="common.no">Nej</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<swp-toggle-label localize="customers.detail.smsMarketing">SMS marketing</swp-toggle-label>
<swp-toggle-slider data-value="no">
<swp-toggle-option localize="common.yes">Ja</swp-toggle-option>
<swp-toggle-option localize="common.no">Nej</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Betalingsindstillinger -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-credit-card"></i>
<span localize="customers.detail.paymentSettings">Betalingsindstillinger</span>
</swp-card-title>
</swp-card-header>
<swp-toggle-row>
<swp-toggle-info>
<swp-toggle-label localize="customers.detail.requirePrepayment">Kræv forudbetaling</swp-toggle-label>
<swp-toggle-desc>Kunden skal betale fuldt belob ved booking</swp-toggle-desc>
</swp-toggle-info>
<swp-toggle-slider data-value="no">
<swp-toggle-option localize="common.yes">Ja</swp-toggle-option>
<swp-toggle-option localize="common.no">Nej</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
<swp-toggle-row>
<swp-toggle-info>
<swp-toggle-label localize="customers.detail.allowPartialPayment">Tillad delvis betaling</swp-toggle-label>
<swp-toggle-desc>Kunden kan vaelge at betale et depositum</swp-toggle-desc>
</swp-toggle-info>
<swp-toggle-slider data-value="no">
<swp-toggle-option localize="common.yes">Ja</swp-toggle-option>
<swp-toggle-option localize="common.no">Nej</swp-toggle-option>
</swp-toggle-slider>
</swp-toggle-row>
</swp-card>
<!-- Praeferencer -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-sliders-horizontal"></i>
<span localize="customers.detail.preferences">Præferencer</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.preferredHairdresser">Foretrukken frisør</swp-profile-box-label>
<swp-profile-box-value>Emma L.</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box>
<swp-profile-box-label localize="customers.detail.preferredDay">Foretrukken dag</swp-profile-box-label>
<swp-profile-box-value>Tirsdag/Torsdag</swp-profile-box-value>
</swp-profile-box>
<swp-profile-box class="full-width">
<swp-profile-box-label localize="customers.detail.specialRequests">Specielle ønsker</swp-profile-box-label>
<swp-profile-box-value>Foretraekker kold tone, ikke for mørk</swp-profile-box-value>
</swp-profile-box>
</swp-profile-boxes>
</swp-card>
<!-- Advarsler -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-warning"></i>
<span localize="customers.detail.warnings">Advarsler</span>
</swp-card-title>
</swp-card-header>
<swp-profile-boxes>
<swp-profile-box class="warning full-width">
<swp-profile-box-label localize="customers.detail.allergies">Allergier / Følsomhed</swp-profile-box-label>
<swp-profile-box-value>Parfumeallergi - brug uparfumerede produkter</swp-profile-box-value>
</swp-profile-box>
</swp-profile-boxes>
</swp-card>
<!-- Kundegruppe & Relationer -->
<swp-card>
<swp-card-header>
<swp-card-title>
<i class="ph ph-users-three"></i>
<span localize="customers.detail.groupAndRelations">Kundegruppe & Relationer</span>
</swp-card-title>
</swp-card-header>
<swp-customer-group-row>
<swp-customer-group-label>Kundegruppe:</swp-customer-group-label>
<swp-select>
<button type="button">
<swp-select-value>Standard</swp-select-value>
<i class="ph ph-caret-down"></i>
</button>
<swp-select-dropdown>
<swp-select-option class="selected" data-value="standard">Standard</swp-select-option>
<swp-select-option data-value="premium">Premium</swp-select-option>
<swp-select-option data-value="erhverv">Erhverv</swp-select-option>
<swp-select-option data-value="medarbejder">Medarbejder</swp-select-option>
<swp-select-option data-value="familie">Familie & Venner</swp-select-option>
</swp-select-dropdown>
</swp-select>
</swp-customer-group-row>
<swp-relations-list>
<swp-relation-item>
<swp-relation-avatar>EN</swp-relation-avatar>
<swp-relation-info>
<swp-relation-name>Emil Nielsen</swp-relation-name>
<swp-relation-type>Barn</swp-relation-type>
</swp-relation-info>
<swp-relation-actions>
<swp-relation-link>Åbn</swp-relation-link>
<swp-relation-remove>&times;</swp-relation-remove>
</swp-relation-actions>
</swp-relation-item>
<swp-relation-item>
<swp-relation-avatar>LN</swp-relation-avatar>
<swp-relation-info>
<swp-relation-name>Luna Nielsen</swp-relation-name>
<swp-relation-type>Barn</swp-relation-type>
</swp-relation-info>
<swp-relation-actions>
<swp-relation-link>Åbn</swp-relation-link>
<swp-relation-remove>&times;</swp-relation-remove>
</swp-relation-actions>
</swp-relation-item>
<swp-add-relation>
<i class="ph ph-plus"></i>
<span>Tilføj relation</span>
</swp-add-relation>
</swp-relations-list>
</swp-card>
</swp-card-column>
</swp-detail-grid>
</swp-page-container>
</swp-tab-content>
<!-- Placeholder for other tabs -->
<swp-tab-content data-tab="economy">
<swp-page-container>
<swp-empty-state>
<i class="ph ph-currency-circle-dollar"></i>
<span>Økonomi tab - kommer snart</span>
</swp-empty-state>
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="statistics">
<swp-page-container>
<swp-detail-grid>
<!-- Fremmøde & Pålidelighed - spans both columns -->
<swp-card class="full-width">
<swp-card-header>
<swp-card-title>Fremmøde & Pålidelighed</swp-card-title>
</swp-card-header>
<div class="grid-4">
<swp-stat-card class="highlight">
<swp-stat-value>47</swp-stat-value>
<swp-stat-label>Fremmøder</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="warning">
<swp-stat-value>3</swp-stat-value>
<swp-stat-label>Aflysninger</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="danger">
<swp-stat-value>1</swp-stat-value>
<swp-stat-label>No-shows</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="success">
<swp-stat-value>92%</swp-stat-value>
<swp-stat-label>Pålidelighed</swp-stat-label>
</swp-stat-card>
</div>
<swp-attendance-bar>
<swp-attendance-segment class="attended" style="width: 92%;">47</swp-attendance-segment>
<swp-attendance-segment class="cancelled" style="width: 6%;">3</swp-attendance-segment>
<swp-attendance-segment class="noshow" style="width: 2%;">1</swp-attendance-segment>
</swp-attendance-bar>
</swp-card>
<!-- Left Column -->
<swp-card-column>
<!-- Service-mønstre -->
<swp-card>
<swp-card-header>
<swp-card-title>Service-mønstre</swp-card-title>
</swp-card-header>
<div class="grid-2">
<div>
<swp-section-label class="small">Top 3 Services</swp-section-label>
<swp-top-list>
<swp-top-item>
<swp-top-rank>1</swp-top-rank>
<swp-top-name>Klip + Farve</swp-top-name>
<swp-top-count>12×</swp-top-count>
</swp-top-item>
<swp-top-item>
<swp-top-rank>2</swp-top-rank>
<swp-top-name>Farve</swp-top-name>
<swp-top-count>8×</swp-top-count>
</swp-top-item>
<swp-top-item>
<swp-top-rank>3</swp-top-rank>
<swp-top-name>Klip</swp-top-name>
<swp-top-count>6×</swp-top-count>
</swp-top-item>
</swp-top-list>
</div>
<div>
<swp-section-label class="small">Top 3 Produkter</swp-section-label>
<swp-top-list>
<swp-top-item>
<swp-top-rank>1</swp-top-rank>
<swp-top-name>Olaplex No. 3</swp-top-name>
<swp-top-count>5×</swp-top-count>
</swp-top-item>
<swp-top-item>
<swp-top-rank>2</swp-top-rank>
<swp-top-name>Shampoo</swp-top-name>
<swp-top-count>3×</swp-top-count>
</swp-top-item>
<swp-top-item>
<swp-top-rank>3</swp-top-rank>
<swp-top-name>Hårkur</swp-top-name>
<swp-top-count>2×</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>Booking-adfærd</swp-card-title>
</swp-card-header>
<swp-kv-list>
<swp-kv-row>
<swp-kv-label>Gns. bookingvarsel</swp-kv-label>
<swp-kv-value>5 dage</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>Foretrukken dag</swp-kv-label>
<swp-kv-value>Tirsdag</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>Foretrukken tid</swp-kv-label>
<swp-kv-value>10:00 - 12:00</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>Online booking rate</swp-kv-label>
<swp-kv-value>78%</swp-kv-value>
</swp-kv-row>
<swp-kv-row>
<swp-kv-label>Gns. aflysningsvarsel</swp-kv-label>
<swp-kv-value>2 dage</swp-kv-value>
</swp-kv-row>
</swp-kv-list>
</swp-card>
<!-- Loyalitet -->
<swp-card>
<swp-card-header>
<swp-card-title>Loyalitet</swp-card-title>
</swp-card-header>
<div class="grid-2 compact">
<swp-stat-card>
<swp-stat-value class="small">1,8 år</swp-stat-value>
<swp-stat-label>Kunde siden</swp-stat-label>
</swp-stat-card>
<swp-stat-card class="success">
<swp-stat-value class="small">13</swp-stat-value>
<swp-stat-label>Dage siden sidst</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value class="small">
<swp-risk-indicator class="low">
<swp-risk-dot></swp-risk-dot>
<span>Lav</span>
</swp-risk-indicator>
</swp-stat-value>
<swp-stat-label>Churn-risiko</swp-stat-label>
</swp-stat-card>
<swp-stat-card>
<swp-stat-value class="small">32 dage</swp-stat-value>
<swp-stat-label>Gns. interval</swp-stat-label>
</swp-stat-card>
</div>
</swp-card>
</swp-card-column>
</swp-detail-grid>
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="journal">
<swp-page-container>
<!-- Mini Tabs for quick filter -->
<swp-journal-mini-tabs>
<swp-journal-mini-tab class="active">
<span class="tab-dot blue"></span>
Alle
<span class="tab-count">5</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab>
<span class="tab-dot blue"></span>
Noter
<span class="tab-count">2</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab>
<span class="tab-dot amber"></span>
Farveformler
<span class="tab-count">2</span>
</swp-journal-mini-tab>
<swp-journal-mini-tab>
<span class="tab-dot purple"></span>
Analyser
<span class="tab-count">1</span>
</swp-journal-mini-tab>
</swp-journal-mini-tabs>
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Noter header card -->
<swp-card>
<swp-card-header>
<swp-card-title>
<span class="col-dot blue"></span>
<span>Noter</span>
</swp-card-title>
<swp-section-action>+ Tilføj note</swp-section-action>
</swp-card-header>
<swp-journal-entry>
<swp-journal-entry-header>
<swp-journal-entry-type class="note">Note</swp-journal-entry-type>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
Kunden foretrækker naturlige farver og ønsker lidt ekstra tid til konsultation. Husk at tjekke allergistatus inden farvebehandling.
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>9. dec 2025 · Af: Emma</swp-journal-entry-date>
<swp-journal-entry-visibility>
<i class="ph ph-eye"></i>
<span>Alle</span>
</swp-journal-entry-visibility>
</swp-journal-entry-footer>
</swp-journal-entry>
</swp-card>
<!-- Advarsel note card -->
<swp-card>
<swp-journal-entry>
<swp-journal-entry-header>
<swp-journal-entry-type class="advarsel">Advarsel</swp-journal-entry-type>
<swp-journal-entry-tags>
<swp-journal-tag class="allergi">Allergi</swp-journal-tag>
</swp-journal-entry-tags>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
<strong>PARFUMEALLERGI</strong> — Brug kun uparfumerede produkter. Havde reaktion på standard shampoo ved første besøg.
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>15. mar 2024 · Af: Nina</swp-journal-entry-date>
<swp-journal-entry-visibility class="warning">
<i class="ph ph-warning"></i>
<span>Advarsel</span>
</swp-journal-entry-visibility>
</swp-journal-entry-footer>
</swp-journal-entry>
</swp-card>
<!-- Farveformler card -->
<swp-card>
<swp-card-header>
<swp-card-title>
<span class="col-dot amber"></span>
<span>Farveformler</span>
</swp-card-title>
<swp-section-action>+ Tilføj</swp-section-action>
</swp-card-header>
<swp-journal-entry>
<swp-journal-entry-header>
<swp-journal-entry-type class="farveformel">Farveformel</swp-journal-entry-type>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
<span class="label">Måltone:</span> Kold<br>
<span class="label">Oxidant:</span> <span class="mono">6%</span><br>
<span class="label">Formel:</span> <span class="mono">7/1 + 7/0 (1:1)</span><br>
<span class="label">Virketid:</span> <span class="mono">35 min</span><br>
<span class="label">Placering:</span> Hele håret<br><br>
Resultat: Flot ensartet farve, kunden meget tilfreds
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>12. nov 2025 · Af: Emma</swp-journal-entry-date>
</swp-journal-entry-footer>
</swp-journal-entry>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Analyser card -->
<swp-card>
<swp-card-header>
<swp-card-title>
<span class="col-dot purple"></span>
<span>Analyser</span>
</swp-card-title>
<swp-section-action>+ Tilføj</swp-section-action>
</swp-card-header>
<swp-journal-entry>
<swp-journal-entry-header>
<swp-journal-entry-type class="analyse">Håranalyse</swp-journal-entry-type>
<swp-journal-entry-delete><i class="ph ph-trash"></i></swp-journal-entry-delete>
</swp-journal-entry-header>
<swp-journal-entry-body>
<span class="label">Tilstand:</span> God, let tørt i spidserne<br>
<span class="label">Porøsitet:</span> Medium<br>
<span class="label">Elasticitet:</span> Normal<br><br>
Anbefaling: Olaplex behandling hver 6. uge
</swp-journal-entry-body>
<swp-journal-entry-footer>
<swp-journal-entry-date>1. okt 2025 · Af: Maria</swp-journal-entry-date>
</swp-journal-entry-footer>
</swp-journal-entry>
</swp-card>
</swp-card-column>
</swp-detail-grid>
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="appointments">
<swp-page-container>
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Kommende aftaler -->
<swp-card>
<swp-card-header>
<swp-card-title>Kommende aftaler</swp-card-title>
</swp-card-header>
<swp-appointment-card>
<swp-appointment-date>
Tirsdag 14. januar 2026 kl. 10:00
</swp-appointment-date>
<swp-appointment-details>
Klip + Farve · Emma L. · 2 timer
</swp-appointment-details>
<swp-appointment-actions>
<swp-btn class="secondary">Flyt</swp-btn>
<swp-btn class="secondary">Aflys</swp-btn>
</swp-appointment-actions>
</swp-appointment-card>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Tidligere aftaler -->
<swp-card>
<swp-card-header>
<swp-card-title>Tidligere aftaler</swp-card-title>
</swp-card-header>
<swp-table>
<swp-table-header>
<span>Dato</span>
<span>Service</span>
<span>Frisør</span>
<span>Varighed</span>
<span>Pris</span>
</swp-table-header>
<swp-table-row>
<span>9. dec 2025</span>
<span>Klip + Farve</span>
<span>Emma L.</span>
<span>2 timer</span>
<span class="mono">1.450 kr</span>
</swp-table-row>
<swp-table-row>
<span>12. nov 2025</span>
<span>Farve</span>
<span>Emma L.</span>
<span>1t 30m</span>
<span class="mono">1.200 kr</span>
</swp-table-row>
<swp-table-row>
<span>15. okt 2025</span>
<span>Klip</span>
<span>Emma L.</span>
<span>45 min</span>
<span class="mono">550 kr</span>
</swp-table-row>
<swp-table-row>
<span>20. sep 2025</span>
<span>Klip + Behandling</span>
<span>Nina K.</span>
<span>1t 15m</span>
<span class="mono">750 kr</span>
</swp-table-row>
<swp-table-row>
<span>15. aug 2025</span>
<span>Farve + Klip</span>
<span>Emma L.</span>
<span>2t 15m</span>
<span class="mono">1.600 kr</span>
</swp-table-row>
</swp-table>
<swp-see-all>Se alle aftaler →</swp-see-all>
</swp-card>
</swp-card-column>
</swp-detail-grid>
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="giftcards">
<swp-page-container>
<swp-detail-grid>
<!-- Left Column -->
<swp-card-column>
<!-- Aktive gavekort -->
<swp-card>
<swp-card-header>
<swp-card-title>Aktive gavekort</swp-card-title>
</swp-card-header>
<swp-giftcard>
<swp-giftcard-header>
Gavekort #GK-2024-0892
</swp-giftcard-header>
<swp-giftcard-balance>
Saldo: <strong>350 kr</strong> (af 500 kr)
</swp-giftcard-balance>
<swp-progress-bar>
<swp-progress-fill style="width: 70%;"></swp-progress-fill>
</swp-progress-bar>
<swp-giftcard-expires>Udløber: 15. marts 2026</swp-giftcard-expires>
</swp-giftcard>
</swp-card>
<!-- Klippekort -->
<swp-card>
<swp-card-header>
<swp-card-title>Klippekort</swp-card-title>
</swp-card-header>
<swp-giftcard>
<swp-giftcard-header>
10-klip kort
</swp-giftcard-header>
<swp-giftcard-balance>
Brugt: <strong>7 af 10</strong> klip
</swp-giftcard-balance>
<swp-progress-bar>
<swp-progress-fill style="width: 70%;"></swp-progress-fill>
</swp-progress-bar>
<swp-giftcard-expires>Udløber aldrig</swp-giftcard-expires>
</swp-giftcard>
</swp-card>
</swp-card-column>
<!-- Right Column -->
<swp-card-column>
<!-- Udløbne / Brugte -->
<swp-card>
<swp-card-header>
<swp-card-title>Udløbne / Brugte</swp-card-title>
</swp-card-header>
<swp-empty-state>
<p>Ingen udløbne eller brugte kort</p>
</swp-empty-state>
</swp-card>
</swp-card-column>
</swp-detail-grid>
</swp-page-container>
</swp-tab-content>
<swp-tab-content data-tab="activity">
<swp-page-container>
<!-- Filters -->
<swp-activity-filters>
<swp-activity-filter class="active">Alle</swp-activity-filter>
<swp-activity-filter><i class="ph ph-calendar"></i> Bookinger</swp-activity-filter>
<swp-activity-filter><i class="ph ph-envelope"></i> Kommunikation</swp-activity-filter>
<swp-activity-filter><i class="ph ph-pencil-simple"></i> Ændringer</swp-activity-filter>
<swp-activity-filter><i class="ph ph-credit-card"></i> Betalinger</swp-activity-filter>
<swp-activity-filter><i class="ph ph-key"></i> Login</swp-activity-filter>
</swp-activity-filters>
<swp-card>
<swp-activity-timeline>
<!-- I dag -->
<swp-activity-date-group>
<swp-activity-date-header>I dag</swp-activity-date-header>
<swp-activity-item>
<swp-activity-icon class="communication"><i class="ph ph-chat-text"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
<strong>SMS påmindelse</strong> sendt om aftale i morgen
<swp-activity-badge class="auto">Auto</swp-activity-badge>
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>14:00</swp-activity-time>
<swp-activity-actor>System</swp-activity-actor>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
<swp-activity-item>
<swp-activity-icon class="customer"><i class="ph ph-key"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
Kunde <strong>loggede ind</strong> via online booking
<swp-activity-badge class="online">Online</swp-activity-badge>
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>09:15</swp-activity-time>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
</swp-activity-date-group>
<!-- 9. december 2025 -->
<swp-activity-date-group>
<swp-activity-date-header>9. december 2025</swp-activity-date-header>
<swp-activity-item>
<swp-activity-icon class="booking"><i class="ph ph-check-circle"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
<strong>Booking gennemført</strong>
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>12:30</swp-activity-time>
<swp-activity-actor>Farve + Behandling · Emma L.</swp-activity-actor>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
<swp-activity-item>
<swp-activity-icon class="edit"><i class="ph ph-note-pencil"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
<strong>Note tilføjet</strong> — Farveformel opdateret
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>12:45</swp-activity-time>
<swp-activity-actor>Emma L.</swp-activity-actor>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
</swp-activity-date-group>
<!-- 15. november 2025 -->
<swp-activity-date-group>
<swp-activity-date-header>15. november 2025</swp-activity-date-header>
<swp-activity-item>
<swp-activity-icon class="warning"><i class="ph ph-warning"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
<strong>Allergi registreret</strong> — Parfumeallergi tilføjet til profil
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>10:00</swp-activity-time>
<swp-activity-actor>Nina K.</swp-activity-actor>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
</swp-activity-date-group>
<!-- 1. marts 2024 -->
<swp-activity-date-group>
<swp-activity-date-header>1. marts 2024</swp-activity-date-header>
<swp-activity-item>
<swp-activity-icon class="customer"><i class="ph ph-user-plus"></i></swp-activity-icon>
<swp-activity-content>
<swp-activity-title>
<strong>Kunde oprettet</strong> via online booking
</swp-activity-title>
<swp-activity-meta>
<swp-activity-time>14:22</swp-activity-time>
<swp-activity-actor>System</swp-activity-actor>
</swp-activity-meta>
</swp-activity-content>
</swp-activity-item>
</swp-activity-date-group>
</swp-activity-timeline>
</swp-card>
</swp-page-container>
</swp-tab-content>
@section Scripts {
<script>
// Tab switching
document.querySelectorAll('swp-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
// Update tab active state
document.querySelectorAll('swp-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update content visibility
document.querySelectorAll('swp-tab-content').forEach(content => {
content.classList.remove('active');
if (content.dataset.tab === tabName) {
content.classList.add('active');
}
});
});
});
// Booking exclusion toggle (feature-specific)
const bookingExclusion = document.querySelector('swp-booking-exclusion');
if (bookingExclusion) {
bookingExclusion.addEventListener('click', () => {
const isExcluded = bookingExclusion.dataset.excluded === 'true';
bookingExclusion.dataset.excluded = isExcluded ? 'false' : 'true';
const icon = bookingExclusion.querySelector('.icon');
const text = bookingExclusion.querySelector('span:not(.icon)');
if (isExcluded) {
icon.className = 'ph ph-check icon';
text.textContent = 'Booking tilladt';
} else {
icon.className = 'ph ph-x icon';
text.textContent = 'Booking blokeret';
}
});
}
</script>
}
@await Component.InvokeAsync("CustomerDetailView", Model.Id)

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}