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
328 lines
9.6 KiB
TypeScript
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();
|
|
}
|