From 0a431c8db4b3d4a5daacfabe7616516f059158bd Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 19 Jan 2026 18:27:59 +0100 Subject: [PATCH] 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 --- .../Features/Customers/Pages/Index.cshtml | 181 +++++++++++- .../wwwroot/css/components.css | 4 +- .../wwwroot/css/customers.css | 273 +++++++++++++++++- .../wwwroot/css/drawers.css | 1 + .../wwwroot/css/employees.css | 2 +- PlanTempus.Application/wwwroot/css/stats.css | 10 + PlanTempus.Application/wwwroot/ts/app.ts | 3 + .../wwwroot/ts/modules/customers.ts | 235 +++++++++++++++ 8 files changed, 694 insertions(+), 15 deletions(-) create mode 100644 PlanTempus.Application/wwwroot/ts/modules/customers.ts diff --git a/PlanTempus.Application/Features/Customers/Pages/Index.cshtml b/PlanTempus.Application/Features/Customers/Pages/Index.cshtml index 51b92ac..ba4a96c 100644 --- a/PlanTempus.Application/Features/Customers/Pages/Index.cshtml +++ b/PlanTempus.Application/Features/Customers/Pages/Index.cshtml @@ -61,7 +61,7 @@ Tags - + AJ Anna Jensen @@ -75,7 +75,7 @@ - + CH Camilla Holm @@ -91,7 +91,7 @@ - + EL Emma Larsen @@ -105,7 +105,7 @@ - + FC Freja Christensen @@ -122,7 +122,7 @@ - + IA Ida Andersen @@ -138,7 +138,7 @@ - + KB Katrine Berg @@ -152,7 +152,7 @@ - + LF Line Frost @@ -168,7 +168,7 @@ - + LH Louise Hansen @@ -184,7 +184,7 @@ - + MP Maja Petersen @@ -200,7 +200,7 @@ - + MO Maria Olsen @@ -216,7 +216,7 @@ - + RS Rikke Skov @@ -230,7 +230,7 @@ - + SN Sofie Nielsen @@ -253,3 +253,160 @@ + + +
+ + + + Kundekort + + + + + + + + + + SN + + + + Sofie Nielsen + Kunde siden marts 2024 + + + +45 23 45 67 89 + sofie@email.dk + + + + + + + + + + 14 + Besøg + + + 32 dage + Gns. interval + + + Emma L. + Foretrukken frisør + + + + +
+ Kontaktoplysninger + + + Telefon + + + + Email + + + + Adresse + + + + Postnr + By + + + + + + +
+ + +
+ Marketing + + + Email marketing + + Ja + Nej + + + + SMS marketing + + Ja + Nej + + + +
+ + +
+ Profil + + + Hårtype + Medium · Bølget + + + Porøsitet + Medium + + + Præference + Kold tone, ikke for mørk + + + Advarsler + Parfume-allergi + + +
+ + + + + Omsætning (sidste 6 mdr) + + + + Services + + + + Produkter + + + + + + + + + Seneste noter + + + Note + 9. dec 2025 + + Kunden foretrækker naturlige farver og ønsker lidt ekstra tid til konsultation. + + + + Farveformel + 12. nov 2025 + + 7/1 + 7/0 (1:1) · Oxidant 6% · Virketid 35 min + + Se alle noter → + +
+
diff --git a/PlanTempus.Application/wwwroot/css/components.css b/PlanTempus.Application/wwwroot/css/components.css index 3eebf4b..c64415c 100644 --- a/PlanTempus.Application/wwwroot/css/components.css +++ b/PlanTempus.Application/wwwroot/css/components.css @@ -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 { diff --git a/PlanTempus.Application/wwwroot/css/customers.css b/PlanTempus.Application/wwwroot/css/customers.css index 084e1df..1609c05 100644 --- a/PlanTempus.Application/wwwroot/css/customers.css +++ b/PlanTempus.Application/wwwroot/css/customers.css @@ -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; + } + } +} diff --git a/PlanTempus.Application/wwwroot/css/drawers.css b/PlanTempus.Application/wwwroot/css/drawers.css index fda035e..bd9888c 100644 --- a/PlanTempus.Application/wwwroot/css/drawers.css +++ b/PlanTempus.Application/wwwroot/css/drawers.css @@ -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, diff --git a/PlanTempus.Application/wwwroot/css/employees.css b/PlanTempus.Application/wwwroot/css/employees.css index f29bceb..894bf1c 100644 --- a/PlanTempus.Application/wwwroot/css/employees.css +++ b/PlanTempus.Application/wwwroot/css/employees.css @@ -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 { diff --git a/PlanTempus.Application/wwwroot/css/stats.css b/PlanTempus.Application/wwwroot/css/stats.css index b38e800..87e16b2 100644 --- a/PlanTempus.Application/wwwroot/css/stats.css +++ b/PlanTempus.Application/wwwroot/css/stats.css @@ -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 =========================================== */ diff --git a/PlanTempus.Application/wwwroot/ts/app.ts b/PlanTempus.Application/wwwroot/ts/app.ts index ca3a933..595e607 100644 --- a/PlanTempus.Application/wwwroot/ts/app.ts +++ b/PlanTempus.Application/wwwroot/ts/app.ts @@ -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(); } } diff --git a/PlanTempus.Application/wwwroot/ts/modules/customers.ts b/PlanTempus.Application/wwwroot/ts/modules/customers.ts new file mode 100644 index 0000000..8373d31 --- /dev/null +++ b/PlanTempus.Application/wwwroot/ts/modules/customers.ts @@ -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 | 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('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); + } +}