Enhances AI booking optimization with smart slot recommendations
Implements AI-driven time slot selection algorithm for booking system Adds intelligent slot scoring mechanism that considers: - Minimizing calendar gaps - Optimizing employee time utilization - Providing recommended time slots for customers Introduces new AI features across booking interfaces to improve scheduling efficiency
This commit is contained in:
parent
3b86a6c8b3
commit
2a066c6d14
18 changed files with 4496 additions and 25 deletions
|
|
@ -568,6 +568,59 @@
|
|||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
swp-time-slot.recommended {
|
||||
position: relative;
|
||||
border: 2px solid var(--color-green);
|
||||
background: color-mix(in srgb, var(--color-green) 10%, white);
|
||||
}
|
||||
|
||||
swp-time-slot.recommended:hover {
|
||||
background: color-mix(in srgb, var(--color-green) 18%, white);
|
||||
}
|
||||
|
||||
swp-time-slot.recommended.selected {
|
||||
background: var(--color-teal);
|
||||
border-color: var(--color-teal);
|
||||
}
|
||||
|
||||
swp-ai-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: var(--color-green);
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
swp-ai-badge i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
swp-ai-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--color-green) 10%, white);
|
||||
border: 1px solid color-mix(in srgb, var(--color-green) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
swp-ai-info i {
|
||||
font-size: 18px;
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
CONTACT FORM
|
||||
========================================== */
|
||||
|
|
@ -1065,6 +1118,180 @@
|
|||
{ id: "EMP004", name: "Viktor", role: "Frisør", color: "#009688" }
|
||||
];
|
||||
|
||||
// ==========================================
|
||||
// EKSISTERENDE BOOKINGER (Mock data til AI-optimering)
|
||||
// ==========================================
|
||||
const existingBookings = {
|
||||
// Bookinger pr. medarbejder pr. dato
|
||||
'EMP001': {
|
||||
'2026-01-06': [
|
||||
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||
{ start: '13:30', end: '14:30', service: 'Herreklip' }
|
||||
],
|
||||
'2026-01-07': [
|
||||
{ start: '09:00', end: '10:30', service: 'Bundfarve' },
|
||||
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||
{ start: '14:00', end: '15:00', service: 'Dameklip' }
|
||||
]
|
||||
},
|
||||
'EMP002': {
|
||||
'2026-01-06': [
|
||||
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||
{ start: '15:00', end: '16:30', service: 'Striber' }
|
||||
],
|
||||
'2026-01-07': [
|
||||
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||
{ start: '13:00', end: '14:00', service: 'Herreklip' }
|
||||
]
|
||||
},
|
||||
'EMP003': {
|
||||
'2026-01-06': [
|
||||
{ start: '08:30', end: '09:30', service: 'Herreklip' },
|
||||
{ start: '12:00', end: '13:00', service: 'Dameklip' }
|
||||
]
|
||||
},
|
||||
'EMP004': {
|
||||
'2026-01-06': [
|
||||
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||
{ start: '14:00', end: '15:30', service: 'Bundfarve' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const SALON_OPEN = '08:00';
|
||||
const SALON_CLOSE = '17:00';
|
||||
|
||||
// ==========================================
|
||||
// AI SLOT OPTIMERING
|
||||
// ==========================================
|
||||
function timeToMinutes(time) {
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function minutesToTime(mins) {
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getBookingsForSlot(date, employeeId) {
|
||||
// Hvis ingen præference, kombiner alle medarbejderes bookinger
|
||||
if (!employeeId) {
|
||||
// Find medarbejder med færrest bookinger (simulerer "første ledige")
|
||||
let bestEmployee = 'EMP001';
|
||||
let minBookings = Infinity;
|
||||
for (const empId of Object.keys(existingBookings)) {
|
||||
const bookings = existingBookings[empId]?.[date] || [];
|
||||
if (bookings.length < minBookings) {
|
||||
minBookings = bookings.length;
|
||||
bestEmployee = empId;
|
||||
}
|
||||
}
|
||||
return existingBookings[bestEmployee]?.[date] || [];
|
||||
}
|
||||
return existingBookings[employeeId]?.[date] || [];
|
||||
}
|
||||
|
||||
function calculateOptimalSlots(serviceDuration, date, employeeId) {
|
||||
const bookings = getBookingsForSlot(date, employeeId);
|
||||
const openMins = timeToMinutes(SALON_OPEN);
|
||||
const closeMins = timeToMinutes(SALON_CLOSE);
|
||||
|
||||
// Generer alle mulige slots (30 min intervaller)
|
||||
const slots = [];
|
||||
for (let mins = openMins; mins <= closeMins - serviceDuration; mins += 30) {
|
||||
const slotStart = mins;
|
||||
const slotEnd = mins + serviceDuration;
|
||||
const time = minutesToTime(mins);
|
||||
|
||||
// Tjek om slot overlapper med eksisterende booking
|
||||
let isTaken = false;
|
||||
for (const booking of bookings) {
|
||||
const bookStart = timeToMinutes(booking.start);
|
||||
const bookEnd = timeToMinutes(booking.end);
|
||||
if (slotStart < bookEnd && slotEnd > bookStart) {
|
||||
isTaken = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Beregn score for dette slot
|
||||
let score = 0;
|
||||
|
||||
if (!isTaken) {
|
||||
// +3: Starter ved åbningstid
|
||||
if (slotStart === openMins) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
// +3: Slutter præcis på næste booking
|
||||
for (const booking of bookings) {
|
||||
const bookStart = timeToMinutes(booking.start);
|
||||
if (slotEnd === bookStart) {
|
||||
score += 3;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// +2: Starter lige efter en booking
|
||||
for (const booking of bookings) {
|
||||
const bookEnd = timeToMinutes(booking.end);
|
||||
if (slotStart === bookEnd) {
|
||||
score += 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -2: Skaber lille hul (< 30 min) til næste booking
|
||||
for (const booking of bookings) {
|
||||
const bookStart = timeToMinutes(booking.start);
|
||||
const gap = bookStart - slotEnd;
|
||||
if (gap > 0 && gap < 30) {
|
||||
score -= 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// -2: Skaber lille hul fra forrige booking
|
||||
for (const booking of bookings) {
|
||||
const bookEnd = timeToMinutes(booking.end);
|
||||
const gap = slotStart - bookEnd;
|
||||
if (gap > 0 && gap < 30) {
|
||||
score -= 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// +1: Sidst på dagen (fylder op bagfra)
|
||||
if (slotEnd >= closeMins - 60) {
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
slots.push({
|
||||
time,
|
||||
taken: isTaken,
|
||||
score,
|
||||
recommended: false
|
||||
});
|
||||
}
|
||||
|
||||
// Marker top 3 ledige slots som recommended
|
||||
const availableSlots = slots.filter(s => !s.taken);
|
||||
availableSlots.sort((a, b) => b.score - a.score);
|
||||
|
||||
const topSlots = availableSlots.slice(0, 3);
|
||||
for (const slot of topSlots) {
|
||||
if (slot.score > 0) {
|
||||
slot.recommended = true;
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INIT
|
||||
// ==========================================
|
||||
|
|
@ -1294,6 +1521,10 @@
|
|||
</swp-calendar>
|
||||
<swp-time-section>
|
||||
<swp-section-title>Ledige tider</swp-section-title>
|
||||
<swp-ai-info>
|
||||
<i class="ph ph-sparkle"></i>
|
||||
<span>Anbefalede tider passer bedst i vores kalender og minimerer ventetid</span>
|
||||
</swp-ai-info>
|
||||
<swp-time-grid id="timeGrid"></swp-time-grid>
|
||||
</swp-time-section>
|
||||
</swp-datetime-layout>
|
||||
|
|
@ -1356,11 +1587,22 @@
|
|||
const grid = document.getElementById('timeGrid');
|
||||
if (!grid) return;
|
||||
|
||||
const times = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'];
|
||||
const taken = ['10:30', '14:00'];
|
||||
// Beregn samlet varighed af valgte services (eller default 60 min)
|
||||
const serviceDuration = state.services.reduce((sum, s) => sum + s.duration, 0) || 60;
|
||||
|
||||
grid.innerHTML = times.map(t => `
|
||||
<swp-time-slot data-time="${t}" class="${taken.includes(t) ? 'disabled' : ''} ${state.time === t ? 'selected' : ''}">${t}</swp-time-slot>
|
||||
// Brug AI-algoritmen til at beregne optimale slots
|
||||
const selectedDate = state.date || new Date().toISOString().split('T')[0];
|
||||
const slots = calculateOptimalSlots(serviceDuration, selectedDate, state.employee);
|
||||
|
||||
// Render slots med AI-anbefalinger
|
||||
grid.innerHTML = slots.map(slot => `
|
||||
<swp-time-slot
|
||||
data-time="${slot.time}"
|
||||
class="${slot.taken ? 'disabled' : ''} ${slot.recommended ? 'recommended' : ''} ${state.time === slot.time ? 'selected' : ''}"
|
||||
>
|
||||
${slot.time}
|
||||
${slot.recommended ? '<swp-ai-badge><i class="ph ph-sparkle"></i>Anbefalet</swp-ai-badge>' : ''}
|
||||
</swp-time-slot>
|
||||
`).join('');
|
||||
|
||||
grid.querySelectorAll('swp-time-slot:not(.disabled)').forEach(slot => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue