2026-01-19 18:27:59 +01:00
|
|
|
/**
|
|
|
|
|
* Customers Controller
|
|
|
|
|
*
|
|
|
|
|
* Handles:
|
|
|
|
|
* - Fuzzy search with Fuse.js
|
|
|
|
|
* - Customer drawer population
|
2026-01-25 01:55:41 +01:00
|
|
|
* - Customer detail economy chart
|
2026-01-19 18:27:59 +01:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import Fuse from 'fuse.js';
|
2026-01-25 01:55:41 +01:00
|
|
|
import { createChart } from '@sevenweirdpeople/swp-charting';
|
2026-01-19 18:27:59 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-25 01:55:41 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|