PlanTempusApp/PlanTempus.Application/wwwroot/ts/modules/customers.ts
Janus C. H. Knudsen 1b25978d9b 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
2026-01-25 01:55:41 +01:00

328 lines
9.6 KiB
TypeScript

/**
* Customers Controller
*
* Handles:
* - Fuzzy search with Fuse.js
* - Customer drawer population
* - Customer detail economy chart
*/
import Fuse from 'fuse.js';
import { createChart } from '@sevenweirdpeople/swp-charting';
interface CustomerItem {
name: string;
phone: string;
email: string;
visits: string;
created: string;
tags: string;
hairdresser: string;
element: HTMLElement;
}
export class CustomersController {
private fuse: Fuse<CustomerItem> | null = null;
private customers: CustomerItem[] = [];
private searchInput: HTMLInputElement | null = null;
private emptyState: HTMLElement | null = null;
private dataTable: HTMLElement | null = null;
constructor() {
// Only initialize if we're on the customers page
const customersTable = document.querySelector('swp-card.customers-list');
if (!customersTable) return;
this.init();
}
private init(): void {
this.searchInput = document.getElementById('searchInput') as HTMLInputElement;
this.emptyState = document.getElementById('emptyState');
this.dataTable = document.querySelector('swp-card.customers-list swp-data-table');
this.buildCustomerIndex();
this.setupSearch();
this.setupDrawerPopulation();
}
private buildCustomerIndex(): void {
const customerRows = document.querySelectorAll('swp-card.customers-list swp-data-table-row');
customerRows.forEach((row) => {
const element = row as HTMLElement;
const cells = element.querySelectorAll('swp-data-table-cell');
const name = element.dataset.name || '';
const phone = cells[1]?.textContent?.trim() || '';
const email = cells[2]?.textContent?.trim() || '';
const visits = element.dataset.visits || '';
const created = element.dataset.created || '';
const tags = element.dataset.tags || '';
const hairdresser = cells[5]?.textContent?.trim() || '';
this.customers.push({
name,
phone,
email,
visits,
created,
tags,
hairdresser,
element
});
});
}
private setupSearch(): void {
if (!this.searchInput) return;
// Initialize Fuse.js with multiple search keys
this.fuse = new Fuse(this.customers, {
keys: ['name', 'phone', 'email'],
threshold: 0.3,
minMatchCharLength: 2
});
// Listen for input with debounce
let debounceTimer: number;
this.searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
const query = (e.target as HTMLInputElement).value.trim();
this.filterCustomers(query);
}, 150);
});
}
private filterCustomers(query: string): void {
if (!query || query.length < 2) {
this.showAll();
return;
}
if (!this.fuse) return;
// Get matching customers
const results = this.fuse.search(query);
const matchingCustomers = new Set(results.map(r => r.item.element));
let visibleCount = 0;
// Show/hide customers
this.customers.forEach(customer => {
if (matchingCustomers.has(customer.element)) {
customer.element.style.display = 'grid';
visibleCount++;
} else {
customer.element.style.display = 'none';
}
});
// Show/hide empty state
this.updateEmptyState(visibleCount);
}
private showAll(): void {
this.customers.forEach(customer => {
customer.element.style.display = 'grid';
});
this.updateEmptyState(this.customers.length);
}
private updateEmptyState(visibleCount: number): void {
if (!this.emptyState || !this.dataTable) return;
if (visibleCount === 0) {
this.emptyState.style.display = 'flex';
// Hide header when no results
const header = this.dataTable.querySelector('swp-data-table-header') as HTMLElement;
if (header) header.style.display = 'none';
} else {
this.emptyState.style.display = 'none';
// Show header when results exist
const header = this.dataTable.querySelector('swp-data-table-header') as HTMLElement;
if (header) header.style.display = 'grid';
}
}
private setupDrawerPopulation(): void {
// Listen for clicks on customer rows to populate drawer
document.addEventListener('click', (e) => {
const row = (e.target as HTMLElement).closest<HTMLElement>('swp-data-table-row[data-drawer-trigger="customer-drawer"]');
if (!row) return;
this.populateDrawer(row);
});
}
private populateDrawer(row: HTMLElement): void {
const cells = row.querySelectorAll('swp-data-table-cell');
const name = row.dataset.name || '';
const phone = cells[1]?.textContent?.trim() || '';
const email = cells[2]?.textContent?.trim() || '';
const visits = row.dataset.visits || '';
const created = row.dataset.created || '';
const tags = row.dataset.tags || '';
const hairdresser = cells[5]?.textContent?.trim() || '';
// Generate initials
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase();
// Format "Kunde siden"
const createdDate = created ? this.formatCreatedDate(created) : 'Ukendt';
// Update drawer elements
const drawerAvatar = document.getElementById('drawerAvatar');
const drawerName = document.getElementById('drawerName');
const drawerSince = document.getElementById('drawerSince');
const drawerPhoneLink = document.getElementById('drawerPhoneLink') as HTMLAnchorElement;
const drawerEmailLink = document.getElementById('drawerEmailLink') as HTMLAnchorElement;
const drawerVisits = document.getElementById('drawerVisits');
const drawerHairdresser = document.getElementById('drawerHairdresser');
const drawerTags = document.getElementById('drawerTags');
const editPhone = document.getElementById('editPhone') as HTMLInputElement;
const editEmail = document.getElementById('editEmail') as HTMLInputElement;
if (drawerAvatar) drawerAvatar.textContent = initials;
if (drawerName) drawerName.textContent = name;
if (drawerSince) drawerSince.textContent = `Kunde siden ${createdDate}`;
if (drawerPhoneLink) {
drawerPhoneLink.textContent = phone;
drawerPhoneLink.href = `tel:${phone.replace(/\s/g, '')}`;
}
if (drawerEmailLink) {
drawerEmailLink.textContent = email;
drawerEmailLink.href = `mailto:${email}`;
}
if (drawerVisits) drawerVisits.textContent = visits;
if (drawerHairdresser) drawerHairdresser.textContent = hairdresser;
// Update editable fields
if (editPhone) editPhone.value = phone;
if (editEmail) editEmail.value = email;
// Update tags
if (drawerTags) {
drawerTags.innerHTML = '';
if (tags) {
tags.split(',').forEach(tag => {
const tagEl = document.createElement('swp-tag');
tagEl.className = tag.trim();
tagEl.textContent = this.formatTagLabel(tag.trim());
drawerTags.appendChild(tagEl);
});
}
}
}
private formatCreatedDate(dateStr: string): string {
const months = [
'januar', 'februar', 'marts', 'april', 'maj', 'juni',
'juli', 'august', 'september', 'oktober', 'november', 'december'
];
const [year, month] = dateStr.split('-');
const monthIndex = parseInt(month, 10) - 1;
return `${months[monthIndex]} ${year}`;
}
private formatTagLabel(tag: string): string {
// Capitalize first letter
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();
}