From 439903fda4dd0c0ead783bb0bbef06e32341981c Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Sat, 27 Dec 2025 12:18:23 +0100 Subject: [PATCH] Adds comprehensive services management for employees Introduces advanced drag-and-drop services selection interface with dynamic filtering and editing capabilities Enables employees to: - Customize service list with multi-select and drag functionality - Search and filter available services - Override individual service prices and durations - Manage services across expandable panel Supports responsive and interactive service management workflow --- wwwroot/data/kk-services.json | 125 ++++ wwwroot/icons/drag.svg | 2 + wwwroot/poc-employee.html | 1116 ++++++++++++++++++++++++++++----- 3 files changed, 1100 insertions(+), 143 deletions(-) create mode 100644 wwwroot/data/kk-services.json create mode 100644 wwwroot/icons/drag.svg diff --git a/wwwroot/data/kk-services.json b/wwwroot/data/kk-services.json new file mode 100644 index 0000000..5c84a0a --- /dev/null +++ b/wwwroot/data/kk-services.json @@ -0,0 +1,125 @@ +{ + "Klip dame, herre og børn": [ + { "name": "Dameklip", "duration": 60, "price": 725 }, + { "name": "Dameklip uden snak", "duration": 60, "price": 725 }, + { "name": "Dameklip spidser mellemlangt og langt hår", "duration": 40, "price": 575 }, + { "name": "Dameklip Luksus ekstra glans og pleje", "duration": 75, "price": 925 }, + { "name": "Herreklip", "duration": 60, "price": 645 }, + { "name": "Herreklip uden snak", "duration": 60, "price": 645 }, + { "name": "Skin fade", "duration": 60, "price": 645 }, + { "name": "Klip med maskine (herre klip)", "duration": 30, "price": 475 }, + { "name": "Børneklip 0-4 år", "duration": 45, "price": 475 }, + { "name": "Børneklip 4-8 år", "duration": 45, "price": 525 }, + { "name": "Touch up Dame", "duration": 10, "price": 0 }, + { "name": "Touch up Herre", "duration": 10, "price": 0 }, + { "name": "Pandehår helt nyt", "duration": 20, "price": 325 }, + { "name": "Konsultation uden behandling", "duration": 10, "price": 0 }, + { "name": "Herreklip", "duration": 60, "price": 550 }, + { "name": "Klip med maskine (herre klip)", "duration": 45, "price": 525 } + ], + + "Farvebehandlinger": [ + { "name": "Bundfarve almindelig udgroning maks 3 cm", "duration": 90, "price": 785 }, + { "name": "Helfarve kort hår", "duration": 105, "price": 950 }, + { "name": "Helfarve mellemlangt hår", "duration": 120, "price": 1450 }, + { "name": "Helfarve langt hår", "duration": 120, "price": 1550 }, + { "name": "Bundfarve/ Lysning", "duration": 105, "price": 975 }, + { "name": "Afblegning kort hår + gloss", "duration": 150, "price": 1895 } + ], + + "Striber/ Refleksbehandling": [ + { "name": "Striber kort hår", "duration": 120, "price": 1465 }, + { "name": "Striber mellemlangt hår", "duration": 150, "price": 1665 }, + { "name": "Striber langt hår", "duration": 180, "price": 1865 }, + { "name": "Striber på toppen/ overhår", "duration": 90, "price": 1065 }, + { "name": "Striber babylights tæt lysning mellemlangt hår", "duration": 180, "price": 2650 }, + { "name": "Striber babylights tæt lysning langt hår", "duration": 180, "price": 2850 }, + { "name": "Striber babylights tæt lysning på toppen", "duration": 120, "price": 1650 }, + { "name": "AirTouch skulderlangt hår", "duration": 210, "price": 3250 }, + { "name": "AirTouch langt hår", "duration": 240, "price": 3850 } + ], + + "Hårvask med styling eller uden styling": [ + { "name": "Hårvask uden styling", "duration": 30, "price": 265 }, + { "name": "Hårvask med styling (kun føn)", "duration": 40, "price": 450 }, + { "name": "Vask + Styling med varme glatning/krøller (mellemlangt/langt)", "duration": 60, "price": 650 } + ], + + "Henna naturlig hårfarver": [ + { "name": "Henna kort hår", "duration": 90, "price": 965 }, + { "name": "Henna mellemlangt/langt hår", "duration": 90, "price": 1265 }, + { "name": "Henna bundfarve", "duration": 90, "price": 750 } + ], + + "Kurbehandling": [ + { "name": "Olaplex Stand alone", "duration": 60, "price": 550 }, + { "name": "Kurbehandling fugt/protein", "duration": 40, "price": 365 } + ], + + "Bryn og vipper": [ + { "name": "Farvning vipper & bryn", "duration": 30, "price": 345 }, + { "name": "Farvning vipper", "duration": 20, "price": 185 }, + { "name": "Farvning og retning af bryn", "duration": 20, "price": 185 }, + { "name": "Retning af bryn", "duration": 10, "price": 100 } + ], + + "Balayage": [ + { "name": "Balayage maks til skulderen", "duration": 150, "price": 1850 }, + { "name": "Balayage maks skulder + gloss/toning", "duration": 180, "price": 2250 }, + { "name": "Balayage langt hår", "duration": 150, "price": 2150 }, + { "name": "Balayage langt hår + gloss/toning", "duration": 180, "price": 2550 } + ], + + "Skæg": [ + { "name": "Skægtrim", "duration": 20, "price": 300 } + ], + + "Gloss": [ + { "name": "Gloss ekstra langt/tykt hår", "duration": 75, "price": 900 }, + { "name": "Glossing kort hår", "duration": 60, "price": 685 }, + { "name": "Glossing mellemlangt/ langt hår", "duration": 60, "price": 745 }, + { "name": "Glossing mænd", "duration": 40, "price": 350 }, + { "name": "Gloss ifb. anden farvebehandling", "duration": 20, "price": 450 }, + { "name": "Gloss ifb. anden farvebehandling", "duration": 30, "price": 450 } + ], + + "Håropsætning": [ + { "name": "Håropsætning kort hår", "duration": 60, "price": 850 }, + { "name": "Håropsætning langt hår", "duration": 60, "price": 1450 }, + { "name": "Håropsætning Brud/brudepiger/Galla/Oscar", "duration": 90, "price": 1599 }, + { "name": "Make-up Special Brud/Galla mm", "duration": 90, "price": 3000 } + ], + + "Modeller": [ + { "name": "Dameklip Model", "duration": 60, "price": 0 }, + { "name": "Herreklip Model", "duration": 60, "price": 0 }, + { "name": "Balayage Model", "duration": 240, "price": 0 }, + { "name": "Striber Model", "duration": 180, "price": 0 }, + { "name": "Bryn & Vippe Model", "duration": 40, "price": 0 }, + { "name": "Bundfarve Model", "duration": 120, "price": 0 }, + { "name": "Gloss", "duration": 30, "price": 0 } + ], + + "Tristan farve modeller": [ + { "name": "Bundfarve med HP/HPF", "duration": 90, "price": 325 }, + { "name": "Striber Model", "duration": 240, "price": 400 } + ], + + "Uden kategori": [ + { "name": "P-afgift", "duration": 0, "price": -25 }, + { "name": "Børneklip 9-12 år", "duration": 60, "price": 450 } + ], + + "Tilvalg services": [ + { "name": "Touch up kur", "duration": 15, "price": 175 }, + { "name": "Root shading", "duration": 30, "price": 425 }, + { "name": "Styling med varme (efter behandling)", "duration": 60, "price": 475 }, + { "name": "Styling kort hår (efter farve)", "duration": 20, "price": 175 }, + { "name": "Olaplex efter afblegning", "duration": 10, "price": 325 }, + { "name": "Let afrensning af gloss/klor/kemi", "duration": 20, "price": 220 }, + { "name": "Forpigmentering", "duration": 20, "price": 300 }, + { "name": "Knække bund ifb. farvebehandling", "duration": 20, "price": 400 }, + { "name": "Olaplex i farve", "duration": 10, "price": 230 }, + { "name": "Metal DX intens kur redken gloss", "duration": 20, "price": 225 } + ] +} diff --git a/wwwroot/icons/drag.svg b/wwwroot/icons/drag.svg new file mode 100644 index 0000000..0ecb252 --- /dev/null +++ b/wwwroot/icons/drag.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/wwwroot/poc-employee.html b/wwwroot/poc-employee.html index 0e079c5..52454d3 100644 --- a/wwwroot/poc-employee.html +++ b/wwwroot/poc-employee.html @@ -611,6 +611,356 @@ display: block; } + swp-tab-info { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + background: color-mix(in srgb, var(--color-teal) 8%, white); + border: 1px solid color-mix(in srgb, var(--color-teal) 25%, white); + border-radius: 8px; + margin-bottom: 20px; + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.4; + } + + swp-tab-info svg { + flex-shrink: 0; + color: var(--color-teal); + margin-top: 1px; + } + + swp-tab-info strong { + color: var(--color-teal); + font-weight: 600; + } + + /* ========================================== + SERVICES DRAG-DROP + ========================================== */ + .services-grid { + align-items: start; + } + + /* Services layout with collapsible panel */ + .services-layout { + display: flex; + gap: 0; + align-items: stretch; + min-height: 500px; + } + + swp-services-panel { + display: flex; + transition: width 300ms ease; + overflow: hidden; + } + + swp-services-panel.collapsed { + width: 40px; + } + + swp-services-panel.expanded { + width: 420px; + } + + swp-panel-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + min-width: 40px; + background: linear-gradient(135deg, var(--color-teal) 0%, color-mix(in srgb, var(--color-teal) 80%, #000) 100%); + color: white; + cursor: pointer; + border-radius: 8px 0 0 8px; + transition: all 200ms ease; + flex-shrink: 0; + } + + swp-panel-toggle:hover { + background: linear-gradient(135deg, color-mix(in srgb, var(--color-teal) 90%, #fff) 0%, var(--color-teal) 100%); + } + + swp-panel-toggle svg { + transition: transform 200ms ease; + } + + swp-services-panel.expanded swp-panel-toggle svg { + transform: rotate(180deg); + } + + swp-panel-content { + display: none; + flex: 1; + min-width: 0; + } + + swp-services-panel.expanded swp-panel-content { + display: block; + } + + swp-services-panel swp-card { + border-radius: 0 8px 8px 0; + border-left: none; + height: 100%; + } + + .selected-services-card { + flex: 1; + } + + /* Edit button in section label */ + .selected-services-card swp-section-label { + display: flex; + align-items: center; + justify-content: space-between; + } + + .edit-services-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--color-teal); + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 150ms ease; + } + + .edit-services-btn:hover { + background: color-mix(in srgb, var(--color-teal) 85%, #000); + } + + .edit-services-btn.active { + background: var(--color-text-secondary); + } + + .services-card { + display: flex; + flex-direction: column; + } + + .services-card swp-search-field { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + margin-bottom: 12px; + } + + .services-card swp-search-field img { + opacity: 0.5; + flex-shrink: 0; + } + + .services-card swp-search-field input { + flex: 1; + border: none; + background: none; + outline: none; + font-size: 14px; + color: var(--color-text); + } + + .services-card swp-search-field input::placeholder { + color: var(--color-text-muted); + } + + swp-services-selected, + swp-services-available { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 300px; + max-height: 600px; + overflow-y: auto; + padding: 4px; + border-radius: 8px; + transition: all 150ms ease; + } + + swp-services-selected.drag-over, + swp-services-available.drag-over { + background: color-mix(in srgb, var(--color-teal) 8%, white); + outline: 2px dashed var(--color-teal); + outline-offset: -2px; + } + + swp-services-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 40px 20px; + color: var(--color-text-muted); + border: 2px dashed var(--color-border); + border-radius: 8px; + text-align: center; + font-size: 14px; + } + + swp-services-empty svg { + opacity: 0.4; + } + + /* Service category in columns */ + swp-service-category-group { + display: flex; + flex-direction: column; + gap: 4px; + } + + swp-service-category-header { + font-size: 11px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 0 4px 0; + border-bottom: 1px solid var(--color-border); + margin-bottom: 4px; + } + + /* Service row - draggable */ + swp-service-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: url('icons/drag.svg') 12 12, grab; + transition: all 150ms ease; + user-select: none; + } + + swp-service-row:hover { + border-color: var(--color-teal); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + } + + swp-service-row:active { + cursor: url('icons/drag.svg') 12 12, grabbing; + } + + swp-service-row.dragging { + opacity: 0.5; + transform: scale(0.98); + } + + swp-service-row.multi-selected { + background: color-mix(in srgb, var(--color-teal) 12%, white); + border-color: var(--color-teal); + box-shadow: 0 0 0 1px var(--color-teal); + } + + swp-drag-handle { + color: var(--color-text-muted); + font-size: 14px; + cursor: url('icons/drag.svg') 12 12, grab; + padding: 0 2px; + } + + swp-service-row:hover swp-drag-handle { + color: var(--color-text-secondary); + } + + + swp-service-color { + width: 4px; + height: 28px; + border-radius: 2px; + flex-shrink: 0; + } + + swp-service-info { + flex: 1; + min-width: 0; + } + + swp-service-name { + font-size: 12px; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + swp-service-meta { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: 2px; + } + + /* Price and duration columns */ + swp-service-price-col { + width: 75px; + text-align: right; + font-size: 13px; + font-family: var(--font-mono); + color: var(--color-text-secondary); + flex-shrink: 0; + } + + swp-service-duration-col { + width: 50px; + text-align: right; + font-size: 13px; + font-family: var(--font-mono); + color: var(--color-text-secondary); + flex-shrink: 0; + } + + /* Editable inputs in selected column */ + swp-services-selected swp-service-price-col, + swp-services-selected swp-service-duration-col { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + + swp-services-selected swp-service-price-col input, + swp-services-selected swp-service-duration-col input { + width: 55px; + padding: 4px 6px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 12px; + font-family: var(--font-mono); + text-align: right; + background: var(--color-surface); + } + + swp-services-selected swp-service-price-col input:focus, + swp-services-selected swp-service-duration-col input:focus { + border-color: var(--color-teal); + outline: none; + } + + swp-services-selected swp-service-price-col input::-webkit-inner-spin-button, + swp-services-selected swp-service-price-col input::-webkit-outer-spin-button { + opacity: 1; + } + + /* Override indicator */ + swp-service-row.has-override swp-service-price-col, + swp-service-row.has-override swp-service-duration-col { + color: var(--color-teal); + } + /* ========================================== LAYOUT GRID ========================================== */ @@ -1085,51 +1435,6 @@ font-weight: 500; } - /* ========================================== - SPECIALTY TAGS - ========================================== */ - swp-specialty-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - swp-specialty-tag { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - font-size: 13px; - border-radius: 20px; - border: 1px solid var(--color-border); - cursor: pointer; - transition: all 150ms ease; - } - - swp-specialty-tag:hover { - background: var(--color-background-hover); - } - - swp-specialty-tag.selected { - background: color-mix(in srgb, var(--color-teal) 12%, white); - border-color: var(--color-teal); - color: var(--color-teal); - } - - swp-specialty-tag::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 50%; - border: 1px solid var(--color-border); - transition: all 150ms ease; - } - - swp-specialty-tag.selected::before { - background: var(--color-teal); - border-color: var(--color-teal); - } - /* ========================================== CERTIFICATION LIST ========================================== */ @@ -1364,6 +1669,168 @@ color: var(--color-teal); background: color-mix(in srgb, var(--color-teal) 5%, white); } + + /* Duration slider (same style as time-range-slider) */ + swp-duration-slider { + position: relative; + width: 80px; + height: 20px; + display: flex; + align-items: center; + } + + swp-duration-slider swp-slider-track { + position: absolute; + width: 100%; + height: 4px; + background: var(--color-border); + border-radius: 2px; + } + + swp-duration-slider input[type="range"] { + position: absolute; + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: transparent; + margin: 0; + } + + swp-duration-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: var(--color-teal); + border: 2px solid white; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } + + swp-duration-slider input[type="range"]::-moz-range-thumb { + width: 14px; + height: 14px; + background: var(--color-teal); + border: 2px solid white; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + } + + /* Compact service list (main view) */ + swp-service-compact-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + swp-service-compact-category { + font-size: 11px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 12px; + padding-bottom: 4px; + } + + swp-service-compact-category:first-child { + margin-top: 0; + } + + swp-service-compact-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--color-background-alt); + border-radius: 6px; + } + + swp-service-compact-color { + width: 4px; + height: 24px; + border-radius: 2px; + flex-shrink: 0; + } + + swp-service-compact-name { + flex: 1; + font-size: 14px; + color: var(--color-text); + } + + swp-service-compact-override { + display: flex; + gap: 8px; + font-size: 12px; + font-family: var(--font-mono); + } + + swp-service-compact-override .duration-override { + color: var(--color-teal); + padding: 2px 8px; + background: color-mix(in srgb, var(--color-teal) 10%, white); + border-radius: 4px; + } + + swp-service-compact-override .price-override { + color: var(--color-teal); + padding: 2px 8px; + background: color-mix(in srgb, var(--color-teal) 10%, white); + border-radius: 4px; + } + + swp-service-compact-override .price-override s { + color: var(--color-text-muted); + margin-right: 4px; + } + + swp-service-compact-override.standard { + display: none; + } + + swp-service-compact-empty { + display: block; + padding: 24px 16px; + text-align: center; + color: var(--color-text-muted); + font-size: 14px; + } + + /* Edit link for services card */ + swp-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + swp-card-header swp-section-label { + margin-bottom: 0; + } + + swp-edit-link { + font-size: 13px; + color: var(--color-teal); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: color 150ms ease; + } + + swp-edit-link:hover { + color: #00695c; + } + + swp-edit-link svg { + width: 14px; + height: 14px; + fill: currentColor; + } @@ -1735,96 +2202,45 @@ SERVICES TAB ========================================== --> -
- - Services medarbejderen kan udføre - - - - - - - - Klip & Farve - 60-120 min · fra 795 kr - - Varighed: Standard - + + + Priserne vises for stillingen "Master Stylist". Tip: Hold Shift nede for at vælge en række, eller Ctrl for at vælge flere enkeltvis. + +
+ + - - - - - - - Balayage - 90-150 min · fra 1.295 kr - - Varighed: Standard - - - - - - - - - Dameklip - 45 min · fra 395 kr - - Varighed: -10 min - - - - - - - - - Herreklip - 30 min · fra 295 kr - - Varighed: Standard - - - - - - - - - Extensions - 120-180 min · fra 2.500 kr - - Varighed: - - - - - - - - - Olaplex Behandling - 45 min · 350 kr - - Varighed: Standard - - - - - - Specialer - - Farve - Balayage - Highlights - Extensions - Olaplex - Permanent - Brude-styling - Kort hår - + + + + Valgte services + + + + + + Træk services hertil + +
@@ -2045,20 +2461,434 @@ }); }); - // Service item selection - document.querySelectorAll('swp-service-item').forEach(item => { - item.addEventListener('click', () => { - item.classList.toggle('selected'); + // ========================================== + // Services Drag-Drop Functionality + // ========================================== + + // Category colors + const categoryColors = { + 'Klip dame, herre og børn': '#1e88e5', + 'Farvebehandlinger': '#8e24aa', + 'Striber/ Refleksbehandling': '#f4511e', + 'Hårvask med styling eller uden styling': '#00897b', + 'Henna naturlig hårfarver': '#6d4c41', + 'Kurbehandling': '#43a047', + 'Bryn og vipper': '#ec407a', + 'Balayage': '#ab47bc', + 'Skæg': '#5c6bc0', + 'Gloss': '#ffb300', + 'Håropsætning': '#e91e63', + 'Modeller': '#78909c', + 'Tristan farve modeller': '#26a69a', + 'Uden kategori': '#607d8b', + 'Tilvalg services': '#009688' + }; + + // Pre-selected services (by name) + const preSelectedServices = [ + // Klip + 'Dameklip', + 'Dameklip uden snak', + 'Herreklip', + 'Herreklip uden snak', + 'Skin fade', + 'Børneklip 0-4 år', + 'Børneklip 4-8 år', + // Farve + 'Bundfarve almindelig udgroning maks 3 cm', + 'Helfarve kort hår', + 'Helfarve mellemlangt hår', + // Striber + 'Striber kort hår', + 'Striber mellemlangt hår', + // Bryn og vipper + 'Farvning vipper & bryn', + 'Farvning vipper', + 'Farvning og retning af bryn', + // Balayage + 'Balayage maks til skulderen', + 'Balayage langt hår', + // Gloss + 'Glossing kort hår', + 'Glossing mellemlangt/ langt hår' + ]; + + // All services data - loaded from JSON + let allServices = []; + const serviceState = new Map(); + + const selectedContainer = document.getElementById('selectedServices'); + const availableContainer = document.getElementById('availableServices'); + const serviceSearch = document.getElementById('serviceSearch'); + + // Load services from JSON + async function loadServices() { + try { + const response = await fetch('data/kk-services.json'); + const data = await response.json(); + + let id = 1; + for (const [category, services] of Object.entries(data)) { + const color = categoryColors[category] || '#607d8b'; + services.forEach(service => { + const isPreSelected = preSelectedServices.includes(service.name); + allServices.push({ + id: id, + name: service.name, + category: category, + color: color, + duration: service.duration, + price: service.price, + selected: isPreSelected + }); + + serviceState.set(id, { + selected: isPreSelected, + priceOverride: null, + durationOverride: 0 + }); + + id++; + }); + } + + console.log(`Loaded ${allServices.length} services from JSON`); + renderServices(); + } catch (error) { + console.error('Fejl ved indlæsning af services:', error); + } + } + + // Format duration + function formatDuration(minutes) { + if (minutes >= 60) { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}t ${mins}m` : `${hours}t`; + } + return `${minutes}m`; + } + + // Create service row HTML + function createServiceRow(service, isSelected) { + const state = serviceState.get(service.id); + const effectivePrice = state.priceOverride !== null ? state.priceOverride : service.price; + const effectiveDuration = service.duration + state.durationOverride; + + const row = document.createElement('swp-service-row'); + row.dataset.id = service.id; + row.dataset.category = service.category; + row.draggable = true; + + if (state.priceOverride !== null || state.durationOverride !== 0) { + row.classList.add('has-override'); + } + + if (isSelected) { + // Selected: with editable inputs + row.innerHTML = ` + ⋮⋮ + + + ${service.name} + + + kr + + ${formatDuration(effectiveDuration)} + `; + } else { + // Available: name only (no price/duration) + row.innerHTML = ` + ⋮⋮ + + + ${service.name} + + `; + } + + return row; + } + + // Render all services + function renderServices(searchQuery = '') { + const lowerQuery = searchQuery.toLowerCase(); + + // Group services by category + const selectedByCategory = {}; + const availableByCategory = {}; + + allServices.forEach(service => { + const state = serviceState.get(service.id); + const matchesSearch = !searchQuery || service.name.toLowerCase().includes(lowerQuery); + + if (state.selected) { + if (!selectedByCategory[service.category]) { + selectedByCategory[service.category] = []; + } + selectedByCategory[service.category].push(service); + } else if (matchesSearch) { + if (!availableByCategory[service.category]) { + availableByCategory[service.category] = []; + } + availableByCategory[service.category].push(service); + } }); + + // Render selected services + selectedContainer.innerHTML = ''; + const selectedCategories = Object.entries(selectedByCategory); + + if (selectedCategories.length === 0) { + selectedContainer.innerHTML = ` + + + Træk services hertil + + `; + } else { + selectedCategories.forEach(([category, services]) => { + const group = document.createElement('swp-service-category-group'); + group.innerHTML = `${category}`; + services.forEach(service => { + group.appendChild(createServiceRow(service, true)); + }); + selectedContainer.appendChild(group); + }); + } + + // Render available services + availableContainer.innerHTML = ''; + const availableCategories = Object.entries(availableByCategory); + + availableCategories.forEach(([category, services]) => { + const group = document.createElement('swp-service-category-group'); + group.innerHTML = `${category}`; + services.forEach(service => { + group.appendChild(createServiceRow(service, false)); + }); + availableContainer.appendChild(group); + }); + } + + // Multi-selection state + let selectedServiceIds = new Set(); + let lastClickedId = null; + + // Get all service IDs in order (for shift-selection) + function getOrderedServiceIds(container) { + return Array.from(container.querySelectorAll('swp-service-row')) + .map(row => parseInt(row.dataset.id)); + } + + // Handle service row click for selection + function handleServiceClick(e, row) { + const serviceId = parseInt(row.dataset.id); + const container = row.closest('swp-services-selected, swp-services-available'); + + if (e.shiftKey && lastClickedId !== null) { + // Shift+click: range selection + const orderedIds = getOrderedServiceIds(container); + const startIndex = orderedIds.indexOf(lastClickedId); + const endIndex = orderedIds.indexOf(serviceId); + + if (startIndex !== -1 && endIndex !== -1) { + const minIndex = Math.min(startIndex, endIndex); + const maxIndex = Math.max(startIndex, endIndex); + + for (let i = minIndex; i <= maxIndex; i++) { + selectedServiceIds.add(orderedIds[i]); + } + } + } else if (e.ctrlKey || e.metaKey) { + // Ctrl/Cmd+click: toggle individual selection + if (selectedServiceIds.has(serviceId)) { + selectedServiceIds.delete(serviceId); + } else { + selectedServiceIds.add(serviceId); + } + } else { + // Simple click: single selection + selectedServiceIds.clear(); + selectedServiceIds.add(serviceId); + } + + lastClickedId = serviceId; + updateSelectionVisual(); + } + + // Update visual selection state + function updateSelectionVisual() { + document.querySelectorAll('swp-service-row').forEach(row => { + const serviceId = parseInt(row.dataset.id); + if (selectedServiceIds.has(serviceId)) { + row.classList.add('multi-selected'); + } else { + row.classList.remove('multi-selected'); + } + }); + } + + // Move service between columns + function moveService(serviceId, toSelected) { + const state = serviceState.get(parseInt(serviceId)); + if (state) { + state.selected = toSelected; + renderServices(serviceSearch.value); + } + } + + // Move multiple services + function moveMultipleServices(serviceIds, toSelected) { + serviceIds.forEach(id => { + const state = serviceState.get(id); + if (state) { + state.selected = toSelected; + } + }); + selectedServiceIds.clear(); + renderServices(serviceSearch.value); + } + + // Drag and drop handlers + let draggedId = null; + let draggedIds = []; + + // Click handler for row selection + document.addEventListener('click', (e) => { + const row = e.target.closest('swp-service-row'); + // Don't select on input focus + if (row && e.target.tagName !== 'INPUT') { + handleServiceClick(e, row); + } }); - // Specialty tag selection - document.querySelectorAll('swp-specialty-tag').forEach(tag => { - tag.addEventListener('click', () => { - tag.classList.toggle('selected'); - }); + document.addEventListener('dragstart', (e) => { + const row = e.target.closest('swp-service-row'); + if (row) { + const serviceId = parseInt(row.dataset.id); + + // If dragging a selected row, drag all selected + if (selectedServiceIds.has(serviceId) && selectedServiceIds.size > 1) { + draggedIds = Array.from(selectedServiceIds); + // Mark all selected as dragging + document.querySelectorAll('swp-service-row.multi-selected').forEach(r => { + r.classList.add('dragging'); + }); + } else { + // Otherwise, just drag this one + draggedIds = [serviceId]; + row.classList.add('dragging'); + } + + draggedId = row.dataset.id; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', draggedIds.join(',')); + } }); + document.addEventListener('dragend', (e) => { + document.querySelectorAll('swp-service-row.dragging').forEach(row => { + row.classList.remove('dragging'); + }); + draggedId = null; + draggedIds = []; + selectedContainer.classList.remove('drag-over'); + availableContainer.classList.remove('drag-over'); + }); + + selectedContainer.addEventListener('dragover', (e) => { + e.preventDefault(); + selectedContainer.classList.add('drag-over'); + }); + + selectedContainer.addEventListener('dragleave', (e) => { + if (!selectedContainer.contains(e.relatedTarget)) { + selectedContainer.classList.remove('drag-over'); + } + }); + + selectedContainer.addEventListener('drop', (e) => { + e.preventDefault(); + selectedContainer.classList.remove('drag-over'); + if (draggedIds.length > 0) { + moveMultipleServices(draggedIds, true); + } else if (draggedId) { + moveService(draggedId, true); + } + }); + + availableContainer.addEventListener('dragover', (e) => { + e.preventDefault(); + availableContainer.classList.add('drag-over'); + }); + + availableContainer.addEventListener('dragleave', (e) => { + if (!availableContainer.contains(e.relatedTarget)) { + availableContainer.classList.remove('drag-over'); + } + }); + + availableContainer.addEventListener('drop', (e) => { + e.preventDefault(); + availableContainer.classList.remove('drag-over'); + if (draggedIds.length > 0) { + moveMultipleServices(draggedIds, false); + } else if (draggedId) { + moveService(draggedId, false); + } + }); + + // Price input handler + selectedContainer.addEventListener('input', (e) => { + if (e.target.type === 'number') { + const row = e.target.closest('swp-service-row'); + const serviceId = parseInt(row.dataset.id); + const state = serviceState.get(serviceId); + const originalPrice = parseInt(e.target.dataset.original); + const newPrice = parseInt(e.target.value) || originalPrice; + + if (newPrice !== originalPrice) { + state.priceOverride = newPrice; + row.classList.add('has-override'); + } else { + state.priceOverride = null; + row.classList.remove('has-override'); + } + } + }); + + // Search handler + let searchTimeout; + serviceSearch.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + renderServices(e.target.value); + }, 150); + }); + + // Initial load from JSON + loadServices(); + + // Services panel toggle + const servicesPanel = document.getElementById('servicesPanel'); + const panelToggle = document.getElementById('panelToggle'); + const editServicesBtn = document.getElementById('editServicesBtn'); + + function toggleServicesPanel() { + const isCollapsed = servicesPanel.classList.contains('collapsed'); + servicesPanel.classList.toggle('collapsed', !isCollapsed); + servicesPanel.classList.toggle('expanded', isCollapsed); + editServicesBtn.classList.toggle('active', isCollapsed); + editServicesBtn.innerHTML = isCollapsed + ? ` Luk` + : ` Rediger`; + } + + panelToggle.addEventListener('click', toggleServicesPanel); + editServicesBtn.addEventListener('click', toggleServicesPanel); + // Checkbox rows document.querySelectorAll('swp-checkbox-row').forEach(row => { row.addEventListener('click', () => {