Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
Showing only changes of commit 85b006e0d6 - Show all commits

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`