PlanTempusApp/.workbench/POC/poc-loen-provision.html

1526 lines
47 KiB
HTML
Raw Normal View History

2026-02-03 19:12:45 +01:00
<!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>