Adds customer details drawer to customers list page

Enhances customer management with interactive drawer
Introduces detailed customer profile view with metadata
Implements search functionality and dynamic drawer population

Improves user experience for customer information exploration
This commit is contained in:
Janus C. H. Knudsen 2026-01-19 18:27:59 +01:00
parent 65ad9aacdf
commit 0a431c8db4
8 changed files with 694 additions and 15 deletions

View file

@ -1039,6 +1039,8 @@ swp-form-input {
[data-drawer] swp-section-label {
margin-bottom: 12px;
padding-bottom: 0;
border-bottom: none;
}
[data-drawer] swp-data-section {
@ -1179,7 +1181,7 @@ swp-status-indicator {
&[data-active="false"] {
background: var(--bg-red-medium);
color: var(--color-red);
border: 1px solid var(--bg-red-border);
border: 1px solid var(--border-red);
}
.icon {

View file

@ -4,7 +4,7 @@
* Feature-specific styling only.
* Reuses:
* - swp-sticky-header, swp-header-content, swp-page-container (page.css)
* - swp-stats-row, swp-stat-card (stats.css)
* - swp-stats-row, swp-stat-card, swp-quick-stats (stats.css)
* - swp-action-bar, swp-search-input (components.css, services.css)
* - swp-data-table, swp-avatar, swp-tag, swp-empty-state (components.css)
* - swp-btn (components.css)
@ -66,3 +66,274 @@ swp-card.customers-list swp-data-table-cell:last-child {
gap: var(--spacing-2);
flex-wrap: wrap;
}
/* ===========================================
CUSTOMER DRAWER
Reuses: swp-drawer-* (drawers.css), swp-section-label (components.css),
swp-edit-section/row (components.css), swp-toggle-row/slider (controls.css)
=========================================== */
/* Customer Header */
swp-customer-header {
display: flex;
gap: var(--spacing-6);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--spacing-6);
}
swp-customer-avatar-large {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: var(--color-teal);
color: white;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-semibold);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
swp-customer-header-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing-3);
}
swp-customer-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-4);
}
swp-customer-header-left {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
swp-customer-header-name {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
swp-customer-since {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-customer-header-contact {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
a {
color: var(--color-teal);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
swp-customer-tags {
display: flex;
gap: var(--spacing-2);
margin-top: var(--spacing-1);
}
/* Marketing Section */
swp-marketing-section {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
swp-toggle-row,
swp-toggle-row:last-child {
padding: var(--spacing-4);
background: var(--color-background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
}
/* Profile Boxes (2x2 grid) */
swp-profile-boxes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-4);
}
swp-profile-box {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4);
background: var(--color-background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
&.warning {
background: var(--bg-red-subtle);
border: 1px solid var(--border-red);
swp-profile-box-label {
color: var(--color-red);
}
}
}
swp-profile-box-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
swp-profile-box-value {
font-size: var(--font-size-base);
color: var(--color-text);
}
/* Chart Section */
swp-chart-section {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-6);
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-border);
}
swp-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
swp-chart-legend {
display: flex;
gap: var(--spacing-5);
}
swp-chart-legend-item {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
swp-chart-legend-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
&.services {
background: var(--color-teal);
}
&.products {
background: var(--color-blue);
}
}
swp-chart-container {
width: 100%;
height: 180px;
background: var(--color-background-alt);
border-radius: var(--radius-md);
}
/* Notes Section */
swp-notes-section {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-top: var(--spacing-6);
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-border);
}
swp-note-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4);
background: var(--color-background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
swp-note-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
swp-note-type {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-teal);
text-transform: uppercase;
letter-spacing: 0.5px;
}
swp-note-date {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
swp-note-text {
font-size: var(--font-size-sm);
color: var(--color-text);
line-height: 1.5;
}
swp-see-all-link {
font-size: var(--font-size-sm);
color: var(--color-teal);
cursor: pointer;
text-align: center;
padding-top: var(--spacing-2);
&:hover {
text-decoration: underline;
}
}
/* Edit input variant for drawer */
swp-edit-input {
display: flex;
gap: var(--spacing-2);
input {
flex: 1;
padding: var(--spacing-3) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
&:focus {
outline: none;
border-color: var(--color-teal);
}
&.short {
width: 80px;
flex: none;
}
}
}

View file

@ -33,6 +33,7 @@
[data-drawer="md"] { --drawer-width: 360px; }
[data-drawer="lg"] { --drawer-width: 420px; }
[data-drawer="xl"] { --drawer-width: 480px; }
[data-drawer="xxl"] { --drawer-width: 680px; }
/* Legacy support for existing drawers */
swp-profile-drawer,

View file

@ -286,7 +286,7 @@ swp-employee-status {
&[data-active="false"] {
background: var(--bg-red-medium);
color: var(--color-red);
border: 1px solid var(--bg-red-border);
border: 1px solid var(--border-red);
}
.icon {

View file

@ -226,6 +226,10 @@ swp-quick-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--card-gap);
&.cols-3 {
grid-template-columns: repeat(3, 1fr);
}
}
swp-quick-stat {
@ -235,6 +239,7 @@ swp-quick-stat {
padding: var(--card-padding);
background: var(--color-background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
swp-quick-stat swp-stat-value {
@ -249,6 +254,11 @@ swp-quick-stat swp-stat-label {
color: var(--color-text-secondary);
}
swp-quick-stat.highlight {
background: var(--bg-teal-subtle);
border: 1px solid var(--bg-teal-border);
}
/* ===========================================
RESPONSIVE
=========================================== */

View file

@ -13,6 +13,7 @@ import { CashController } from './modules/cash';
import { EmployeesController } from './modules/employees';
import { ControlsController } from './modules/controls';
import { ServicesController } from './modules/services';
import { CustomersController } from './modules/customers';
import { TrackingController } from './modules/tracking';
/**
@ -28,6 +29,7 @@ export class App {
readonly employees: EmployeesController;
readonly controls: ControlsController;
readonly services: ServicesController;
readonly customers: CustomersController;
readonly tracking: TrackingController;
constructor() {
@ -41,6 +43,7 @@ export class App {
this.employees = new EmployeesController();
this.controls = new ControlsController();
this.services = new ServicesController();
this.customers = new CustomersController();
this.tracking = new TrackingController();
}
}

View file

@ -0,0 +1,235 @@
/**
* Customers Controller
*
* Handles:
* - Fuzzy search with Fuse.js
* - Customer drawer population
*/
import Fuse from 'fuse.js';
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);
}
}