Enhances service management UI with dynamic pricing controls

Adds interactive price and duration adjustment for services
Introduces visual indicators for overridden service values
Improves editing experience with responsive and intuitive controls

Implements granular service modifications with strikethrough and highlight effects
This commit is contained in:
Janus C. H. Knudsen 2025-12-27 22:54:46 +01:00
parent 439903fda4
commit 85b006e0d6

View file

@ -961,6 +961,199 @@
color: var(--color-teal); color: var(--color-teal);
} }
/* Service values (read mode) */
swp-service-values {
display: flex;
align-items: center;
margin-left: auto;
}
swp-service-price,
swp-service-duration {
font-size: 13px;
font-family: var(--font-mono);
color: var(--color-text-secondary);
text-align: right;
}
swp-service-price {
width: 100px;
}
swp-service-duration {
width: 80px;
}
swp-service-price.modified,
swp-service-duration.modified {
color: var(--color-teal);
font-weight: 500;
}
/* Hide values in edit mode */
.edit-mode swp-service-values {
display: none;
}
/* Service controls container - hidden by default */
swp-service-controls {
display: none;
align-items: center;
margin-left: auto;
}
/* Show controls in edit mode */
.edit-mode swp-service-controls {
display: flex;
}
/* Final values with adjustable controls */
swp-service-final-values {
display: flex;
align-items: center;
gap: 12px;
}
swp-adjustable-value {
display: flex;
align-items: center;
gap: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 2px;
}
swp-adjustable-value.modified {
border-color: var(--color-teal);
background: color-mix(in srgb, var(--color-teal) 5%, white);
}
swp-adjustable-value .adjust-down,
swp-adjustable-value .adjust-up {
width: 24px;
height: 24px;
border: none;
background: var(--color-background);
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 100ms ease;
}
swp-adjustable-value .adjust-down:hover,
swp-adjustable-value .adjust-up:hover {
background: var(--color-teal);
color: white;
}
swp-adjustable-value .adjust-down:active,
swp-adjustable-value .adjust-up:active {
transform: scale(0.95);
}
swp-adjustable-value .adjust-display {
min-width: 55px;
text-align: center;
font-size: 13px;
font-family: var(--font-mono);
color: var(--color-text);
padding: 0 4px;
}
swp-adjustable-value.modified .adjust-display {
color: var(--color-teal);
font-weight: 500;
}
/* Duration display */
.duration-display {
min-width: 50px;
text-align: right;
font-size: 13px;
font-family: var(--font-mono);
color: var(--color-text-secondary);
}
.duration-display.modified {
color: var(--color-teal);
font-weight: 500;
}
/* Original values (read mode) */
swp-service-originals {
display: flex;
align-items: center;
gap: 16px;
margin-right: 16px;
}
swp-service-originals .original-price-val,
swp-service-originals .original-duration-val {
font-family: var(--font-mono);
font-size: 15px;
color: var(--color-text-secondary);
}
/* Strikethrough only when value is changed */
swp-service-originals .original-price-val.struck,
swp-service-originals .original-duration-val.struck {
text-decoration: line-through;
text-decoration-color: var(--color-teal);
text-decoration-thickness: 1px;
color: var(--color-text-muted);
}
/* Original values (edit mode) - hidden by default, shown when has-override */
swp-service-originals-edit {
display: none;
align-items: center;
gap: 16px;
margin-right: 20px;
}
swp-service-row.has-override swp-service-originals-edit {
display: flex;
}
swp-service-originals-edit .original-price-val,
swp-service-originals-edit .original-duration-val {
font-family: var(--font-mono);
font-size: 15px;
color: var(--color-text-secondary);
}
swp-service-originals-edit .original-price-val.struck,
swp-service-originals-edit .original-duration-val.struck {
text-decoration: line-through;
text-decoration-color: var(--color-teal);
text-decoration-thickness: 1px;
color: var(--color-text-muted);
}
/* Highlight originals when value changes */
swp-service-originals.highlight .original-price-val,
swp-service-originals.highlight .original-duration-val,
swp-service-originals-edit.highlight .original-price-val,
swp-service-originals-edit.highlight .original-duration-val {
animation: flash-text 300ms ease;
}
@keyframes pulse-border {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-teal) 40%, transparent); }
100% { box-shadow: 0 0 0 4px transparent; }
}
@keyframes flash-text {
0% { opacity: 0.5; }
100% { opacity: 1; }
}
/* ========================================== /* ==========================================
LAYOUT GRID LAYOUT GRID
========================================== */ ========================================== */
@ -1033,6 +1226,13 @@
margin-top: 4px; margin-top: 4px;
} }
swp-stat-subtitle {
display: block;
font-size: 11px;
color: var(--color-text-muted);
margin-top: 2px;
}
swp-stat-card.highlight swp-stat-value { swp-stat-card.highlight swp-stat-value {
color: var(--color-teal); color: var(--color-teal);
} }
@ -1589,6 +1789,90 @@
font-weight: 500; font-weight: 500;
} }
/* ==========================================
INVOICE TABLE (afsluttede bookinger)
========================================== */
swp-invoice-table {
display: block;
margin-top: 16px;
}
swp-invoice-table table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
swp-invoice-table th,
swp-invoice-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
swp-invoice-table th {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
background: var(--color-background-alt);
}
swp-invoice-table td {
color: var(--color-text);
}
swp-invoice-table .date,
swp-invoice-table .time {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-secondary);
}
swp-invoice-table .customer {
font-weight: 500;
}
swp-invoice-table .services {
max-width: 250px;
}
swp-invoice-table .duration {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-secondary);
}
swp-invoice-table .amount {
font-family: var(--font-mono);
font-weight: 600;
text-align: right;
}
swp-invoice-table .amount-col {
text-align: right;
}
/* Status badges */
swp-status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
swp-status-badge.paid {
background: color-mix(in srgb, var(--color-teal) 15%, white);
color: var(--color-teal);
}
swp-status-badge.pending {
background: color-mix(in srgb, #f59e0b 15%, white);
color: #d97706;
}
/* ========================================== /* ==========================================
CHART SECTION (matches poc-detail-drawer.html) CHART SECTION (matches poc-detail-drawer.html)
========================================== */ ========================================== */
@ -2360,12 +2644,13 @@
<swp-stat-label>Bookinger denne måned</swp-stat-label> <swp-stat-label>Bookinger denne måned</swp-stat-label>
</swp-stat-card> </swp-stat-card>
<swp-stat-card> <swp-stat-card>
<swp-stat-value>28.450 kr</swp-stat-value> <swp-stat-value>30.825 kr</swp-stat-value>
<swp-stat-label>Omsætning denne måned</swp-stat-label> <swp-stat-label>Værdi af bookede services</swp-stat-label>
<swp-stat-subtitle>Baseret på 49 bookinger</swp-stat-subtitle>
</swp-stat-card> </swp-stat-card>
<swp-stat-card> <swp-stat-card>
<swp-stat-value>4.9</swp-stat-value> <swp-stat-value>28.450 kr</swp-stat-value>
<swp-stat-label>Gns. kundetilfredshed</swp-stat-label> <swp-stat-label>Omsætning denne måned</swp-stat-label>
</swp-stat-card> </swp-stat-card>
<swp-stat-card> <swp-stat-card>
<swp-stat-value>68%</swp-stat-value> <swp-stat-value>68%</swp-stat-value>
@ -2441,6 +2726,100 @@
</swp-booking-table> </swp-booking-table>
</swp-card> </swp-card>
</div> </div>
<!-- Afsluttede bookinger (fakturaliste) -->
<swp-card style="margin-top: 24px;">
<swp-section-label>Afsluttede bookinger</swp-section-label>
<swp-invoice-table>
<table>
<thead>
<tr>
<th>Dato</th>
<th>Tid</th>
<th>Kunde</th>
<th>Services</th>
<th>Varighed</th>
<th class="amount-col">Beløb</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td class="date">23. dec 2024</td>
<td class="time">10:00</td>
<td class="customer">Maria Hansen</td>
<td class="services">Dameklip, Bundfarve</td>
<td class="duration">2t 30m</td>
<td class="amount">1.510 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">23. dec 2024</td>
<td class="time">13:30</td>
<td class="customer">Louise Nielsen</td>
<td class="services">Balayage langt hår, Olaplex</td>
<td class="duration">3t</td>
<td class="amount">2.700 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">22. dec 2024</td>
<td class="time">09:00</td>
<td class="customer">Sofie Andersen</td>
<td class="services">Dameklip</td>
<td class="duration">1t</td>
<td class="amount">725 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">22. dec 2024</td>
<td class="time">11:00</td>
<td class="customer">Karen Pedersen</td>
<td class="services">Striber mellemlangt hår, Klip</td>
<td class="duration">2t 30m</td>
<td class="amount">2.390 kr</td>
<td><swp-status-badge class="pending">Afventer</swp-status-badge></td>
</tr>
<tr>
<td class="date">21. dec 2024</td>
<td class="time">14:00</td>
<td class="customer">Emma Larsen</td>
<td class="services">Olaplex Stand alone</td>
<td class="duration">1t</td>
<td class="amount">550 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">21. dec 2024</td>
<td class="time">10:00</td>
<td class="customer">Mette Kristensen</td>
<td class="services">Herreklip</td>
<td class="duration">1t</td>
<td class="amount">645 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">20. dec 2024</td>
<td class="time">09:30</td>
<td class="customer">Anne Thomsen</td>
<td class="services">Glossing mellemlangt hår</td>
<td class="duration">1t</td>
<td class="amount">745 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
<tr>
<td class="date">20. dec 2024</td>
<td class="time">12:00</td>
<td class="customer">Lise Mortensen</td>
<td class="services">Dameklip, Farvning vipper & bryn</td>
<td class="duration">1t 30m</td>
<td class="amount">1.070 kr</td>
<td><swp-status-badge class="paid">Betalt</swp-status-badge></td>
</tr>
</tbody>
</table>
</swp-invoice-table>
</swp-card>
</swp-tab-content> </swp-tab-content>
<script type="module"> <script type="module">
@ -2569,33 +2948,72 @@
return `${minutes}m`; return `${minutes}m`;
} }
// Format duration with offset (e.g., "1t -5m" or "30m +10m")
function formatDurationWithOffset(baseMinutes, offset) {
const baseStr = formatDuration(baseMinutes);
if (offset === 0) return baseStr;
const sign = offset > 0 ? '+' : '';
return `${baseStr} <span class="duration-offset">${sign}${offset}m</span>`;
}
// Create service row HTML // Create service row HTML
function createServiceRow(service, isSelected) { function createServiceRow(service, isSelected) {
const state = serviceState.get(service.id); const state = serviceState.get(service.id);
const effectivePrice = state.priceOverride !== null ? state.priceOverride : service.price; const effectivePrice = state.priceOverride !== null ? state.priceOverride : service.price;
const effectiveDuration = service.duration + state.durationOverride; const effectiveDuration = service.duration + state.durationOverride;
const hasPriceOverride = state.priceOverride !== null && state.priceOverride !== service.price;
const hasDurationOverride = state.durationOverride !== 0;
const row = document.createElement('swp-service-row'); const row = document.createElement('swp-service-row');
row.dataset.id = service.id; row.dataset.id = service.id;
row.dataset.category = service.category; row.dataset.category = service.category;
row.draggable = true; row.draggable = true;
if (state.priceOverride !== null || state.durationOverride !== 0) { if (hasPriceOverride || hasDurationOverride) {
row.classList.add('has-override'); row.classList.add('has-override');
} }
if (isSelected) { if (isSelected) {
// Selected: with editable inputs // Selected: show adjusted values + original values in bordered box when modified
const hasAnyOverride = hasPriceOverride || hasDurationOverride;
// Original values box (shown when any override exists)
const originalsHtml = hasAnyOverride
? `<swp-service-originals>
<span class="original-price-val ${hasPriceOverride ? 'struck' : ''}" data-original-price="${service.price}">${service.price} kr</span>
<span class="original-duration-val ${hasDurationOverride ? 'struck' : ''}" data-original-duration="${service.duration}">${formatDuration(service.duration)}</span>
</swp-service-originals>`
: '';
row.innerHTML = ` row.innerHTML = `
<swp-drag-handle>⋮⋮</swp-drag-handle> <swp-drag-handle>⋮⋮</swp-drag-handle>
<swp-service-color style="background: ${service.color};"></swp-service-color> <swp-service-color style="background: ${service.color};"></swp-service-color>
<swp-service-info> <swp-service-info>
<swp-service-name>${service.name}</swp-service-name> <swp-service-name>${service.name}</swp-service-name>
</swp-service-info> </swp-service-info>
<swp-service-price-col> <swp-service-values>
<input type="number" value="${effectivePrice}" data-original="${service.price}"> kr ${originalsHtml}
</swp-service-price-col> <swp-service-price class="${hasPriceOverride ? 'modified' : ''}">${effectivePrice} kr</swp-service-price>
<swp-service-duration-col>${formatDuration(effectiveDuration)}</swp-service-duration-col> <swp-service-duration class="${hasDurationOverride ? 'modified' : ''}">${formatDuration(effectiveDuration)}</swp-service-duration>
</swp-service-values>
<swp-service-controls>
<swp-service-originals-edit>
<span class="original-price-val ${hasPriceOverride ? 'struck' : ''}">${service.price} kr</span>
<span class="original-duration-val ${hasDurationOverride ? 'struck' : ''}">${formatDuration(service.duration)}</span>
</swp-service-originals-edit>
<swp-service-final-values>
<swp-adjustable-value class="price-adjust ${hasPriceOverride ? 'modified' : ''}" data-type="price" data-original="${service.price}" data-value="${effectivePrice}" data-step="5">
<button class="adjust-down" type="button"></button>
<span class="adjust-display">${effectivePrice} kr</span>
<button class="adjust-up" type="button">+</button>
</swp-adjustable-value>
<swp-adjustable-value class="duration-adjust ${hasDurationOverride ? 'modified' : ''}" data-type="duration" data-base="${service.duration}" data-offset="${state.durationOverride}" data-step="5">
<button class="adjust-down" type="button"></button>
<span class="adjust-display">${formatDuration(effectiveDuration)}</span>
<button class="adjust-up" type="button">+</button>
</swp-adjustable-value>
</swp-service-final-values>
</swp-service-controls>
`; `;
} else { } else {
// Available: name only (no price/duration) // Available: name only (no price/duration)
@ -2840,22 +3258,110 @@
} }
}); });
// Price input handler // Adjustable value click handler (up/down buttons)
selectedContainer.addEventListener('input', (e) => { selectedContainer.addEventListener('click', (e) => {
if (e.target.type === 'number') { const btn = e.target.closest('.adjust-up, .adjust-down');
const row = e.target.closest('swp-service-row'); if (!btn) return;
const adjustable = btn.closest('swp-adjustable-value');
const row = btn.closest('swp-service-row');
if (!adjustable || !row) return;
const serviceId = parseInt(row.dataset.id); const serviceId = parseInt(row.dataset.id);
const state = serviceState.get(serviceId); const state = serviceState.get(serviceId);
const originalPrice = parseInt(e.target.dataset.original); const service = allServices.find(s => s.id === serviceId);
const newPrice = parseInt(e.target.value) || originalPrice; const step = parseInt(adjustable.dataset.step);
const isUp = btn.classList.contains('adjust-up');
const delta = isUp ? step : -step;
if (newPrice !== originalPrice) { if (adjustable.dataset.type === 'price') {
state.priceOverride = newPrice; // Adjust price
row.classList.add('has-override'); const original = parseInt(adjustable.dataset.original);
} else { let current = parseInt(adjustable.dataset.value);
state.priceOverride = null; current = Math.max(0, current + delta);
row.classList.remove('has-override'); adjustable.dataset.value = current;
const isModified = current !== original;
state.priceOverride = isModified ? current : null;
// Update displays
adjustable.querySelector('.adjust-display').textContent = `${current} kr`;
adjustable.classList.toggle('modified', isModified);
const priceDisplay = row.querySelector('swp-service-price');
if (priceDisplay) {
priceDisplay.textContent = `${current} kr`;
priceDisplay.classList.toggle('modified', isModified);
} }
// Toggle strikethrough on all original price elements (read + edit mode)
row.querySelectorAll('.original-price-val').forEach(el => {
el.classList.toggle('struck', isModified);
});
} else if (adjustable.dataset.type === 'duration') {
// Adjust duration offset
const base = parseInt(adjustable.dataset.base);
let offset = parseInt(adjustable.dataset.offset);
offset = Math.max(-base, offset + delta); // Don't go below 0 total
adjustable.dataset.offset = offset;
const effectiveDuration = base + offset;
const isModified = offset !== 0;
state.durationOverride = offset;
// Update displays
adjustable.querySelector('.adjust-display').textContent = formatDuration(effectiveDuration);
adjustable.classList.toggle('modified', isModified);
const durationDisplay = row.querySelector('swp-service-duration');
if (durationDisplay) {
durationDisplay.textContent = formatDuration(effectiveDuration);
durationDisplay.classList.toggle('modified', isModified);
}
// Toggle strikethrough on all original duration elements (read + edit mode)
row.querySelectorAll('.original-duration-val').forEach(el => {
el.classList.toggle('struck', isModified);
});
}
// Check if any override exists
const hasOverride = state.priceOverride !== null || state.durationOverride !== 0;
row.classList.toggle('has-override', hasOverride);
// Handle originals box visibility and highlight
const valuesContainer = row.querySelector('swp-service-values');
let originalsBox = valuesContainer.querySelector('swp-service-originals');
const originalsEditBox = row.querySelector('swp-service-originals-edit');
if (hasOverride) {
// Show originals box if not present (insert at beginning)
const hasPriceOverride = state.priceOverride !== null;
const hasDurationOverride = state.durationOverride !== 0;
if (!originalsBox) {
originalsBox = document.createElement('swp-service-originals');
originalsBox.innerHTML = `
<span class="original-price-val ${hasPriceOverride ? 'struck' : ''}">${service.price} kr</span>
<span class="original-duration-val ${hasDurationOverride ? 'struck' : ''}">${formatDuration(service.duration)}</span>
`;
valuesContainer.insertBefore(originalsBox, valuesContainer.firstChild);
} else {
// Update struck classes on existing originals box
const priceVal = originalsBox.querySelector('.original-price-val');
const durationVal = originalsBox.querySelector('.original-duration-val');
if (priceVal) priceVal.classList.toggle('struck', hasPriceOverride);
if (durationVal) durationVal.classList.toggle('struck', hasDurationOverride);
}
// Highlight animation
originalsBox.classList.remove('highlight');
originalsEditBox?.classList.remove('highlight');
void originalsBox.offsetWidth; // Trigger reflow
originalsBox.classList.add('highlight');
originalsEditBox?.classList.add('highlight');
} else {
// Remove originals box if no overrides
if (originalsBox) originalsBox.remove();
} }
}); });
@ -2875,11 +3381,13 @@
const servicesPanel = document.getElementById('servicesPanel'); const servicesPanel = document.getElementById('servicesPanel');
const panelToggle = document.getElementById('panelToggle'); const panelToggle = document.getElementById('panelToggle');
const editServicesBtn = document.getElementById('editServicesBtn'); const editServicesBtn = document.getElementById('editServicesBtn');
const selectedServicesCard = document.querySelector('.selected-services-card');
function toggleServicesPanel() { function toggleServicesPanel() {
const isCollapsed = servicesPanel.classList.contains('collapsed'); const isCollapsed = servicesPanel.classList.contains('collapsed');
servicesPanel.classList.toggle('collapsed', !isCollapsed); servicesPanel.classList.toggle('collapsed', !isCollapsed);
servicesPanel.classList.toggle('expanded', isCollapsed); servicesPanel.classList.toggle('expanded', isCollapsed);
selectedServicesCard.classList.toggle('edit-mode', isCollapsed);
editServicesBtn.classList.toggle('active', isCollapsed); editServicesBtn.classList.toggle('active', isCollapsed);
editServicesBtn.innerHTML = isCollapsed editServicesBtn.innerHTML = isCollapsed
? `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> Luk` ? `<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg> Luk`