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); + } +}