Calendar/wwwroot/poc-loen-provision.html
Janus C. H. Knudsen 2a066c6d14 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
2026-01-02 21:19:10 +01:00

1525 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Løn & Provision</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/regular/style.css">
<script src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<style>
/* ==========================================
FONT FACE (Poppins)
========================================== */
@font-face {
font-family: 'Poppins';
src: url('fonts/Poppins-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('fonts/Poppins-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('fonts/Poppins-SemiBold.woff') format('woff');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins';
src: url('fonts/Poppins-Bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ==========================================
CSS VARIABLES
========================================== */
:root {
--color-surface: #fff;
--color-background: #f5f5f5;
--color-background-hover: #f0f0f0;
--color-background-alt: #fafafa;
--color-border: #e0e0e0;
--color-text: #333;
--color-text-secondary: #666;
--color-teal: #00897b;
--color-blue: #1976d2;
--color-red: #e53935;
--color-amber: #f59e0b;
--color-purple: #8b5cf6;
--color-green: #43a047;
--font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* ==========================================
RESET & BASE
========================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
font-size: 14px;
color: var(--color-text);
background: var(--color-background);
line-height: 1.5;
}
/* ==========================================
TOPBAR
========================================== */
swp-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
swp-topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
swp-back-link {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: color 150ms ease;
}
swp-back-link:hover {
color: var(--color-teal);
}
swp-back-link svg {
width: 16px;
height: 16px;
fill: currentColor;
}
swp-page-title {
font-size: 16px;
font-weight: 600;
}
swp-topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
/* ==========================================
LAYOUT
========================================== */
swp-page-container {
display: block;
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
/* ==========================================
BUTTONS
========================================== */
swp-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-family);
border-radius: 6px;
cursor: pointer;
transition: all 150ms ease;
border: none;
}
swp-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
swp-btn.primary {
background: var(--color-teal);
color: white;
}
swp-btn.primary:hover {
background: #00796b;
}
swp-btn.secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
swp-btn.secondary:hover {
background: var(--color-background-hover);
}
swp-btn.small {
padding: 6px 12px;
font-size: 12px;
}
/* ==========================================
CARDS
========================================== */
swp-card {
display: block;
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
margin-bottom: 20px;
padding: 20px;
}
swp-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: -20px -20px 20px -20px;
padding: 14px 20px;
border-bottom: 1px solid var(--color-border);
}
swp-card-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
/* ==========================================
PERIODE SELECTOR
========================================== */
swp-period-selector {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
swp-period-field {
display: flex;
align-items: center;
gap: 8px;
}
swp-period-label {
font-size: 13px;
color: var(--color-text-secondary);
}
swp-period-field select {
padding: 8px 12px;
font-size: 14px;
font-family: var(--font-family);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
cursor: pointer;
}
swp-period-field select:focus {
outline: none;
border-color: var(--color-teal);
}
swp-period-separator {
font-size: 13px;
color: var(--color-text-secondary);
}
/* ==========================================
EMPLOYEE ACCORDION
========================================== */
swp-employee-list {
display: flex;
flex-direction: column;
gap: 16px;
}
swp-employee-card {
display: block;
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
overflow: hidden;
}
swp-employee-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
cursor: pointer;
transition: background 150ms ease;
}
swp-employee-header:hover {
background: var(--color-background-alt);
}
swp-employee-info {
display: flex;
align-items: center;
gap: 14px;
}
swp-employee-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-teal);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
}
swp-employee-avatar.purple { background: var(--color-purple); }
swp-employee-avatar.blue { background: var(--color-blue); }
swp-employee-avatar.amber { background: var(--color-amber); }
swp-employee-details {
display: flex;
flex-direction: column;
gap: 2px;
}
swp-employee-name {
font-size: 15px;
font-weight: 600;
color: var(--color-text);
}
swp-employee-meta {
font-size: 12px;
color: var(--color-text-secondary);
}
swp-employee-summary {
display: flex;
align-items: center;
gap: 20px;
}
swp-summary-item {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
swp-summary-value {
font-size: 16px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text);
}
swp-summary-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
}
swp-expand-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
transition: transform 200ms ease;
}
swp-expand-icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
swp-employee-card.expanded swp-expand-icon {
transform: rotate(180deg);
}
swp-employee-content {
display: none;
border-top: 1px solid var(--color-border);
}
swp-employee-card.expanded swp-employee-content {
display: block;
}
/* ==========================================
EMPLOYEE CONFIG
========================================== */
swp-employee-config {
display: flex;
gap: 24px;
padding: 16px 20px;
background: var(--color-background-alt);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
swp-config-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
swp-config-label {
color: var(--color-text-secondary);
}
swp-config-value {
font-weight: 500;
color: var(--color-text);
}
swp-config-value.mono {
font-family: var(--font-mono);
}
/* ==========================================
WEEKS TABLE
========================================== */
swp-weeks-table {
display: block;
padding: 20px;
}
swp-table {
display: table;
width: 100%;
border-collapse: collapse;
}
swp-table-header {
display: table-header-group;
}
swp-table-body {
display: table-row-group;
}
swp-table-footer {
display: table-footer-group;
}
swp-table-row {
display: table-row;
}
swp-table-cell {
display: table-cell;
padding: 12px 10px;
font-size: 13px;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
swp-table-header swp-table-cell {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
background: var(--color-background-alt);
}
swp-table-footer swp-table-cell {
font-weight: 600;
background: var(--color-background-alt);
border-bottom: none;
}
swp-table-cell.right {
text-align: right;
}
swp-table-cell.mono {
font-family: var(--font-mono);
}
swp-table-cell.highlight {
color: var(--color-teal);
}
swp-table-cell.warning {
color: var(--color-amber);
}
swp-table-body swp-table-row:hover {
background: var(--color-background-hover);
}
/* Editable cells */
swp-table-cell input {
width: 80px;
padding: 6px 8px;
font-size: 13px;
font-family: var(--font-mono);
border: 1px solid var(--color-border);
border-radius: 4px;
text-align: right;
background: var(--color-surface);
}
swp-table-cell input:focus {
outline: none;
border-color: var(--color-teal);
}
/* ==========================================
INTECT BOX
========================================== */
swp-intect-box {
display: block;
margin: 0 20px 20px;
padding: 20px;
background: linear-gradient(135deg, color-mix(in srgb, var(--color-teal) 8%, white) 0%, color-mix(in srgb, var(--color-blue) 8%, white) 100%);
border: 2px solid var(--color-teal);
border-radius: 8px;
}
swp-intect-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
swp-intect-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-teal);
text-transform: uppercase;
letter-spacing: 0.5px;
}
swp-intect-title svg {
width: 18px;
height: 18px;
fill: currentColor;
}
swp-intect-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
swp-intect-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
swp-intect-label {
color: var(--color-text);
}
swp-intect-value {
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text);
}
swp-intect-divider {
height: 1px;
background: var(--color-teal);
opacity: 0.3;
margin: 8px 0;
}
swp-intect-total {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 700;
}
swp-intect-total swp-intect-value {
font-size: 18px;
color: var(--color-teal);
}
swp-copy-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--color-teal);
background: var(--color-surface);
border: 1px solid var(--color-teal);
border-radius: 4px;
cursor: pointer;
transition: all 150ms ease;
}
swp-copy-btn:hover {
background: var(--color-teal);
color: white;
}
swp-copy-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
swp-copy-btn.copied {
background: var(--color-green);
border-color: var(--color-green);
color: white;
}
/* ==========================================
TOTAL SUMMARY
========================================== */
swp-total-summary {
display: block;
background: var(--color-surface);
border-radius: 8px;
border: 1px solid var(--color-border);
padding: 24px;
margin-bottom: 24px;
}
swp-total-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
swp-total-header i {
font-size: 24px;
color: var(--color-teal);
}
swp-total-title {
font-size: 16px;
font-weight: 600;
}
swp-total-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
swp-total-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
background: var(--color-background-alt);
border-radius: 6px;
}
swp-total-item.highlight {
background: color-mix(in srgb, var(--color-teal) 10%, white);
}
swp-total-value {
font-size: 24px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--color-text);
}
swp-total-item.highlight swp-total-value {
color: var(--color-teal);
}
swp-total-label {
font-size: 12px;
color: var(--color-text-secondary);
}
@media (max-width: 768px) {
swp-total-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* ==========================================
SECTION LABEL
========================================== */
swp-section-label {
display: block;
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
/* ==========================================
PAYROLL EXPORT
========================================== */
swp-export-table {
display: block;
margin-bottom: 20px;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
swp-export-row {
display: grid;
grid-template-columns: 32px 1fr 90px 80px 55px 85px 85px 100px 44px;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
gap: 8px;
}
swp-export-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
swp-export-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--color-teal);
}
swp-export-row:last-child {
border-bottom: none;
}
swp-export-row.header {
background: var(--color-background-alt);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-secondary);
padding: 12px 16px;
}
swp-export-row.total {
background: color-mix(in srgb, var(--color-teal) 8%, white);
font-weight: 600;
}
swp-export-cell {
font-size: 14px;
}
swp-export-cell.right {
text-align: right;
}
swp-export-cell.mono {
font-family: var(--font-mono);
font-size: 13px;
}
swp-export-cell.highlight {
color: var(--color-teal);
font-weight: 600;
}
swp-copy-cell-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background);
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 150ms ease;
}
swp-copy-cell-btn:hover {
background: var(--color-teal);
color: white;
}
swp-copy-cell-btn.copied {
background: var(--color-green);
color: white;
}
swp-copy-cell-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
swp-export-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
</style>
</head>
<body>
<!-- Topbar -->
<swp-topbar>
<swp-topbar-left>
<swp-back-link>
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Tilbage
</swp-back-link>
<swp-page-title>Løn & Provision</swp-page-title>
</swp-topbar-left>
<swp-topbar-right>
<swp-btn class="secondary">
<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
Eksporter
</swp-btn>
</swp-topbar-right>
</swp-topbar>
<swp-page-container>
<!-- Periode selector -->
<swp-card>
<swp-card-header>
<swp-card-title>Vælg periode</swp-card-title>
</swp-card-header>
<swp-period-selector>
<swp-period-field>
<swp-period-label>År:</swp-period-label>
<select id="yearSelect">
<option value="2025" selected>2025</option>
<option value="2024">2024</option>
</select>
</swp-period-field>
<swp-period-field>
<swp-period-label>Fra uge:</swp-period-label>
<select id="weekFromSelect"></select>
</swp-period-field>
<swp-period-separator></swp-period-separator>
<swp-period-field>
<swp-period-label>Til uge:</swp-period-label>
<select id="weekToSelect"></select>
</swp-period-field>
<swp-btn class="primary" id="loadDataBtn">
<svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
Hent data
</swp-btn>
</swp-period-selector>
</swp-card>
<!-- Total summary -->
<swp-total-summary id="totalSummary">
<swp-total-header>
<i class="ph ph-coins"></i>
<swp-total-title>Samlet overblik for perioden</swp-total-title>
</swp-total-header>
<swp-total-grid>
<swp-total-item class="highlight">
<swp-total-value id="grandTotal">0 kr</swp-total-value>
<swp-total-label>Total løn</swp-total-label>
</swp-total-item>
<swp-total-item>
<swp-total-value id="totalBasePay">0 kr</swp-total-value>
<swp-total-label>Grundløn</swp-total-label>
</swp-total-item>
<swp-total-item>
<swp-total-value id="totalOvertime">0 kr</swp-total-value>
<swp-total-label>Overtidstillæg</swp-total-label>
</swp-total-item>
<swp-total-item>
<swp-total-value id="totalServiceCommission">0 kr</swp-total-value>
<swp-total-label>Prov. services</swp-total-label>
</swp-total-item>
<swp-total-item>
<swp-total-value id="totalProductCommission">0 kr</swp-total-value>
<swp-total-label>Prov. produkter</swp-total-label>
</swp-total-item>
</swp-total-grid>
</swp-total-summary>
<!-- Employee list -->
<swp-employee-list id="employeeList">
<!-- Rendered by JavaScript -->
</swp-employee-list>
<!-- Payroll Export Section -->
<swp-card id="payrollExportCard">
<swp-section-label>Løneksport til Intect</swp-section-label>
<swp-export-table id="exportTable">
<!-- Rendered by JavaScript -->
</swp-export-table>
<swp-export-actions>
<swp-btn class="secondary" id="copyAllBtn">
<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
Kopier alle til clipboard
</swp-btn>
<swp-btn class="primary" id="exportBtn">
<svg viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
Eksporter til Intect
</swp-btn>
</swp-export-actions>
</swp-card>
</swp-page-container>
<script>
// ==========================================
// MOCK DATA
// ==========================================
const employees = [
{
id: 1,
name: "Emma Larsen",
initials: "EL",
cpr: "010190-1234",
color: "",
hourlyRate: 185,
weeklyHours: 37,
overtimeRate: 1.5,
commission: {
hourlyMinimum: 220, // kr pr. time inkl. moms
servicePercentage: 15, // % på services over minimum
productPercentage: 15 // % på produktsalg over minimum
}
},
{
id: 2,
name: "Camilla Jensen",
initials: "CJ",
cpr: "150885-5678",
color: "purple",
hourlyRate: 175,
weeklyHours: 37,
overtimeRate: 1.5,
commission: {
hourlyMinimum: 220,
servicePercentage: 15,
productPercentage: 15
}
},
{
id: 3,
name: "Sofie Nielsen",
initials: "SN",
cpr: "220792-9012",
color: "blue",
hourlyRate: 195,
weeklyHours: 30, // Deltid
overtimeRate: 1.5,
commission: {
hourlyMinimum: 220,
servicePercentage: 15,
productPercentage: 15
}
}
];
// Generate mock weekly data
function generateMockData(year, weekFrom, weekTo) {
const data = [];
for (const emp of employees) {
for (let week = weekFrom; week <= weekTo; week++) {
// Random hours around their weekly hours
const hours = Math.floor(Math.random() * 10) + emp.weeklyHours - 3;
// Random service revenue (70-80% of total)
const serviceRevenue = Math.floor(Math.random() * 15000) + 8000;
// Random product revenue (20-30% of total)
const productRevenue = Math.floor(Math.random() * 5000) + 2000;
// Random vacation days (0-2 per week, mostly 0)
const vacationDays = Math.random() > 0.8 ? Math.floor(Math.random() * 3) : 0;
data.push({
employeeId: emp.id,
week,
year,
hoursWorked: Math.max(hours, 20),
serviceRevenue,
productRevenue,
vacationDays
});
}
}
return data;
}
let weeklyData = [];
// ==========================================
// CALCULATIONS
// ==========================================
function calculateWeekPayroll(employee, weekData) {
const normalHours = Math.min(weekData.hoursWorked, employee.weeklyHours);
const overtimeHours = Math.max(0, weekData.hoursWorked - employee.weeklyHours);
const normalPay = normalHours * employee.hourlyRate;
const overtimePay = overtimeHours * employee.hourlyRate * employee.overtimeRate;
// Provision: minimum baseret på timer × timesat
const totalRevenue = weekData.serviceRevenue + weekData.productRevenue;
const minimumThreshold = weekData.hoursWorked * employee.commission.hourlyMinimum;
const revenueOverMinimum = Math.max(0, totalRevenue - minimumThreshold);
// Fordel overskud proportionalt mellem services og produkter
let serviceCommission = 0;
let productCommission = 0;
if (revenueOverMinimum > 0 && totalRevenue > 0) {
const serviceRatio = weekData.serviceRevenue / totalRevenue;
const productRatio = weekData.productRevenue / totalRevenue;
const serviceOverMinimum = revenueOverMinimum * serviceRatio;
const productOverMinimum = revenueOverMinimum * productRatio;
serviceCommission = serviceOverMinimum * (employee.commission.servicePercentage / 100);
productCommission = productOverMinimum * (employee.commission.productPercentage / 100);
}
const totalCommission = serviceCommission + productCommission;
return {
normalHours,
overtimeHours,
normalPay,
overtimePay,
serviceRevenue: weekData.serviceRevenue,
productRevenue: weekData.productRevenue,
totalRevenue,
minimumThreshold,
revenueOverMinimum,
serviceCommission,
productCommission,
commission: totalCommission,
vacationDays: weekData.vacationDays || 0,
totalPay: normalPay + overtimePay + totalCommission
};
}
function calculateEmployeeTotals(employee, weeks) {
const employeeWeeks = weeks.filter(w => w.employeeId === employee.id);
let totalNormalHours = 0;
let totalOvertimeHours = 0;
let totalNormalPay = 0;
let totalOvertimePay = 0;
let totalServiceCommission = 0;
let totalProductCommission = 0;
let totalServiceRevenue = 0;
let totalProductRevenue = 0;
let totalVacationDays = 0;
const weekResults = [];
for (const week of employeeWeeks) {
const calc = calculateWeekPayroll(employee, week);
weekResults.push({
...week,
...calc
});
totalNormalHours += calc.normalHours;
totalOvertimeHours += calc.overtimeHours;
totalNormalPay += calc.normalPay;
totalOvertimePay += calc.overtimePay;
totalServiceCommission += calc.serviceCommission;
totalProductCommission += calc.productCommission;
totalServiceRevenue += calc.serviceRevenue;
totalProductRevenue += calc.productRevenue;
totalVacationDays += calc.vacationDays;
}
const totalCommission = totalServiceCommission + totalProductCommission;
return {
weeks: weekResults,
totals: {
normalHours: totalNormalHours,
overtimeHours: totalOvertimeHours,
normalPay: totalNormalPay,
overtimePay: totalOvertimePay,
serviceCommission: totalServiceCommission,
productCommission: totalProductCommission,
commission: totalCommission,
serviceRevenue: totalServiceRevenue,
productRevenue: totalProductRevenue,
revenue: totalServiceRevenue + totalProductRevenue,
vacationDays: totalVacationDays,
total: totalNormalPay + totalOvertimePay + totalCommission
}
};
}
// ==========================================
// FORMATTING
// ==========================================
function formatCurrency(amount) {
return Math.round(amount).toLocaleString('da-DK') + ' kr';
}
function formatHours(hours) {
return hours + 't';
}
// ==========================================
// RENDER FUNCTIONS
// ==========================================
function renderEmployeeCard(employee, data, isExpanded = false) {
const { weeks, totals } = data;
return `
<swp-employee-card class="${isExpanded ? 'expanded' : ''}" data-employee-id="${employee.id}">
<swp-employee-header onclick="toggleEmployee(${employee.id})">
<swp-employee-info>
<swp-employee-avatar class="${employee.color}">${employee.initials}</swp-employee-avatar>
<swp-employee-details>
<swp-employee-name>${employee.name}</swp-employee-name>
<swp-employee-meta>${formatHours(totals.normalHours + totals.overtimeHours)} · ${formatCurrency(totals.revenue)} oms.</swp-employee-meta>
</swp-employee-details>
</swp-employee-info>
<swp-employee-summary>
<swp-summary-item>
<swp-summary-value>${formatCurrency(totals.commission)}</swp-summary-value>
<swp-summary-label>Provision</swp-summary-label>
</swp-summary-item>
<swp-summary-item>
<swp-summary-value>${formatCurrency(totals.total)}</swp-summary-value>
<swp-summary-label>Total</swp-summary-label>
</swp-summary-item>
<swp-expand-icon>
<svg viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</swp-expand-icon>
</swp-employee-summary>
</swp-employee-header>
<swp-employee-content>
<swp-employee-config>
<swp-config-item>
<swp-config-label>Timeløn:</swp-config-label>
<swp-config-value class="mono">${employee.hourlyRate} kr</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>Normtid:</swp-config-label>
<swp-config-value>${employee.weeklyHours}t/uge</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>Overtid:</swp-config-label>
<swp-config-value>+${Math.round((employee.overtimeRate - 1) * 100)}%</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>Minimum:</swp-config-label>
<swp-config-value class="mono">${employee.commission.hourlyMinimum} kr/time</swp-config-value>
</swp-config-item>
<swp-config-item>
<swp-config-label>Provision:</swp-config-label>
<swp-config-value>${employee.commission.servicePercentage}% services · ${employee.commission.productPercentage}% produkter</swp-config-value>
</swp-config-item>
</swp-employee-config>
<swp-weeks-table>
<swp-table>
<swp-table-header>
<swp-table-row>
<swp-table-cell>Uge</swp-table-cell>
<swp-table-cell class="right">Timer</swp-table-cell>
<swp-table-cell class="right">Overtid</swp-table-cell>
<swp-table-cell class="right">Ferie</swp-table-cell>
<swp-table-cell class="right">Services</swp-table-cell>
<swp-table-cell class="right">Produkter</swp-table-cell>
<swp-table-cell class="right">Minimum</swp-table-cell>
<swp-table-cell class="right">Provision</swp-table-cell>
<swp-table-cell class="right">I alt</swp-table-cell>
</swp-table-row>
</swp-table-header>
<swp-table-body>
${weeks.map(w => `
<swp-table-row>
<swp-table-cell>Uge ${w.week}</swp-table-cell>
<swp-table-cell class="right mono">${formatHours(w.normalHours)}</swp-table-cell>
<swp-table-cell class="right mono ${w.overtimeHours > 0 ? 'warning' : ''}">${formatHours(w.overtimeHours)}</swp-table-cell>
<swp-table-cell class="right mono">${w.vacationDays} dg</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(w.serviceRevenue)}</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(w.productRevenue)}</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(w.minimumThreshold)}</swp-table-cell>
<swp-table-cell class="right mono ${w.commission > 0 ? 'highlight' : ''}">${formatCurrency(w.commission)}</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(w.totalPay)}</swp-table-cell>
</swp-table-row>
`).join('')}
</swp-table-body>
<swp-table-footer>
<swp-table-row>
<swp-table-cell>TOTAL</swp-table-cell>
<swp-table-cell class="right mono">${formatHours(totals.normalHours)}</swp-table-cell>
<swp-table-cell class="right mono">${formatHours(totals.overtimeHours)}</swp-table-cell>
<swp-table-cell class="right mono">${totals.vacationDays} dg</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(totals.serviceRevenue)}</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(totals.productRevenue)}</swp-table-cell>
<swp-table-cell class="right mono">—</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(totals.commission)}</swp-table-cell>
<swp-table-cell class="right mono">${formatCurrency(totals.total)}</swp-table-cell>
</swp-table-row>
</swp-table-footer>
</swp-table>
</swp-weeks-table>
</swp-employee-content>
</swp-employee-card>
`;
}
function renderEmployeeList() {
const container = document.getElementById('employeeList');
let html = '';
let grandTotal = 0;
let totalBasePay = 0;
let totalOvertime = 0;
let totalServiceCommission = 0;
let totalProductCommission = 0;
for (const emp of employees) {
const data = calculateEmployeeTotals(emp, weeklyData);
html += renderEmployeeCard(emp, data, emp.id === 1);
grandTotal += data.totals.total;
totalBasePay += data.totals.normalPay;
totalOvertime += data.totals.overtimePay;
totalServiceCommission += data.totals.serviceCommission;
totalProductCommission += data.totals.productCommission;
}
container.innerHTML = html;
// Update totals
document.getElementById('grandTotal').textContent = formatCurrency(grandTotal);
document.getElementById('totalBasePay').textContent = formatCurrency(totalBasePay);
document.getElementById('totalOvertime').textContent = formatCurrency(totalOvertime);
document.getElementById('totalServiceCommission').textContent = formatCurrency(totalServiceCommission);
document.getElementById('totalProductCommission').textContent = formatCurrency(totalProductCommission);
}
// ==========================================
// INTERACTIONS
// ==========================================
function toggleEmployee(employeeId) {
const card = document.querySelector(`swp-employee-card[data-employee-id="${employeeId}"]`);
card.classList.toggle('expanded');
}
// ==========================================
// EXPORT TABLE
// ==========================================
let employeeTotalsCache = [];
function renderExportTable() {
const container = document.getElementById('exportTable');
let grandTotalBasePay = 0;
let grandTotalOvertime = 0;
let grandTotalVacationDays = 0;
let grandTotalServiceCommission = 0;
let grandTotalProductCommission = 0;
let grandTotal = 0;
let rows = `
<swp-export-row class="header">
<swp-export-checkbox>
<input type="checkbox" id="selectAllExport" checked onchange="toggleAllExport(this.checked)">
</swp-export-checkbox>
<swp-export-cell>Medarbejder</swp-export-cell>
<swp-export-cell class="right">Grundløn</swp-export-cell>
<swp-export-cell class="right">Overtid</swp-export-cell>
<swp-export-cell class="right">Ferie</swp-export-cell>
<swp-export-cell class="right">Prov. serv.</swp-export-cell>
<swp-export-cell class="right">Prov. prod.</swp-export-cell>
<swp-export-cell class="right">Total</swp-export-cell>
<swp-export-cell></swp-export-cell>
</swp-export-row>
`;
employeeTotalsCache = [];
for (const emp of employees) {
const data = calculateEmployeeTotals(emp, weeklyData);
const { totals } = data;
employeeTotalsCache.push({ employee: emp, totals });
grandTotalBasePay += totals.normalPay;
grandTotalOvertime += totals.overtimePay;
grandTotalVacationDays += totals.vacationDays;
grandTotalServiceCommission += totals.serviceCommission;
grandTotalProductCommission += totals.productCommission;
grandTotal += totals.total;
rows += `
<swp-export-row data-employee-id="${emp.id}">
<swp-export-checkbox>
<input type="checkbox" class="export-employee-checkbox" data-employee-id="${emp.id}" checked onchange="updateSelectAllCheckbox()">
</swp-export-checkbox>
<swp-export-cell>${emp.name}</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(totals.normalPay)}</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(totals.overtimePay)}</swp-export-cell>
<swp-export-cell class="right mono">${totals.vacationDays} dg</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(totals.serviceCommission)}</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(totals.productCommission)}</swp-export-cell>
<swp-export-cell class="right mono highlight">${formatCurrency(totals.total)}</swp-export-cell>
<swp-export-cell>
<swp-copy-cell-btn onclick="copyEmployeeRow(${emp.id}, event)" title="Kopier">
<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</swp-copy-cell-btn>
</swp-export-cell>
</swp-export-row>
`;
}
rows += `
<swp-export-row class="total">
<swp-export-cell></swp-export-cell>
<swp-export-cell>TOTAL</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(grandTotalBasePay)}</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(grandTotalOvertime)}</swp-export-cell>
<swp-export-cell class="right mono">${grandTotalVacationDays} dg</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(grandTotalServiceCommission)}</swp-export-cell>
<swp-export-cell class="right mono">${formatCurrency(grandTotalProductCommission)}</swp-export-cell>
<swp-export-cell class="right mono highlight">${formatCurrency(grandTotal)}</swp-export-cell>
<swp-export-cell></swp-export-cell>
</swp-export-row>
`;
container.innerHTML = rows;
}
function toggleAllExport(checked) {
const checkboxes = document.querySelectorAll('.export-employee-checkbox');
checkboxes.forEach(cb => cb.checked = checked);
}
function updateSelectAllCheckbox() {
const checkboxes = document.querySelectorAll('.export-employee-checkbox');
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
const someChecked = Array.from(checkboxes).some(cb => cb.checked);
const selectAll = document.getElementById('selectAllExport');
selectAll.checked = allChecked;
selectAll.indeterminate = someChecked && !allChecked;
}
function getSelectedEmployeeIds() {
const checkboxes = document.querySelectorAll('.export-employee-checkbox:checked');
return Array.from(checkboxes).map(cb => parseInt(cb.dataset.employeeId));
}
function copyEmployeeRow(employeeId, event) {
const cached = employeeTotalsCache.find(c => c.employee.id === employeeId);
if (!cached) return;
const { employee, totals } = cached;
const text = `${employee.name}\t${Math.round(totals.normalPay)}\t${Math.round(totals.overtimePay)}\t${totals.vacationDays}\t${Math.round(totals.serviceCommission)}\t${Math.round(totals.productCommission)}\t${Math.round(totals.total)}`;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target.closest('swp-copy-cell-btn');
btn.classList.add('copied');
btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>`;
}, 1500);
});
}
function copyAllToClipboard() {
let text = "Medarbejder\tGrundløn\tOvertid\tFeriedage\tProv. serv.\tProv. prod.\tTotal\n";
for (const { employee, totals } of employeeTotalsCache) {
text += `${employee.name}\t${Math.round(totals.normalPay)}\t${Math.round(totals.overtimePay)}\t${totals.vacationDays}\t${Math.round(totals.serviceCommission)}\t${Math.round(totals.productCommission)}\t${Math.round(totals.total)}\n`;
}
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copyAllBtn');
const originalHtml = btn.innerHTML;
btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> Kopieret!`;
setTimeout(() => {
btn.innerHTML = originalHtml;
}, 2000);
});
}
// ==========================================
// INTECT EXPORT
// ==========================================
function generateIntectData() {
const rows = [];
const selectedIds = getSelectedEmployeeIds();
const selectedEmployees = employees.filter(emp => selectedIds.includes(emp.id));
for (const emp of selectedEmployees) {
const data = calculateEmployeeTotals(emp, weeklyData);
// Løn - en linje pr. uge (normal tid)
for (const week of data.weeks) {
rows.push({
cpr: emp.cpr || '',
name: emp.name,
initials: emp.initials,
lønart: 'Løn',
beskrivelse: `Uge ${week.week}`,
enheder: week.normalHours,
sats: emp.hourlyRate,
beløb: week.normalPay
});
// Overtid - også lønart "Løn", pr. uge
if (week.overtimeHours > 0) {
rows.push({
cpr: emp.cpr || '',
name: emp.name,
initials: emp.initials,
lønart: 'Løn',
beskrivelse: `Uge ${week.week} Overtid`,
enheder: week.overtimeHours,
sats: emp.hourlyRate * emp.overtimeRate,
beløb: week.overtimePay
});
}
}
// Provision - en linje pr. uge med provision
for (const week of data.weeks) {
if (week.commission > 0) {
rows.push({
cpr: emp.cpr || '',
name: emp.name,
initials: emp.initials,
lønart: 'Provision',
beskrivelse: `Uge ${week.week} provision ${emp.commission.servicePercentage}%, oms ${Math.round(week.totalRevenue)}`,
grundlag: week.revenueOverMinimum,
enheder: emp.commission.servicePercentage,
sats: week.revenueOverMinimum,
beløb: week.commission
});
}
}
// Ferie - altid inkluderet, også ved 0 dage
rows.push({
cpr: emp.cpr || '',
name: emp.name,
initials: emp.initials,
lønart: 'Ferie',
beskrivelse: '',
enheder: data.totals.vacationDays,
sats: '',
beløb: 0
});
}
return rows;
}
function exportToIntect() {
const year = document.getElementById('yearSelect').value;
const weekFrom = document.getElementById('weekFromSelect').value;
const weekTo = document.getElementById('weekToSelect').value;
const data = generateIntectData();
// Konverter til worksheet format
const wsData = [
['CPR-nummer', 'Medarbejder navn', 'Initialer', 'Ekstern reference',
'Medarbejder-ID', 'Lønart', 'Beskrivelse', 'Grundlag', 'Enheder',
'Sats', 'Beløb', 'Efter næste kørsel', 'Fra dato', 'Til dato'],
...data.map(row => [
row.cpr, row.name, row.initials, '', '',
row.lønart, row.beskrivelse, row.grundlag || '',
row.enheder, row.sats, row.beløb, 'Nulstil', '', ''
])
];
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.aoa_to_sheet(wsData);
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
XLSX.writeFile(wb, `loeneksport_${year}_uge${weekFrom}-${weekTo}.xlsx`);
}
// ==========================================
// INITIALIZATION
// ==========================================
function initWeekSelects() {
const weekFrom = document.getElementById('weekFromSelect');
const weekTo = document.getElementById('weekToSelect');
for (let i = 1; i <= 52; i++) {
weekFrom.innerHTML += `<option value="${i}" ${i === 1 ? 'selected' : ''}>Uge ${i}</option>`;
weekTo.innerHTML += `<option value="${i}" ${i === 4 ? 'selected' : ''}>Uge ${i}</option>`;
}
}
function loadData() {
const year = parseInt(document.getElementById('yearSelect').value);
const weekFrom = parseInt(document.getElementById('weekFromSelect').value);
const weekTo = parseInt(document.getElementById('weekToSelect').value);
weeklyData = generateMockData(year, weekFrom, weekTo);
renderEmployeeList();
renderExportTable();
}
// Initialize
initWeekSelects();
document.getElementById('loadDataBtn').addEventListener('click', loadData);
document.getElementById('copyAllBtn').addEventListener('click', copyAllToClipboard);
document.getElementById('exportBtn').addEventListener('click', exportToIntect);
// Load initial data
loadData();
</script>
</body>
</html>