Merge pull request #19 from Kenearos/copilot/export-data-to-excel
Add CSV export for Excel-friendly data export
This commit is contained in:
commit
b8ce3308d2
4 changed files with 242 additions and 0 deletions
|
|
@ -460,10 +460,12 @@
|
||||||
<p style="color: #666;">Daten werden automatisch im Browser gespeichert (localStorage).</p>
|
<p style="color: #666;">Daten werden automatisch im Browser gespeichert (localStorage).</p>
|
||||||
|
|
||||||
<div style="margin: 20px 0;">
|
<div style="margin: 20px 0;">
|
||||||
|
<button onclick="app.exportCSV()" style="background: #28a745; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: bold;">📊 Excel/CSV Export</button>
|
||||||
<button onclick="app.exportData()">Daten exportieren (JSON)</button>
|
<button onclick="app.exportData()">Daten exportieren (JSON)</button>
|
||||||
<input type="file" id="importFile" accept=".json" onchange="app.importData(this)" style="display:none">
|
<input type="file" id="importFile" accept=".json" onchange="app.importData(this)" style="display:none">
|
||||||
<button onclick="document.getElementById('importFile').click()">Daten importieren</button>
|
<button onclick="document.getElementById('importFile').click()">Daten importieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p style="color: #666; font-size: 0.9rem; margin-top: 10px;">💡 <strong>Tipp:</strong> CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden.</p>
|
||||||
|
|
||||||
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||||
<h4 style="color: #dc3545;">Gefahrenzone</h4>
|
<h4 style="color: #dc3545;">Gefahrenzone</h4>
|
||||||
|
|
@ -963,6 +965,125 @@ class DienstplanApp {
|
||||||
downloadAnchor.remove();
|
downloadAnchor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data as CSV (Excel-compatible) - Beginner-friendly format
|
||||||
|
* Exports two sheets: 1) All duties (Dienste) 2) Monthly summary (Auswertung)
|
||||||
|
*/
|
||||||
|
exportCSV() {
|
||||||
|
const m = parseInt(document.getElementById('selectMonth').value);
|
||||||
|
const y = parseInt(document.getElementById('selectYear').value);
|
||||||
|
|
||||||
|
// Helper function to escape CSV values (handles semicolons, quotes, newlines)
|
||||||
|
const escapeCSV = (value) => {
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(';') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build CSV content with BOM for Excel UTF-8 support
|
||||||
|
let csv = '\uFEFF'; // UTF-8 BOM for Excel
|
||||||
|
|
||||||
|
// === Sheet 1: Dienste (All Duties) ===
|
||||||
|
csv += 'ALLE DIENSTE\n';
|
||||||
|
csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n';
|
||||||
|
|
||||||
|
// Sort duties by date
|
||||||
|
const sortedDuties = [...this.duties].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
sortedDuties.forEach(duty => {
|
||||||
|
const date = new Date(duty.date);
|
||||||
|
const dayInfo = this.getDayTypeInfo(date);
|
||||||
|
const isQual = this.isQualifyingDay(date);
|
||||||
|
const dateStr = date.toLocaleDateString('de-DE');
|
||||||
|
const weekday = WEEKDAYS[date.getDay()];
|
||||||
|
const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)';
|
||||||
|
|
||||||
|
csv += `${dateStr};${weekday};${escapeCSV(duty.name)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
csv += '\n\n';
|
||||||
|
|
||||||
|
// === Sheet 2: Monatliche Auswertung ===
|
||||||
|
csv += `AUSWERTUNG ${MONTHS[m]} ${y}\n`;
|
||||||
|
csv += 'Mitarbeiter;WT (Einheiten);WE Freitag;WE Andere;WE Gesamt;Schwelle erreicht;Auszahlung (EUR)\n';
|
||||||
|
|
||||||
|
// Filter duties for selected month
|
||||||
|
const monthDuties = this.duties.filter(d => {
|
||||||
|
const date = new Date(d.date);
|
||||||
|
return date.getMonth() === m && date.getFullYear() === y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by employee
|
||||||
|
const stats = {};
|
||||||
|
monthDuties.forEach(d => {
|
||||||
|
if (!stats[d.name]) {
|
||||||
|
stats[d.name] = { wt: 0, we_fr: 0, we_other: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(d.date);
|
||||||
|
const isQual = this.isQualifyingDay(date);
|
||||||
|
const isFri = this.isFriday(date);
|
||||||
|
|
||||||
|
if (!isQual) {
|
||||||
|
stats[d.name].wt += d.share;
|
||||||
|
} else {
|
||||||
|
if (isFri) {
|
||||||
|
stats[d.name].we_fr += d.share;
|
||||||
|
} else {
|
||||||
|
stats[d.name].we_other += d.share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalPayout = 0;
|
||||||
|
|
||||||
|
for (const [name, data] of Object.entries(stats)) {
|
||||||
|
const we_total = data.we_fr + data.we_other;
|
||||||
|
const thresholdReached = we_total >= (CONFIG.THRESHOLD - CONFIG.TOLERANCE);
|
||||||
|
|
||||||
|
let payout = 0;
|
||||||
|
|
||||||
|
if (thresholdReached) {
|
||||||
|
const wt_pay = data.wt * CONFIG.RATE_WT;
|
||||||
|
let deduct = CONFIG.DEDUCTION;
|
||||||
|
const deduct_fr = Math.min(deduct, data.we_fr);
|
||||||
|
const deduct_other = Math.max(0, deduct - deduct_fr);
|
||||||
|
const paid_fr = Math.max(0, data.we_fr - deduct_fr);
|
||||||
|
const paid_other = Math.max(0, data.we_other - deduct_other);
|
||||||
|
const we_pay = (paid_fr + paid_other) * CONFIG.RATE_WE;
|
||||||
|
payout = wt_pay + we_pay;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPayout += payout;
|
||||||
|
|
||||||
|
const threshold = thresholdReached ? 'JA' : 'NEIN';
|
||||||
|
|
||||||
|
csv += `${escapeCSV(name)};${data.wt.toFixed(1).replace('.', ',')};${data.we_fr.toFixed(1).replace('.', ',')};${data.we_other.toFixed(1).replace('.', ',')};${we_total.toFixed(1).replace('.', ',')};${threshold};${payout.toFixed(2).replace('.', ',')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
csv += `\nGESAMT;;;;;;${totalPayout.toFixed(2).replace('.', ',')}\n`;
|
||||||
|
|
||||||
|
csv += '\n\n';
|
||||||
|
csv += 'LEGENDE\n';
|
||||||
|
csv += 'WT;Werktag (Montag-Donnerstag ohne Feiertag/Vortag)\n';
|
||||||
|
csv += 'WE-Tag;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
|
||||||
|
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
|
||||||
|
csv += 'Sätze;"WT = 250 EUR/Einheit, WE = 450 EUR/Einheit (abzgl. 1,0 Abzug)"\n';
|
||||||
|
|
||||||
|
// Download CSV file
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const downloadAnchor = document.createElement('a');
|
||||||
|
downloadAnchor.href = url;
|
||||||
|
downloadAnchor.download = `Dienstplan_${y}_${String(m + 1).padStart(2, '0')}.csv`;
|
||||||
|
document.body.appendChild(downloadAnchor);
|
||||||
|
downloadAnchor.click();
|
||||||
|
downloadAnchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
importData(input) {
|
importData(input) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
|
||||||
107
webapp/app.js
107
webapp/app.js
|
|
@ -50,6 +50,7 @@ class DienstplanApp {
|
||||||
document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses());
|
document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses());
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
document.getElementById('export-csv-btn').addEventListener('click', () => this.exportCSV());
|
||||||
document.getElementById('export-btn').addEventListener('click', () => this.exportData());
|
document.getElementById('export-btn').addEventListener('click', () => this.exportData());
|
||||||
document.getElementById('import-btn').addEventListener('click', () => this.importData());
|
document.getElementById('import-btn').addEventListener('click', () => this.importData());
|
||||||
document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData());
|
document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData());
|
||||||
|
|
@ -434,6 +435,112 @@ class DienstplanApp {
|
||||||
this.showToast('Daten wurden exportiert.', 'success');
|
this.showToast('Daten wurden exportiert.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data as CSV (Excel-compatible) - Beginner-friendly format
|
||||||
|
* Exports all duties and monthly summary for the selected month
|
||||||
|
*/
|
||||||
|
exportCSV() {
|
||||||
|
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||||
|
|
||||||
|
const month = parseInt(document.getElementById('calc-month-select').value);
|
||||||
|
const year = parseInt(document.getElementById('calc-year-select').value);
|
||||||
|
|
||||||
|
// Helper function to escape CSV values (handles semicolons, quotes, newlines)
|
||||||
|
const escapeCSV = (value) => {
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(';') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build CSV content with BOM for Excel UTF-8 support
|
||||||
|
let csv = '\uFEFF'; // UTF-8 BOM for Excel
|
||||||
|
|
||||||
|
// === Sheet 1: Dienste (All Duties for the month) ===
|
||||||
|
csv += `DIENSTE ${monthNames[month - 1]} ${year}\n`;
|
||||||
|
csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n';
|
||||||
|
|
||||||
|
const employees = this.storage.getEmployees();
|
||||||
|
const allDuties = [];
|
||||||
|
|
||||||
|
// Collect all duties for the selected month from all employees
|
||||||
|
employees.forEach(employee => {
|
||||||
|
const duties = this.storage.getDutiesForMonth(employee, year, month);
|
||||||
|
duties.forEach(duty => {
|
||||||
|
allDuties.push({
|
||||||
|
...duty,
|
||||||
|
employee: employee
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
allDuties.sort((a, b) => a.date - b.date);
|
||||||
|
|
||||||
|
allDuties.forEach(duty => {
|
||||||
|
const isQual = this.calculator.isQualifyingDay(duty.date);
|
||||||
|
const dateStr = duty.date.toLocaleDateString('de-DE');
|
||||||
|
const weekday = weekdays[duty.date.getDay()];
|
||||||
|
const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)';
|
||||||
|
|
||||||
|
csv += `${dateStr};${weekday};${escapeCSV(duty.employee)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
csv += '\n\n';
|
||||||
|
|
||||||
|
// === Sheet 2: Monatliche Auswertung ===
|
||||||
|
csv += `AUSWERTUNG ${monthNames[month - 1]} ${year}\n`;
|
||||||
|
csv += 'Mitarbeiter;Normale Tage;WE/Feiertag Tage;Abzug;Normale Tage (bezahlt);WE/Feiertag (bezahlt);Schwelle erreicht;Bonus Normal;Bonus WE;Gesamtbonus (EUR)\n';
|
||||||
|
|
||||||
|
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
|
||||||
|
const results = this.calculator.calculateAllEmployees(employeeDuties);
|
||||||
|
|
||||||
|
let totalBonus = 0;
|
||||||
|
|
||||||
|
for (const [employeeName, result] of Object.entries(results)) {
|
||||||
|
const threshold = result.thresholdReached ? 'JA' : 'NEIN';
|
||||||
|
|
||||||
|
totalBonus += result.totalBonus;
|
||||||
|
|
||||||
|
csv += `${escapeCSV(employeeName)};`;
|
||||||
|
csv += `${result.normalDays.toFixed(1).replace('.', ',')};`;
|
||||||
|
csv += `${result.qualifyingDays.toFixed(1).replace('.', ',')};`;
|
||||||
|
csv += `${result.qualifyingDaysDeducted.toFixed(1).replace('.', ',')};`;
|
||||||
|
csv += `${result.normalDaysPaid.toFixed(1).replace('.', ',')};`;
|
||||||
|
csv += `${result.qualifyingDaysPaid.toFixed(1).replace('.', ',')};`;
|
||||||
|
csv += `${threshold};`;
|
||||||
|
csv += `${result.bonusNormalDays.toFixed(2).replace('.', ',')};`;
|
||||||
|
csv += `${result.bonusQualifyingDays.toFixed(2).replace('.', ',')};`;
|
||||||
|
csv += `${result.totalBonus.toFixed(2).replace('.', ',')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
csv += `\nGESAMT;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`;
|
||||||
|
|
||||||
|
csv += '\n\n';
|
||||||
|
csv += 'LEGENDE\n';
|
||||||
|
csv += 'Normale Tage;Montag-Donnerstag ohne Feiertag/Vortag\n';
|
||||||
|
csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
|
||||||
|
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
|
||||||
|
csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n';
|
||||||
|
csv += 'Abzug;"Bei Erreichen der Schwelle wird 1,0 WE-Einheit abgezogen"\n';
|
||||||
|
|
||||||
|
// Download CSV file
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `Dienstplan_${year}_${String(month).padStart(2, '0')}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
this.showToast('CSV wurde exportiert. Öffnen Sie die Datei mit Excel oder LibreOffice.', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import data from JSON file
|
* Import data from JSON file
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,9 @@
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Datenexport / Import</h3>
|
<h3>Datenexport / Import</h3>
|
||||||
|
<button id="export-csv-btn" class="btn btn-success">📊 Excel/CSV Export</button>
|
||||||
<button id="export-btn" class="btn btn-secondary">Daten exportieren (JSON)</button>
|
<button id="export-btn" class="btn btn-secondary">Daten exportieren (JSON)</button>
|
||||||
|
<p class="text-muted" style="margin-top: 10px;">💡 <strong>Tipp:</strong> CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="import-file">Daten importieren:</label>
|
<label for="import-file">Daten importieren:</label>
|
||||||
<input type="file" id="import-file" accept=".json">
|
<input type="file" id="import-file" accept=".json">
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,18 @@ header h1 {
|
||||||
background: #5a6268;
|
background: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #218838;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #dc3545;
|
background: #dc3545;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
|
||||||
Reference in a new issue