Adds comprehensive customer detail view components
Implements full customer detail page with multiple feature-rich components including overview, economy, statistics, journal, appointments, giftcards, and activity sections Creates reusable ViewComponents for different customer detail aspects with robust data modeling and presentation logic
This commit is contained in:
parent
38e9243bcd
commit
1b25978d9b
26 changed files with 3792 additions and 956 deletions
|
|
@ -2,7 +2,10 @@
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(npm run build:*)"
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(node -e:*)",
|
||||||
|
"Bash(dir /s /b \"C:\\\\Users\\\\Janus Knudsen\\\\source\\\\swp-repos\\\\PlanTempus\")",
|
||||||
|
"Bash(findstr:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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>×</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>
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PlanTempus.Application.Features.Localization.Services;
|
||||||
|
|
||||||
|
namespace PlanTempus.Application.Features.Customers.Components;
|
||||||
|
|
||||||
|
public class CustomerDetailStatisticsViewComponent : ViewComponent
|
||||||
|
{
|
||||||
|
private readonly ILocalizationService _localization;
|
||||||
|
|
||||||
|
public CustomerDetailStatisticsViewComponent(ILocalizationService localization)
|
||||||
|
{
|
||||||
|
_localization = localization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IViewComponentResult Invoke(string customerId)
|
||||||
|
{
|
||||||
|
var customer = CustomerDetailCatalog.Get(customerId);
|
||||||
|
var stats = customer.Statistics;
|
||||||
|
|
||||||
|
// Calculate widths for the attendance bar
|
||||||
|
var total = stats.Attendance.Attended + stats.Attendance.Cancelled + stats.Attendance.NoShow;
|
||||||
|
|
||||||
|
string attendedWidth, cancelledWidth, noShowWidth;
|
||||||
|
bool allZero = total == 0;
|
||||||
|
|
||||||
|
if (allZero)
|
||||||
|
{
|
||||||
|
// All zero: gray bar with equal segments
|
||||||
|
attendedWidth = "33.33%";
|
||||||
|
cancelledWidth = "33.33%";
|
||||||
|
noShowWidth = "33.34%";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Calculate percentages, but use 12px minimum for zero values
|
||||||
|
var attendedPercent = stats.Attendance.Attended * 100 / total;
|
||||||
|
var cancelledPercent = stats.Attendance.Cancelled * 100 / total;
|
||||||
|
var noShowPercent = stats.Attendance.NoShow * 100 / total;
|
||||||
|
|
||||||
|
attendedWidth = stats.Attendance.Attended == 0 ? "12px" : $"{attendedPercent}%";
|
||||||
|
cancelledWidth = stats.Attendance.Cancelled == 0 ? "12px" : $"{cancelledPercent}%";
|
||||||
|
noShowWidth = stats.Attendance.NoShow == 0 ? "12px" : $"{noShowPercent}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = new CustomerDetailStatisticsViewModel
|
||||||
|
{
|
||||||
|
// Attendance
|
||||||
|
AttendanceTitle = "Fremmøde & Pålidelighed",
|
||||||
|
Attended = stats.Attendance.Attended,
|
||||||
|
AttendedLabel = "Fremmøder",
|
||||||
|
Cancelled = stats.Attendance.Cancelled,
|
||||||
|
CancelledLabel = "Aflysninger",
|
||||||
|
NoShow = stats.Attendance.NoShow,
|
||||||
|
NoShowLabel = "No-shows",
|
||||||
|
ReliabilityPercent = stats.Attendance.ReliabilityPercent,
|
||||||
|
ReliabilityLabel = "Pålidelighed",
|
||||||
|
AttendedWidth = attendedWidth,
|
||||||
|
CancelledWidth = cancelledWidth,
|
||||||
|
NoShowWidth = noShowWidth,
|
||||||
|
AllZero = allZero,
|
||||||
|
|
||||||
|
// Service patterns
|
||||||
|
ServicePatternsTitle = "Service-mønstre",
|
||||||
|
TopServicesLabel = "Top 3 Services",
|
||||||
|
TopServices = stats.TopServices.Select((s, i) => new TopItemViewModel
|
||||||
|
{
|
||||||
|
Rank = i + 1,
|
||||||
|
Name = s.Name,
|
||||||
|
Count = s.Count
|
||||||
|
}).ToList(),
|
||||||
|
TopProductsLabel = "Top 3 Produkter",
|
||||||
|
TopProducts = stats.TopProducts.Select((p, i) => new TopItemViewModel
|
||||||
|
{
|
||||||
|
Rank = i + 1,
|
||||||
|
Name = p.Name,
|
||||||
|
Count = p.Count
|
||||||
|
}).ToList(),
|
||||||
|
|
||||||
|
// Booking behavior
|
||||||
|
BookingBehaviorTitle = "Booking-adfærd",
|
||||||
|
AvgBookingNotice = $"{stats.BookingBehavior.AvgBookingNoticeDays} dage",
|
||||||
|
AvgBookingNoticeLabel = "Gns. bookingvarsel",
|
||||||
|
PreferredDay = stats.BookingBehavior.PreferredDay,
|
||||||
|
PreferredDayLabel = "Foretrukken dag",
|
||||||
|
PreferredTimeSlot = stats.BookingBehavior.PreferredTimeSlot,
|
||||||
|
PreferredTimeSlotLabel = "Foretrukken tid",
|
||||||
|
OnlineBookingRate = $"{stats.BookingBehavior.OnlineBookingRate}%",
|
||||||
|
OnlineBookingRateLabel = "Online booking rate",
|
||||||
|
AvgCancellationNotice = $"{stats.BookingBehavior.AvgCancellationNoticeDays} dage",
|
||||||
|
AvgCancellationNoticeLabel = "Gns. aflysningsvarsel",
|
||||||
|
|
||||||
|
// Loyalty
|
||||||
|
LoyaltyTitle = "Loyalitet",
|
||||||
|
CustomerSinceYears = $"{stats.Loyalty.CustomerSinceYears:0.0} ar".Replace(".", ","),
|
||||||
|
CustomerSinceYearsLabel = "Kunde siden",
|
||||||
|
DaysSinceLastVisit = stats.Loyalty.DaysSinceLastVisit,
|
||||||
|
DaysSinceLastVisitLabel = "Dage siden sidst",
|
||||||
|
ChurnRisk = stats.Loyalty.ChurnRisk,
|
||||||
|
ChurnRiskLabel = "Churn-risiko",
|
||||||
|
AvgIntervalDays = $"{stats.Loyalty.AvgIntervalDays} dage",
|
||||||
|
AvgIntervalDaysLabel = "Gns. interval"
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomerDetailStatisticsViewModel
|
||||||
|
{
|
||||||
|
// Attendance
|
||||||
|
public required string AttendanceTitle { get; init; }
|
||||||
|
public int Attended { get; init; }
|
||||||
|
public required string AttendedLabel { get; init; }
|
||||||
|
public int Cancelled { get; init; }
|
||||||
|
public required string CancelledLabel { get; init; }
|
||||||
|
public int NoShow { get; init; }
|
||||||
|
public required string NoShowLabel { get; init; }
|
||||||
|
public int ReliabilityPercent { get; init; }
|
||||||
|
public required string ReliabilityLabel { get; init; }
|
||||||
|
public required string AttendedWidth { get; init; }
|
||||||
|
public required string CancelledWidth { get; init; }
|
||||||
|
public required string NoShowWidth { get; init; }
|
||||||
|
public bool AllZero { get; init; }
|
||||||
|
|
||||||
|
// Service patterns
|
||||||
|
public required string ServicePatternsTitle { get; init; }
|
||||||
|
public required string TopServicesLabel { get; init; }
|
||||||
|
public List<TopItemViewModel> TopServices { get; init; } = new();
|
||||||
|
public required string TopProductsLabel { get; init; }
|
||||||
|
public List<TopItemViewModel> TopProducts { get; init; } = new();
|
||||||
|
|
||||||
|
// Booking behavior
|
||||||
|
public required string BookingBehaviorTitle { get; init; }
|
||||||
|
public required string AvgBookingNotice { get; init; }
|
||||||
|
public required string AvgBookingNoticeLabel { get; init; }
|
||||||
|
public required string PreferredDay { get; init; }
|
||||||
|
public required string PreferredDayLabel { get; init; }
|
||||||
|
public required string PreferredTimeSlot { get; init; }
|
||||||
|
public required string PreferredTimeSlotLabel { get; init; }
|
||||||
|
public required string OnlineBookingRate { get; init; }
|
||||||
|
public required string OnlineBookingRateLabel { get; init; }
|
||||||
|
public required string AvgCancellationNotice { get; init; }
|
||||||
|
public required string AvgCancellationNoticeLabel { get; init; }
|
||||||
|
|
||||||
|
// Loyalty
|
||||||
|
public required string LoyaltyTitle { get; init; }
|
||||||
|
public required string CustomerSinceYears { get; init; }
|
||||||
|
public required string CustomerSinceYearsLabel { get; init; }
|
||||||
|
public int DaysSinceLastVisit { get; init; }
|
||||||
|
public required string DaysSinceLastVisitLabel { get; init; }
|
||||||
|
public required string ChurnRisk { get; init; }
|
||||||
|
public required string ChurnRiskLabel { get; init; }
|
||||||
|
public required string AvgIntervalDays { get; init; }
|
||||||
|
public required string AvgIntervalDaysLabel { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TopItemViewModel
|
||||||
|
{
|
||||||
|
public int Rank { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public int Count { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
@model PlanTempus.Application.Features.Customers.Components.CustomerDetailStatisticsViewModel
|
||||||
|
|
||||||
|
<swp-detail-grid>
|
||||||
|
<!-- Fremmøde & Pålidelighed - spans both columns -->
|
||||||
|
<swp-card class="full-width">
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>@Model.AttendanceTitle</swp-card-title>
|
||||||
|
</swp-card-header>
|
||||||
|
<div class="grid-4">
|
||||||
|
<swp-stat-card class="highlight">
|
||||||
|
<swp-stat-value>@Model.Attended</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.AttendedLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="warning">
|
||||||
|
<swp-stat-value>@Model.Cancelled</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.CancelledLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="danger">
|
||||||
|
<swp-stat-value>@Model.NoShow</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.NoShowLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="success">
|
||||||
|
<swp-stat-value>@Model.ReliabilityPercent%</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.ReliabilityLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
</div>
|
||||||
|
<swp-attendance-bar class="@(Model.AllZero ? "empty" : "")">
|
||||||
|
<swp-attendance-segment class="attended" style="width: @Model.AttendedWidth;">@Model.Attended</swp-attendance-segment>
|
||||||
|
<swp-attendance-segment class="cancelled" style="width: @Model.CancelledWidth;">@Model.Cancelled</swp-attendance-segment>
|
||||||
|
<swp-attendance-segment class="noshow" style="width: @Model.NoShowWidth;">@Model.NoShow</swp-attendance-segment>
|
||||||
|
</swp-attendance-bar>
|
||||||
|
</swp-card>
|
||||||
|
|
||||||
|
<!-- Left Column -->
|
||||||
|
<swp-card-column>
|
||||||
|
<!-- Service-mønstre -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>@Model.ServicePatternsTitle</swp-card-title>
|
||||||
|
</swp-card-header>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<swp-section-label class="small">@Model.TopServicesLabel</swp-section-label>
|
||||||
|
<swp-top-list>
|
||||||
|
@foreach (var service in Model.TopServices)
|
||||||
|
{
|
||||||
|
<swp-top-item>
|
||||||
|
<swp-top-rank>@service.Rank</swp-top-rank>
|
||||||
|
<swp-top-name>@service.Name</swp-top-name>
|
||||||
|
<swp-top-count>@(service.Count)x</swp-top-count>
|
||||||
|
</swp-top-item>
|
||||||
|
}
|
||||||
|
</swp-top-list>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<swp-section-label class="small">@Model.TopProductsLabel</swp-section-label>
|
||||||
|
<swp-top-list>
|
||||||
|
@foreach (var product in Model.TopProducts)
|
||||||
|
{
|
||||||
|
<swp-top-item>
|
||||||
|
<swp-top-rank>@product.Rank</swp-top-rank>
|
||||||
|
<swp-top-name>@product.Name</swp-top-name>
|
||||||
|
<swp-top-count>@(product.Count)x</swp-top-count>
|
||||||
|
</swp-top-item>
|
||||||
|
}
|
||||||
|
</swp-top-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</swp-card>
|
||||||
|
</swp-card-column>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<swp-card-column>
|
||||||
|
<!-- Booking-adfærd -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>@Model.BookingBehaviorTitle</swp-card-title>
|
||||||
|
</swp-card-header>
|
||||||
|
<swp-kv-list>
|
||||||
|
<swp-kv-row>
|
||||||
|
<swp-kv-label>@Model.AvgBookingNoticeLabel</swp-kv-label>
|
||||||
|
<swp-kv-value>@Model.AvgBookingNotice</swp-kv-value>
|
||||||
|
</swp-kv-row>
|
||||||
|
<swp-kv-row>
|
||||||
|
<swp-kv-label>@Model.PreferredDayLabel</swp-kv-label>
|
||||||
|
<swp-kv-value>@Model.PreferredDay</swp-kv-value>
|
||||||
|
</swp-kv-row>
|
||||||
|
<swp-kv-row>
|
||||||
|
<swp-kv-label>@Model.PreferredTimeSlotLabel</swp-kv-label>
|
||||||
|
<swp-kv-value>@Model.PreferredTimeSlot</swp-kv-value>
|
||||||
|
</swp-kv-row>
|
||||||
|
<swp-kv-row>
|
||||||
|
<swp-kv-label>@Model.OnlineBookingRateLabel</swp-kv-label>
|
||||||
|
<swp-kv-value>@Model.OnlineBookingRate</swp-kv-value>
|
||||||
|
</swp-kv-row>
|
||||||
|
<swp-kv-row>
|
||||||
|
<swp-kv-label>@Model.AvgCancellationNoticeLabel</swp-kv-label>
|
||||||
|
<swp-kv-value>@Model.AvgCancellationNotice</swp-kv-value>
|
||||||
|
</swp-kv-row>
|
||||||
|
</swp-kv-list>
|
||||||
|
</swp-card>
|
||||||
|
|
||||||
|
<!-- Loyalitet -->
|
||||||
|
<swp-card>
|
||||||
|
<swp-card-header>
|
||||||
|
<swp-card-title>@Model.LoyaltyTitle</swp-card-title>
|
||||||
|
</swp-card-header>
|
||||||
|
<div class="grid-2 compact">
|
||||||
|
<swp-stat-card>
|
||||||
|
<swp-stat-value class="small">@Model.CustomerSinceYears</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.CustomerSinceYearsLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card class="success">
|
||||||
|
<swp-stat-value class="small">@Model.DaysSinceLastVisit</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.DaysSinceLastVisitLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card>
|
||||||
|
<swp-stat-value class="small">
|
||||||
|
<swp-risk-indicator class="@Model.ChurnRisk">
|
||||||
|
<swp-risk-dot></swp-risk-dot>
|
||||||
|
@{
|
||||||
|
var riskText = Model.ChurnRisk switch
|
||||||
|
{
|
||||||
|
"low" => "Lav",
|
||||||
|
"medium" => "Medium",
|
||||||
|
"high" => "Hoj",
|
||||||
|
_ => Model.ChurnRisk
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<span>@riskText</span>
|
||||||
|
</swp-risk-indicator>
|
||||||
|
</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.ChurnRiskLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
<swp-stat-card>
|
||||||
|
<swp-stat-value class="small">@Model.AvgIntervalDays</swp-stat-value>
|
||||||
|
<swp-stat-label>@Model.AvgIntervalDaysLabel</swp-stat-label>
|
||||||
|
</swp-stat-card>
|
||||||
|
</div>
|
||||||
|
</swp-card>
|
||||||
|
</swp-card-column>
|
||||||
|
</swp-detail-grid>
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -1,898 +1,7 @@
|
||||||
@page "/kunder/{id}"
|
@page "/kunder/{id}"
|
||||||
@model PlanTempus.Application.Features.Customers.Pages.DetailModel
|
@model PlanTempus.Application.Features.Customers.Pages.DetailModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Kundedetaljer - Sofie Nielsen";
|
ViewData["Title"] = "Kundedetaljer";
|
||||||
}
|
}
|
||||||
|
|
||||||
<swp-sticky-header>
|
@await Component.InvokeAsync("CustomerDetailView", Model.Id)
|
||||||
<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>×</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>×</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>
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -656,8 +656,52 @@
|
||||||
"seeAllNotes": "Se alle noter →"
|
"seeAllNotes": "Se alle noter →"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
|
"back": "Tilbage til kunder",
|
||||||
|
"delete": "Slet kunde",
|
||||||
|
"save": "Gem",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Oversigt",
|
||||||
|
"economy": "Økonomi",
|
||||||
|
"statistics": "Statistik",
|
||||||
|
"journal": "Journal",
|
||||||
|
"appointments": "Aftaler",
|
||||||
|
"giftcards": "Gavekort",
|
||||||
"activitylog": "Aktivitetslog"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -656,8 +656,52 @@
|
||||||
"seeAllNotes": "See all notes →"
|
"seeAllNotes": "See all notes →"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
|
"back": "Back to customers",
|
||||||
|
"delete": "Delete customer",
|
||||||
|
"save": "Save",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"economy": "Economy",
|
||||||
|
"statistics": "Statistics",
|
||||||
|
"journal": "Journal",
|
||||||
|
"appointments": "Appointments",
|
||||||
|
"giftcards": "Gift cards",
|
||||||
"activitylog": "Activity log"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -617,68 +617,6 @@ swp-profile-box.full-width {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
|
||||||
CUSTOMER DETAIL - APPOINTMENTS TABLE
|
|
||||||
=========================================== */
|
|
||||||
swp-card.customer-appointments {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-appointments swp-card-header {
|
|
||||||
padding: var(--spacing-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-appointments swp-data-table {
|
|
||||||
grid-template-columns: 80px 60px 1fr 100px 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===========================================
|
|
||||||
CUSTOMER DETAIL - GIFTCARDS TABLE
|
|
||||||
=========================================== */
|
|
||||||
swp-card.customer-giftcards {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-giftcards swp-card-header {
|
|
||||||
padding: var(--spacing-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-giftcards swp-data-table {
|
|
||||||
grid-template-columns: 140px 100px 80px 80px 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===========================================
|
|
||||||
CUSTOMER DETAIL - ACTIVITY LIST
|
|
||||||
=========================================== */
|
|
||||||
swp-card.customer-activity {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-activity swp-card-header {
|
|
||||||
padding: var(--spacing-6);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-activity swp-attention-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 56px 1fr 100px;
|
|
||||||
padding: var(--spacing-4);
|
|
||||||
gap: var(--spacing-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-activity swp-attention-item {
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
swp-card.customer-activity swp-attention-action {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
CUSTOMER DETAIL - JOURNAL NOTES
|
CUSTOMER DETAIL - JOURNAL NOTES
|
||||||
Override notes-section when inside a card
|
Override notes-section when inside a card
|
||||||
|
|
@ -798,6 +736,12 @@ swp-attendance-segment.noshow {
|
||||||
background: var(--color-red);
|
background: var(--color-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empty state: all segments gray */
|
||||||
|
swp-attendance-bar.empty swp-attendance-segment {
|
||||||
|
background: var(--color-gray-300);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
/* Top List */
|
/* Top List */
|
||||||
swp-top-list {
|
swp-top-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1468,3 +1412,29 @@ swp-activity-actor {
|
||||||
margin-top: var(--spacing-6);
|
margin-top: var(--spacing-6);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
CUSTOMER DETAIL - ECONOMY TAB
|
||||||
|
=========================================== */
|
||||||
|
.customer-economy {
|
||||||
|
swp-data-table {
|
||||||
|
grid-template-columns: 110px 110px 155px 1fr 110px 150px
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-data-table-header swp-data-table-cell:last-child,
|
||||||
|
swp-data-table-row swp-data-table-cell:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purchase type tags - matches chart legend colors (services=teal, products=blue) */
|
||||||
|
swp-tag.service {
|
||||||
|
background: var(--bg-teal-strong);
|
||||||
|
color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-tag.product {
|
||||||
|
background: var(--bg-blue-strong);
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Fuzzy search with Fuse.js
|
* - Fuzzy search with Fuse.js
|
||||||
* - Customer drawer population
|
* - Customer drawer population
|
||||||
|
* - Customer detail economy chart
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
import { createChart } from '@sevenweirdpeople/swp-charting';
|
||||||
|
|
||||||
interface CustomerItem {
|
interface CustomerItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -233,3 +235,94 @@ export class CustomersController {
|
||||||
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer Economy Controller
|
||||||
|
*
|
||||||
|
* Handles the economy chart on customer detail page.
|
||||||
|
* Initializes chart lazily when economy tab is shown.
|
||||||
|
*/
|
||||||
|
interface ChartDataPoint {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartSeries {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
data: ChartDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerChartData {
|
||||||
|
categories: string[];
|
||||||
|
series: ChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomerEconomyController {
|
||||||
|
private chartInitialized = false;
|
||||||
|
private chart: ReturnType<typeof createChart> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupTabListener();
|
||||||
|
// Check if economy tab is already active on page load
|
||||||
|
this.checkInitialTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTabListener(): void {
|
||||||
|
document.addEventListener('click', (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tab = target.closest<HTMLElement>('swp-tab[data-tab="economy"]');
|
||||||
|
|
||||||
|
if (tab) {
|
||||||
|
// Small delay to let tab content become visible
|
||||||
|
setTimeout(() => this.initializeChart(), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkInitialTab(): void {
|
||||||
|
const activeTab = document.querySelector('swp-tab[data-tab="economy"].active');
|
||||||
|
if (activeTab) {
|
||||||
|
this.initializeChart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeChart(): void {
|
||||||
|
if (this.chartInitialized) return;
|
||||||
|
|
||||||
|
const container = document.getElementById('customerRevenueChart');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const dataScript = document.getElementById('customerRevenueChartData');
|
||||||
|
if (!dataScript) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataScript.textContent || '') as CustomerChartData;
|
||||||
|
this.createRevenueChart(container, data);
|
||||||
|
this.chartInitialized = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse chart data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRevenueChart(container: HTMLElement, data: CustomerChartData): void {
|
||||||
|
this.chart = createChart(container, {
|
||||||
|
deferRender: true,
|
||||||
|
height: 200,
|
||||||
|
xAxis: {
|
||||||
|
categories: data.categories
|
||||||
|
},
|
||||||
|
series: data.series.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
data: s.data
|
||||||
|
})),
|
||||||
|
legend: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize economy controller if on customer detail page
|
||||||
|
if (document.getElementById('customerRevenueChart') || document.querySelector('swp-tab[data-tab="economy"]')) {
|
||||||
|
new CustomerEconomyController();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue