Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0930b37638 | ||
|
|
c4b22cfe50 | ||
|
|
1b4c99c08d | ||
|
|
2c24b4d71d |
+28
-12
@@ -1,17 +1,33 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [v0.42.0] - 2026-06-30
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
- Modernes, komplett überarbeitetes Webinterface
|
||||||
|
- Verbesserte Benutzerführung
|
||||||
|
- Optimiertes responsives Layout
|
||||||
|
- Überarbeitete Karten- und Tabellenansicht
|
||||||
|
- Modernisierte Farbgebung und Typografie
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- README auf Version v0.42.0 aktualisiert
|
||||||
|
|
||||||
|
|
||||||
|
## v0.41.1
|
||||||
|
|
||||||
|
- Versionsanzeige in einen dezenten Footer rechts unten verschoben
|
||||||
|
- Repository-Link ergänzt
|
||||||
|
- Version zentral über `APP_VERSION` gesetzt
|
||||||
|
|
||||||
|
## v0.41.0
|
||||||
|
|
||||||
|
- Versionsanzeige im Tool ergänzt
|
||||||
|
- CSV-Export enthält Tool-Version
|
||||||
|
- Exportdateiname enthält Tool-Version
|
||||||
|
|
||||||
## v0.40.0
|
## v0.40.0
|
||||||
|
|
||||||
- Bundesland-Auswahl für Feiertage ergänzt
|
- Feiertagslogik für Nordrhein-Westfalen, Bayern, Niedersachsen und Hamburg ergänzt
|
||||||
- Unterstützte Bundesländer: Bayern, Niedersachsen, Hamburg, Nordrhein-Westfalen
|
- Zusatzoption Bayern inkl. Mariä Himmelfahrt
|
||||||
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
|
|
||||||
- Feiertage werden automatisch aus dem Datum berechnet
|
|
||||||
- Feiertage werden mit 0:00 Sollzeit bewertet
|
- Feiertage werden mit 0:00 Sollzeit bewertet
|
||||||
- Arbeit an Feiertagen zählt vollständig als Pluszeit
|
- Tagesdetails und Export um Feiertagsinformationen ergänzt
|
||||||
- Feiertagsspalte in Tagesdetails und CSV-Export ergänzt
|
|
||||||
|
|
||||||
## v0.30.0
|
|
||||||
|
|
||||||
- Sollzeit Montag bis Freitag = 8:00 Stunden
|
|
||||||
- Sollzeit Samstag und Sonntag = 0:00 Stunden
|
|
||||||
- Wochenendarbeit zählt vollständig als Pluszeit
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden.
|
Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden.
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
Aktueller Stand: **v0.42.0**
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- CSV-Upload im Browser
|
- CSV-Upload im Browser
|
||||||
|
- Modernes, responsives Webinterface
|
||||||
- Auswertung der Netto-Arbeitszeit
|
- Auswertung der Netto-Arbeitszeit
|
||||||
- Sollzeit Montag bis Freitag: 8:00 Stunden
|
- Sollzeit Montag bis Freitag: 8:00 Stunden
|
||||||
- Sollzeit Samstag und Sonntag: 0:00 Stunden
|
- Sollzeit Samstag und Sonntag: 0:00 Stunden
|
||||||
@@ -13,8 +18,12 @@ Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- un
|
|||||||
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
|
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
|
||||||
- Monatsübersicht
|
- Monatsübersicht
|
||||||
- Tagesdetails inklusive Wochentag und Feiertag
|
- Tagesdetails inklusive Wochentag und Feiertag
|
||||||
- CSV-Export
|
- CSV-Export inklusive Tool-Version
|
||||||
|
|
||||||
## Datenschutz
|
## Datenschutz
|
||||||
|
|
||||||
Die CSV-Datei wird ausschließlich lokal im Browser verarbeitet. Es findet kein Upload auf einen Server statt.
|
Die CSV-Datei wird ausschließlich lokal im Browser verarbeitet. Es findet kein Upload auf einen Server statt.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
https://git.mike-lindner.net/mike/qplanner-arbeitszeit-auswertung
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
const APP_VERSION = 'v0.42.0';
|
||||||
|
|
||||||
const fileInput = document.getElementById('csvFile');
|
const fileInput = document.getElementById('csvFile');
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
const targetTimeInput = document.getElementById('targetTime');
|
const targetTimeInput = document.getElementById('targetTime');
|
||||||
const federalStateSelect = document.getElementById('federalState');
|
const federalStateInput = document.getElementById('federalState');
|
||||||
const errorBox = document.getElementById('errorBox');
|
const errorBox = document.getElementById('errorBox');
|
||||||
const summary = document.getElementById('summary');
|
const summary = document.getElementById('summary');
|
||||||
const results = document.getElementById('results');
|
const results = document.getElementById('results');
|
||||||
@@ -9,6 +11,9 @@ const monthTable = document.getElementById('monthTable');
|
|||||||
const dayTable = document.getElementById('dayTable');
|
const dayTable = document.getElementById('dayTable');
|
||||||
const exportButton = document.getElementById('exportCsv');
|
const exportButton = document.getElementById('exportCsv');
|
||||||
const formatHint = document.getElementById('formatHint');
|
const formatHint = document.getElementById('formatHint');
|
||||||
|
const appVersion = document.getElementById('appVersion');
|
||||||
|
|
||||||
|
if (appVersion) appVersion.textContent = APP_VERSION;
|
||||||
|
|
||||||
let lastResult = null;
|
let lastResult = null;
|
||||||
let lastFileText = null;
|
let lastFileText = null;
|
||||||
@@ -18,13 +23,8 @@ fileInput.addEventListener('change', event => {
|
|||||||
if (file) readFile(file);
|
if (file) readFile(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
targetTimeInput.addEventListener('change', () => {
|
targetTimeInput.addEventListener('change', rerunAnalysis);
|
||||||
if (lastFileText) analyseCsv(lastFileText);
|
federalStateInput.addEventListener('change', rerunAnalysis);
|
||||||
});
|
|
||||||
|
|
||||||
federalStateSelect.addEventListener('change', () => {
|
|
||||||
if (lastFileText) analyseCsv(lastFileText);
|
|
||||||
});
|
|
||||||
|
|
||||||
exportButton.addEventListener('click', () => {
|
exportButton.addEventListener('click', () => {
|
||||||
if (lastResult) exportResultCsv(lastResult);
|
if (lastResult) exportResultCsv(lastResult);
|
||||||
@@ -49,6 +49,10 @@ dropZone.addEventListener('drop', event => {
|
|||||||
if (file) readFile(file);
|
if (file) readFile(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rerunAnalysis() {
|
||||||
|
if (lastFileText) analyseCsv(lastFileText);
|
||||||
|
}
|
||||||
|
|
||||||
function readFile(file) {
|
function readFile(file) {
|
||||||
hideError();
|
hideError();
|
||||||
|
|
||||||
@@ -67,161 +71,337 @@ function readFile(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function analyseCsv(text) {
|
function analyseCsv(text) {
|
||||||
try {
|
hideError();
|
||||||
const rows = parseCsv(text);
|
|
||||||
if (rows.length < 2) throw new Error('Die CSV enthält keine auswertbaren Daten.');
|
|
||||||
|
|
||||||
const headers = rows[0].map(h => h.trim().replace(/^\uFEFF/, ''));
|
const rows = parseCsv(text);
|
||||||
const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== ''));
|
if (rows.length < 2) {
|
||||||
|
showError('Die CSV-Datei enthält keine auswertbaren Daten.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = rows[0].map(normalizeHeader);
|
||||||
|
const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim() !== ''));
|
||||||
|
|
||||||
const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00');
|
const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00');
|
||||||
const federalState = federalStateSelect.value || 'NW';
|
const state = federalStateInput.value || 'NW';
|
||||||
const detectedFormat = detectFormat(headers);
|
|
||||||
|
|
||||||
let days;
|
const dailyRows = analyseDailyRows(headers, dataRows, targetMinutes, state);
|
||||||
if (detectedFormat === 'daily') {
|
if (!dailyRows.length) {
|
||||||
days = analyseDailyFormat(headers, dataRows, targetMinutes, federalState);
|
showError('Es wurden keine Arbeitstage mit auswertbarer Dauer gefunden.');
|
||||||
formatHint.textContent = `Format erkannt: Tagesauswertung. Montag bis Freitag haben Sollzeit, Samstag/Sonntag und Feiertage in ${stateLabel(federalState)} werden mit 0:00 Sollzeit berechnet. Zeilen ohne Arbeitszeit werden ignoriert.`;
|
return;
|
||||||
} else if (detectedFormat === 'monthly') {
|
|
||||||
days = analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState);
|
|
||||||
formatHint.textContent = `Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen. Wenn ein Datum erkannt wird, werden Wochenenden und Feiertage in ${stateLabel(federalState)} mit 0:00 Sollzeit berechnet.`;
|
|
||||||
} else {
|
|
||||||
throw new Error('Das CSV-Format wurde nicht erkannt. Benötigt wird entweder die Spalte "Tag" oder "Monat" plus "Dauer Arbeitstag".');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!days.length) throw new Error('Es wurden keine Arbeitszeiten in "Dauer Arbeitstag" gefunden.');
|
const monthlyRows = groupByMonth(dailyRows);
|
||||||
|
const totals = dailyRows.reduce((acc, row) => {
|
||||||
|
acc.days += row.workMinutes > 0 ? 1 : 0;
|
||||||
|
acc.work += row.workMinutes;
|
||||||
|
acc.target += row.targetMinutes;
|
||||||
|
acc.saldo += row.saldoMinutes;
|
||||||
|
acc.holidays += row.isHoliday ? 1 : 0;
|
||||||
|
return acc;
|
||||||
|
}, { days: 0, work: 0, target: 0, saldo: 0, holidays: 0 });
|
||||||
|
|
||||||
const months = groupByMonth(days);
|
lastResult = { dailyRows, monthlyRows, totals, state, version: APP_VERSION };
|
||||||
const totals = {
|
renderResult(lastResult);
|
||||||
days: days.length,
|
}
|
||||||
workMinutes: days.reduce((sum, day) => sum + day.workMinutes, 0),
|
|
||||||
targetMinutes: days.reduce((sum, day) => sum + day.targetMinutes, 0),
|
function analyseDailyRows(headers, dataRows, targetMinutes, state) {
|
||||||
saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0)
|
const tagIndex = headers.indexOf('tag');
|
||||||
|
const beginnIndex = headers.indexOf('arbeitstag beginn');
|
||||||
|
const endeIndex = headers.indexOf('arbeitstag ende');
|
||||||
|
const dauerIndex = headers.indexOf('dauer arbeitstag');
|
||||||
|
const pauseIndex = headers.indexOf('ruhepause eingehalten');
|
||||||
|
const warningIndex = headers.indexOf('warnungen');
|
||||||
|
|
||||||
|
if (tagIndex === -1 || dauerIndex === -1) {
|
||||||
|
throwCsvError('Das Tagesformat wurde nicht erkannt. Erwartet werden mindestens die Spalten "Tag" und "Dauer Arbeitstag".');
|
||||||
|
}
|
||||||
|
|
||||||
|
formatHint.textContent = `Erkanntes Format: Tagesauswertung | Feiertagsregel: ${stateLabel(state)} | Tool-Version: ${APP_VERSION}`;
|
||||||
|
|
||||||
|
return dataRows.map(row => {
|
||||||
|
const dateText = cleanCell(row[tagIndex]);
|
||||||
|
const date = parseGermanDate(dateText);
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
const durationText = cleanCell(row[dauerIndex]);
|
||||||
|
const workMinutes = extractDurationMinutes(durationText);
|
||||||
|
if (workMinutes === null) return null;
|
||||||
|
|
||||||
|
const weekdayNumber = date.getDay();
|
||||||
|
const isWeekend = weekdayNumber === 0 || weekdayNumber === 6;
|
||||||
|
const holidayName = getHolidayName(date, state);
|
||||||
|
const isHoliday = Boolean(holidayName);
|
||||||
|
const dayTargetMinutes = isWeekend || isHoliday ? 0 : targetMinutes;
|
||||||
|
const saldoMinutes = workMinutes - dayTargetMinutes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dateText,
|
||||||
|
monthKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||||||
|
monthLabel: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }),
|
||||||
|
weekday: weekdayLabel(date),
|
||||||
|
begin: cleanCell(row[beginnIndex]) || '-',
|
||||||
|
end: cleanCell(row[endeIndex]) || '-',
|
||||||
|
workMinutes,
|
||||||
|
targetMinutes: dayTargetMinutes,
|
||||||
|
saldoMinutes,
|
||||||
|
holidayName: holidayName || '-',
|
||||||
|
isHoliday,
|
||||||
|
isWeekend,
|
||||||
|
pause: cleanCell(row[pauseIndex]) || '-',
|
||||||
|
warnings: cleanCell(row[warningIndex]) || '-'
|
||||||
};
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) };
|
|
||||||
render(lastResult);
|
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectFormat(headers) {
|
function groupByMonth(dailyRows) {
|
||||||
const hasDuration = headers.includes('Dauer Arbeitstag');
|
const months = new Map();
|
||||||
if (!hasDuration) return 'unknown';
|
|
||||||
if (headers.includes('Tag')) return 'daily';
|
|
||||||
if (headers.includes('Monat')) return 'monthly';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function analyseDailyFormat(headers, dataRows, targetMinutes, federalState) {
|
dailyRows.forEach(row => {
|
||||||
const dateIndex = headers.indexOf('Tag');
|
if (!months.has(row.monthKey)) {
|
||||||
const startIndex = headers.indexOf('Arbeitstag Beginn');
|
months.set(row.monthKey, {
|
||||||
const endIndex = headers.indexOf('Arbeitstag Ende');
|
key: row.monthKey,
|
||||||
const durationIndex = headers.indexOf('Dauer Arbeitstag');
|
label: row.monthLabel,
|
||||||
const breakIndex = headers.indexOf('Ruhepause eingehalten');
|
days: 0,
|
||||||
const warningIndex = headers.indexOf('Warnungen');
|
holidays: 0,
|
||||||
|
work: 0,
|
||||||
const days = [];
|
target: 0,
|
||||||
for (const row of dataRows) {
|
saldo: 0
|
||||||
const date = (row[dateIndex] || '').trim();
|
|
||||||
const durationText = row[durationIndex] || '';
|
|
||||||
const workMinutes = extractSingleDuration(durationText);
|
|
||||||
|
|
||||||
// Wochenenden, Feiertage und nicht gearbeitete Tage stehen meist als "-" in Dauer Arbeitstag.
|
|
||||||
if (workMinutes === null) continue;
|
|
||||||
|
|
||||||
days.push({
|
|
||||||
date,
|
|
||||||
month: monthNameFromDate(date),
|
|
||||||
start: cleanCell(row[startIndex]),
|
|
||||||
end: cleanCell(row[endIndex]),
|
|
||||||
workMinutes,
|
|
||||||
weekday: weekdayNameFromDate(date),
|
|
||||||
holiday: holidayNameForDate(date, federalState),
|
|
||||||
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
|
|
||||||
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
|
|
||||||
breakOk: cleanCell(row[breakIndex]),
|
|
||||||
warnings: cleanCell(row[warningIndex])
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return days;
|
|
||||||
|
const month = months.get(row.monthKey);
|
||||||
|
month.days += row.workMinutes > 0 ? 1 : 0;
|
||||||
|
month.holidays += row.isHoliday ? 1 : 0;
|
||||||
|
month.work += row.workMinutes;
|
||||||
|
month.target += row.targetMinutes;
|
||||||
|
month.saldo += row.saldoMinutes;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...months.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) {
|
function renderResult(result) {
|
||||||
const monthIndex = headers.indexOf('Monat');
|
summary.classList.remove('hidden');
|
||||||
const durationIndex = headers.indexOf('Dauer Arbeitstag');
|
results.classList.remove('hidden');
|
||||||
const stampIndex = headers.indexOf('Stempelzeit');
|
|
||||||
|
|
||||||
const days = [];
|
document.getElementById('totalDays').textContent = String(result.totals.days);
|
||||||
for (const row of dataRows) {
|
document.getElementById('totalWork').textContent = minutesToHours(result.totals.work);
|
||||||
const month = row[monthIndex] || 'Unbekannter Monat';
|
document.getElementById('totalTarget').textContent = minutesToHours(result.totals.target);
|
||||||
const durations = extractDurations(row[durationIndex] || '');
|
|
||||||
const dates = stampIndex >= 0 ? extractDates(row[stampIndex] || '') : [];
|
|
||||||
|
|
||||||
durations.forEach((workMinutes, index) => {
|
const saldoElement = document.getElementById('totalSaldo');
|
||||||
const date = dates[index] || `Tag ${index + 1}`;
|
saldoElement.textContent = minutesToSignedHours(result.totals.saldo);
|
||||||
days.push({
|
saldoElement.className = saldoClass(result.totals.saldo);
|
||||||
date,
|
|
||||||
month,
|
monthTable.innerHTML = result.monthlyRows.map(month => `
|
||||||
start: '',
|
<tr>
|
||||||
end: '',
|
<td>${escapeHtml(month.label)}</td>
|
||||||
workMinutes,
|
<td>${month.days}</td>
|
||||||
weekday: weekdayNameFromDate(date),
|
<td>${month.holidays}</td>
|
||||||
holiday: holidayNameForDate(date, federalState),
|
<td>${minutesToHours(month.work)}</td>
|
||||||
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
|
<td>${minutesToHours(month.target)}</td>
|
||||||
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
|
<td class="${saldoClass(month.saldo)}">${minutesToSignedHours(month.saldo)}</td>
|
||||||
breakOk: '',
|
</tr>
|
||||||
warnings: ''
|
`).join('');
|
||||||
});
|
|
||||||
|
dayTable.innerHTML = result.dailyRows.map(row => `
|
||||||
|
<tr class="${row.isHoliday ? 'holiday-row' : row.isWeekend ? 'weekend-row' : ''}">
|
||||||
|
<td>${escapeHtml(row.dateText)}</td>
|
||||||
|
<td>${escapeHtml(row.weekday)}</td>
|
||||||
|
<td>${escapeHtml(row.begin)}</td>
|
||||||
|
<td>${escapeHtml(row.end)}</td>
|
||||||
|
<td>${minutesToHours(row.workMinutes)}</td>
|
||||||
|
<td>${minutesToHours(row.targetMinutes)}</td>
|
||||||
|
<td class="${saldoClass(row.saldoMinutes)}">${minutesToSignedHours(row.saldoMinutes)}</td>
|
||||||
|
<td>${escapeHtml(row.holidayName)}</td>
|
||||||
|
<td>${escapeHtml(row.pause)}</td>
|
||||||
|
<td>${escapeHtml(row.warnings)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportResultCsv(result) {
|
||||||
|
const lines = [
|
||||||
|
['Tool-Version', APP_VERSION],
|
||||||
|
['Bundesland', stateLabel(result.state)],
|
||||||
|
[],
|
||||||
|
['Datum', 'Wochentag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Feiertag', 'Ruhepause', 'Warnungen']
|
||||||
|
];
|
||||||
|
|
||||||
|
result.dailyRows.forEach(row => {
|
||||||
|
lines.push([
|
||||||
|
row.dateText,
|
||||||
|
row.weekday,
|
||||||
|
row.begin,
|
||||||
|
row.end,
|
||||||
|
minutesToHours(row.workMinutes),
|
||||||
|
minutesToHours(row.targetMinutes),
|
||||||
|
minutesToSignedHours(row.saldoMinutes),
|
||||||
|
row.holidayName,
|
||||||
|
row.pause,
|
||||||
|
row.warnings
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n');
|
||||||
|
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qplanner-arbeitszeit-auswertung-${APP_VERSION}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsv(text) {
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let cell = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && next === '"') {
|
||||||
|
cell += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
}
|
}
|
||||||
return days;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ',' && !inQuotes) {
|
||||||
|
row.push(cell);
|
||||||
|
cell = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||||
|
if (char === '\r' && next === '\n') i++;
|
||||||
|
row.push(cell);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
cell = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cell += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.length || row.length) {
|
||||||
|
row.push(cell);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDurationMinutes(text) {
|
||||||
function targetMinutesForDate(date, weekdayTargetMinutes, federalState) {
|
if (!text || text === '-') return null;
|
||||||
const weekday = getWeekdayIndex(date);
|
const match = text.match(/(?:=|^)(\d{1,3}):(\d{2})/);
|
||||||
// 0 = Sonntag, 6 = Samstag. Rufbereitschafts-/Wochenendarbeit zählt komplett als Pluszeit.
|
if (!match) return null;
|
||||||
if (weekday === 0 || weekday === 6) return 0;
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
if (holidayNameForDate(date, federalState)) return 0;
|
|
||||||
return weekdayTargetMinutes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGermanDate(value) {
|
||||||
|
const match = String(value || '').trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const day = Number(match[1]);
|
||||||
|
const month = Number(match[2]) - 1;
|
||||||
|
const year = Number(match[3]);
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(time) {
|
||||||
|
const [hours, minutes] = String(time || '08:00').split(':').map(Number);
|
||||||
|
return (hours || 0) * 60 + (minutes || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToHours(minutes) {
|
||||||
|
const abs = Math.abs(minutes);
|
||||||
|
const hours = Math.floor(abs / 60);
|
||||||
|
const mins = abs % 60;
|
||||||
|
return `${hours}:${String(mins).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToSignedHours(minutes) {
|
||||||
|
if (minutes === 0) return '±0:00';
|
||||||
|
return `${minutes > 0 ? '+' : '-'}${minutesToHours(minutes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saldoClass(minutes) {
|
||||||
|
if (minutes > 0) return 'positive';
|
||||||
|
if (minutes < 0) return 'negative';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanCell(value) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
return text === '' ? '-' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeader(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayLabel(date) {
|
||||||
|
return date.toLocaleDateString('de-DE', { weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
function stateLabel(state) {
|
function stateLabel(state) {
|
||||||
const labels = {
|
const labels = {
|
||||||
NW: 'Nordrhein-Westfalen',
|
NW: 'Nordrhein-Westfalen',
|
||||||
BY: 'Bayern',
|
BY: 'Bayern',
|
||||||
BY_MH: 'Bayern inkl. Mariä Himmelfahrt',
|
BY_MARIA: 'Bayern inkl. Mariä Himmelfahrt',
|
||||||
NI: 'Niedersachsen',
|
NI: 'Niedersachsen',
|
||||||
HH: 'Hamburg'
|
HH: 'Hamburg'
|
||||||
};
|
};
|
||||||
return labels[state] || 'Nordrhein-Westfalen';
|
return labels[state] || state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGermanDate(date) {
|
function getHolidayName(date, state) {
|
||||||
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
const year = date.getFullYear();
|
||||||
if (!match) return null;
|
const key = dateKey(date);
|
||||||
return {
|
const easter = easterSunday(year);
|
||||||
day: Number(match[1]),
|
|
||||||
month: Number(match[2]),
|
|
||||||
year: Number(match[3]),
|
|
||||||
key: `${match[3]}-${match[2]}-${match[1]}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateKeyFromDate(year, month, day) {
|
const holidays = new Map([
|
||||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
[`${year}-01-01`, 'Neujahr'],
|
||||||
}
|
[`${year}-05-01`, 'Tag der Arbeit'],
|
||||||
|
[`${year}-10-03`, 'Tag der Deutschen Einheit'],
|
||||||
|
[`${year}-12-25`, '1. Weihnachtstag'],
|
||||||
|
[`${year}-12-26`, '2. Weihnachtstag'],
|
||||||
|
[dateKey(addDays(easter, -2)), 'Karfreitag'],
|
||||||
|
[dateKey(addDays(easter, 1)), 'Ostermontag'],
|
||||||
|
[dateKey(addDays(easter, 39)), 'Christi Himmelfahrt'],
|
||||||
|
[dateKey(addDays(easter, 50)), 'Pfingstmontag']
|
||||||
|
]);
|
||||||
|
|
||||||
function addDaysKey(date, offset) {
|
if (state === 'BY' || state === 'BY_MARIA') {
|
||||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
holidays.set(`${year}-01-06`, 'Heilige Drei Könige');
|
||||||
return dateKeyFromDate(copy.getFullYear(), copy.getMonth() + 1, copy.getDate());
|
holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam');
|
||||||
|
holidays.set(`${year}-11-01`, 'Allerheiligen');
|
||||||
|
if (state === 'BY_MARIA') holidays.set(`${year}-08-15`, 'Mariä Himmelfahrt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'NW') {
|
||||||
|
holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam');
|
||||||
|
holidays.set(`${year}-11-01`, 'Allerheiligen');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'NI' || state === 'HH') {
|
||||||
|
holidays.set(`${year}-10-31`, 'Reformationstag');
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays.get(key) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function easterSunday(year) {
|
function easterSunday(year) {
|
||||||
// Gaußsche Osterformel für den gregorianischen Kalender.
|
|
||||||
const a = year % 19;
|
const a = year % 19;
|
||||||
const b = Math.floor(year / 100);
|
const b = Math.floor(year / 100);
|
||||||
const c = year % 100;
|
const c = year % 100;
|
||||||
@@ -234,246 +414,34 @@ function easterSunday(year) {
|
|||||||
const k = c % 4;
|
const k = c % 4;
|
||||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
const month = Math.floor((h + l - 7 * m + 114) / 31) - 1;
|
||||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||||
return new Date(year, month - 1, day);
|
return new Date(year, month, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
function holidaysForYear(year, federalState) {
|
function addDays(date, days) {
|
||||||
const easter = easterSunday(year);
|
const result = new Date(date);
|
||||||
const holidays = new Map();
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
const add = (month, day, name) => holidays.set(dateKeyFromDate(year, month, day), name);
|
|
||||||
const addRelative = (offset, name) => holidays.set(addDaysKey(easter, offset), name);
|
|
||||||
|
|
||||||
// Bundesweite Feiertage
|
|
||||||
add(1, 1, 'Neujahr');
|
|
||||||
addRelative(-2, 'Karfreitag');
|
|
||||||
addRelative(1, 'Ostermontag');
|
|
||||||
add(5, 1, 'Tag der Arbeit');
|
|
||||||
addRelative(39, 'Christi Himmelfahrt');
|
|
||||||
addRelative(50, 'Pfingstmontag');
|
|
||||||
add(10, 3, 'Tag der Deutschen Einheit');
|
|
||||||
add(12, 25, '1. Weihnachtstag');
|
|
||||||
add(12, 26, '2. Weihnachtstag');
|
|
||||||
|
|
||||||
if (federalState === 'BY' || federalState === 'BY_MH') {
|
|
||||||
add(1, 6, 'Heilige Drei Könige');
|
|
||||||
addRelative(60, 'Fronleichnam');
|
|
||||||
add(11, 1, 'Allerheiligen');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (federalState === 'BY_MH') {
|
|
||||||
add(8, 15, 'Mariä Himmelfahrt');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (federalState === 'NW') {
|
|
||||||
addRelative(60, 'Fronleichnam');
|
|
||||||
add(11, 1, 'Allerheiligen');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (federalState === 'NI' || federalState === 'HH') {
|
|
||||||
add(10, 31, 'Reformationstag');
|
|
||||||
}
|
|
||||||
|
|
||||||
return holidays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function holidayNameForDate(date, federalState) {
|
function dateKey(date) {
|
||||||
const parsed = parseGermanDate(date);
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
if (!parsed) return '';
|
|
||||||
return holidaysForYear(parsed.year, federalState).get(parsed.key) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekdayIndex(date) {
|
|
||||||
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const day = Number(match[1]);
|
|
||||||
const month = Number(match[2]) - 1;
|
|
||||||
const year = Number(match[3]);
|
|
||||||
return new Date(year, month, day).getDay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekdayNameFromDate(date) {
|
|
||||||
const names = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
|
||||||
const index = getWeekdayIndex(date);
|
|
||||||
return index === null ? '' : names[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByMonth(days) {
|
|
||||||
const map = new Map();
|
|
||||||
for (const day of days) {
|
|
||||||
const key = day.month || 'Unbekannt';
|
|
||||||
if (!map.has(key)) {
|
|
||||||
map.set(key, { month: key, days: 0, workMinutes: 0, targetMinutes: 0, saldoMinutes: 0 });
|
|
||||||
}
|
|
||||||
const month = map.get(key);
|
|
||||||
month.days += 1;
|
|
||||||
month.workMinutes += day.workMinutes;
|
|
||||||
month.targetMinutes += day.targetMinutes;
|
|
||||||
month.saldoMinutes += day.saldoMinutes;
|
|
||||||
}
|
|
||||||
return [...map.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCsv(text) {
|
|
||||||
const rows = [];
|
|
||||||
let row = [];
|
|
||||||
let value = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const char = text[i];
|
|
||||||
const next = text[i + 1];
|
|
||||||
|
|
||||||
if (char === '"') {
|
|
||||||
if (inQuotes && next === '"') {
|
|
||||||
value += '"';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === ',' && !inQuotes) {
|
|
||||||
row.push(value);
|
|
||||||
value = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((char === '\n' || char === '\r') && !inQuotes) {
|
|
||||||
if (char === '\r' && next === '\n') i++;
|
|
||||||
row.push(value);
|
|
||||||
rows.push(row);
|
|
||||||
row = [];
|
|
||||||
value = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
value += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length || row.length) {
|
|
||||||
row.push(value);
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDurations(text) {
|
|
||||||
const matches = [...text.matchAll(/=(\d{1,3}):(\d{2})/g)];
|
|
||||||
return matches.map(match => Number(match[1]) * 60 + Number(match[2]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSingleDuration(text) {
|
|
||||||
const match = String(text).match(/=(\d{1,3}):(\d{2})/);
|
|
||||||
if (!match) return null;
|
|
||||||
return Number(match[1]) * 60 + Number(match[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDates(text) {
|
|
||||||
return [...text.matchAll(/(\d{2}\.\d{2}\.\d{4})\s+\d{2}:\d{2}\s+-/g)].map(match => match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function monthNameFromDate(date) {
|
|
||||||
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
|
||||||
if (!match) return 'Unbekannt';
|
|
||||||
const names = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
|
||||||
const monthIndex = Number(match[2]) - 1;
|
|
||||||
return `${names[monthIndex] || 'Unbekannt'} ${match[3]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanCell(value) {
|
|
||||||
const text = String(value ?? '').trim();
|
|
||||||
return text === '-' ? '' : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeToMinutes(time) {
|
|
||||||
const [hours, minutes] = time.split(':').map(Number);
|
|
||||||
return hours * 60 + minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(minutes, signed = false) {
|
|
||||||
const sign = minutes < 0 ? '-' : signed && minutes > 0 ? '+' : signed ? '±' : '';
|
|
||||||
const abs = Math.abs(minutes);
|
|
||||||
const hours = Math.floor(abs / 60);
|
|
||||||
const mins = abs % 60;
|
|
||||||
return `${sign}${hours}:${String(mins).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saldoClass(minutes) {
|
|
||||||
if (minutes > 0) return 'positive';
|
|
||||||
if (minutes < 0) return 'negative';
|
|
||||||
return 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(result) {
|
|
||||||
hideError();
|
|
||||||
summary.classList.remove('hidden');
|
|
||||||
results.classList.remove('hidden');
|
|
||||||
|
|
||||||
document.getElementById('totalDays').textContent = result.totals.days;
|
|
||||||
document.getElementById('totalWork').textContent = formatDuration(result.totals.workMinutes);
|
|
||||||
document.getElementById('totalTarget').textContent = formatDuration(result.totals.targetMinutes);
|
|
||||||
|
|
||||||
const totalSaldo = document.getElementById('totalSaldo');
|
|
||||||
totalSaldo.textContent = formatDuration(result.totals.saldoMinutes, true);
|
|
||||||
totalSaldo.className = saldoClass(result.totals.saldoMinutes);
|
|
||||||
|
|
||||||
monthTable.innerHTML = result.months.map(month => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(month.month)}</td>
|
|
||||||
<td>${month.days}</td>
|
|
||||||
<td>${formatDuration(month.workMinutes)}</td>
|
|
||||||
<td>${formatDuration(month.targetMinutes)}</td>
|
|
||||||
<td class="${saldoClass(month.saldoMinutes)}">${formatDuration(month.saldoMinutes, true)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
dayTable.innerHTML = result.days.map(day => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(day.date)}</td>
|
|
||||||
<td>${escapeHtml(day.weekday || '')}</td>
|
|
||||||
<td>${escapeHtml(day.holiday || '')}</td>
|
|
||||||
<td>${escapeHtml(day.start || '')}</td>
|
|
||||||
<td>${escapeHtml(day.end || '')}</td>
|
|
||||||
<td>${formatDuration(day.workMinutes)}</td>
|
|
||||||
<td>${formatDuration(day.targetMinutes)}</td>
|
|
||||||
<td class="${saldoClass(day.saldoMinutes)}">${formatDuration(day.saldoMinutes, true)}</td>
|
|
||||||
<td>${escapeHtml(day.breakOk || '')}</td>
|
|
||||||
<td>${escapeHtml(day.warnings || '')}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportResultCsv(result) {
|
|
||||||
const rows = [
|
|
||||||
['Bereich', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Bundesland'],
|
|
||||||
['Gesamt', '', result.totals.days, formatDuration(result.totals.workMinutes), formatDuration(result.totals.targetMinutes), formatDuration(result.totals.saldoMinutes, true), result.federalStateLabel || ''],
|
|
||||||
[],
|
|
||||||
['Monat', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo'],
|
|
||||||
...result.months.map(m => ['Monat', m.month, m.days, formatDuration(m.workMinutes), formatDuration(m.targetMinutes), formatDuration(m.saldoMinutes, true)]),
|
|
||||||
[],
|
|
||||||
['Tag', 'Datum', 'Wochentag', 'Feiertag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Ruhepause', 'Warnungen'],
|
|
||||||
...result.days.map(d => ['Tag', d.date, d.weekday || '', d.holiday || '', d.start, d.end, formatDuration(d.workMinutes), formatDuration(d.targetMinutes), formatDuration(d.saldoMinutes, true), d.breakOk, d.warnings])
|
|
||||||
];
|
|
||||||
|
|
||||||
const csv = rows.map(row => row.map(csvEscape).join(';')).join('\n');
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = 'arbeitszeit-auswertung.csv';
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function csvEscape(value) {
|
function csvEscape(value) {
|
||||||
const text = String(value ?? '');
|
const text = String(value ?? '');
|
||||||
if (/[;"\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
return text;
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>'"]/g, char => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"'
|
||||||
|
}[char]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
@@ -488,11 +456,7 @@ function hideError() {
|
|||||||
errorBox.classList.add('hidden');
|
errorBox.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function throwCsvError(message) {
|
||||||
return String(value)
|
showError(message);
|
||||||
.replace(/&/g, '&')
|
throw new Error(message);
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-27
@@ -3,72 +3,100 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Arbeitszeit-Auswertung</title>
|
<title>qPlanner Arbeitszeit-Auswertung</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-icon" aria-hidden="true">⌚</span>
|
||||||
|
<div>
|
||||||
|
<strong>qPlanner</strong>
|
||||||
|
<span>Arbeitszeit-Auswertung</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="app">
|
<main class="app">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div class="hero-content">
|
||||||
<p class="eyebrow">CSV-Auswertung</p>
|
<p class="eyebrow">qPlanner CSV-Auswertung</p>
|
||||||
<h1>Arbeitszeit-Saldo berechnen</h1>
|
<h1>Arbeitszeit-Saldo berechnen</h1>
|
||||||
<p class="intro">CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen: Montag bis Freitag mit 8:00 Stunden Sollzeit, Samstag/Sonntag und gesetzliche Feiertage mit 0:00 Sollzeit.</p>
|
<p class="intro">CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen. Samstage, Sonntage und gesetzliche Feiertage im gewählten Bundesland werden mit 0:00 Stunden Sollzeit bewertet.</p>
|
||||||
</div>
|
|
||||||
<div class="settings" aria-label="Einstellungen">
|
<section class="settings" aria-label="Einstellungen">
|
||||||
<label>
|
<label class="setting-card">
|
||||||
Sollzeit pro Arbeitstag
|
<span class="setting-icon clock" aria-hidden="true">◷</span>
|
||||||
|
<span class="setting-content">
|
||||||
|
<span class="setting-label">Sollzeit pro Arbeitstag (Mo–Fr)</span>
|
||||||
<input id="targetTime" type="time" value="08:00" step="60">
|
<input id="targetTime" type="time" value="08:00" step="60">
|
||||||
<small>Gilt für Montag bis Freitag. Samstag/Sonntag und Feiertage = 0:00.</small>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="setting-card">
|
||||||
Bundesland / Feiertage
|
<span class="setting-icon location" aria-hidden="true">⌖</span>
|
||||||
|
<span class="setting-content">
|
||||||
|
<span class="setting-label">Bundesland / Feiertagsregel</span>
|
||||||
<select id="federalState">
|
<select id="federalState">
|
||||||
<option value="NW" selected>Nordrhein-Westfalen</option>
|
<option value="NW">Nordrhein-Westfalen</option>
|
||||||
<option value="BY">Bayern</option>
|
<option value="BY">Bayern</option>
|
||||||
<option value="BY_MH">Bayern inkl. Mariä Himmelfahrt</option>
|
<option value="BY_MARIA">Bayern inkl. Mariä Himmelfahrt</option>
|
||||||
<option value="NI">Niedersachsen</option>
|
<option value="NI">Niedersachsen</option>
|
||||||
<option value="HH">Hamburg</option>
|
<option value="HH">Hamburg</option>
|
||||||
</select>
|
</select>
|
||||||
<small>Feiertage werden automatisch aus dem Datum berechnet.</small>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="dropZone" class="drop-zone">
|
<section id="dropZone" class="drop-zone" aria-label="CSV-Datei hochladen">
|
||||||
<input id="csvFile" type="file" accept=".csv,text/csv">
|
<input id="csvFile" type="file" accept=".csv,text/csv">
|
||||||
<div class="drop-content">
|
<div class="drop-content">
|
||||||
|
<span class="upload-icon" aria-hidden="true">☁</span>
|
||||||
<strong>CSV-Datei hier ablegen</strong>
|
<strong>CSV-Datei hier ablegen</strong>
|
||||||
<span>oder Datei auswählen</span>
|
<span>oder klicken zum Auswählen</span>
|
||||||
|
<em>Unterstützt: .csv Dateien</em>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
<section id="formatHint" class="hint"></section>
|
|
||||||
|
|
||||||
<section id="errorBox" class="error hidden"></section>
|
<section id="errorBox" class="error hidden"></section>
|
||||||
|
<p id="formatHint" class="format-hint"></p>
|
||||||
|
|
||||||
<section id="summary" class="summary hidden" aria-live="polite">
|
<section id="summary" class="summary hidden" aria-live="polite">
|
||||||
<article class="card">
|
<article class="stat-card stat-days">
|
||||||
|
<span class="stat-icon" aria-hidden="true">▣</span>
|
||||||
<span>Arbeitstage</span>
|
<span>Arbeitstage</span>
|
||||||
<strong id="totalDays">0</strong>
|
<strong id="totalDays">0</strong>
|
||||||
|
<small>Tage mit Arbeitszeit</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="stat-card stat-work">
|
||||||
|
<span class="stat-icon" aria-hidden="true">◷</span>
|
||||||
<span>Arbeitszeit gesamt</span>
|
<span>Arbeitszeit gesamt</span>
|
||||||
<strong id="totalWork">0:00</strong>
|
<strong id="totalWork">0:00</strong>
|
||||||
|
<small>Stunden</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="card">
|
<article class="stat-card stat-target">
|
||||||
|
<span class="stat-icon" aria-hidden="true">◎</span>
|
||||||
<span>Sollzeit gesamt</span>
|
<span>Sollzeit gesamt</span>
|
||||||
<strong id="totalTarget">0:00</strong>
|
<strong id="totalTarget">0:00</strong>
|
||||||
|
<small>Stunden</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="card saldo-card">
|
<article class="stat-card stat-saldo">
|
||||||
|
<span class="stat-icon" aria-hidden="true">↗</span>
|
||||||
<span>Saldo gesamt</span>
|
<span>Saldo gesamt</span>
|
||||||
<strong id="totalSaldo">±0:00</strong>
|
<strong id="totalSaldo">±0:00</strong>
|
||||||
|
<small>Stunden</small>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="results" class="results hidden">
|
<section id="results" class="results hidden">
|
||||||
|
<div class="panel table-panel">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
|
<div class="section-title">
|
||||||
|
<span aria-hidden="true">▮</span>
|
||||||
<h2>Monatsübersicht</h2>
|
<h2>Monatsübersicht</h2>
|
||||||
<button id="exportCsv" type="button">Ergebnis als CSV exportieren</button>
|
</div>
|
||||||
|
<button id="exportCsv" type="button">⇩ Ergebnis als CSV exportieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -76,6 +104,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Monat</th>
|
<th>Monat</th>
|
||||||
<th>Arbeitstage</th>
|
<th>Arbeitstage</th>
|
||||||
|
<th>Feiertage</th>
|
||||||
<th>Arbeitszeit</th>
|
<th>Arbeitszeit</th>
|
||||||
<th>Sollzeit</th>
|
<th>Sollzeit</th>
|
||||||
<th>Saldo</th>
|
<th>Saldo</th>
|
||||||
@@ -84,21 +113,25 @@
|
|||||||
<tbody id="monthTable"></tbody>
|
<tbody id="monthTable"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="details">
|
<details class="panel details" open>
|
||||||
<summary>Tagesdetails anzeigen</summary>
|
<summary>
|
||||||
|
<span class="section-title"><span aria-hidden="true">☷</span><strong>Tagesdetails</strong></span>
|
||||||
|
<span class="summary-hint">ein-/ausblenden</span>
|
||||||
|
</summary>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
<th>Wochentag</th>
|
<th>Wochentag</th>
|
||||||
<th>Feiertag</th>
|
|
||||||
<th>Beginn</th>
|
<th>Beginn</th>
|
||||||
<th>Ende</th>
|
<th>Ende</th>
|
||||||
<th>Arbeitszeit</th>
|
<th>Arbeitszeit</th>
|
||||||
<th>Sollzeit</th>
|
<th>Sollzeit</th>
|
||||||
<th>Saldo</th>
|
<th>Saldo</th>
|
||||||
|
<th>Feiertag</th>
|
||||||
<th>Ruhepause</th>
|
<th>Ruhepause</th>
|
||||||
<th>Warnungen</th>
|
<th>Warnungen</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -110,6 +143,12 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<span id="appVersion">v0.42.0</span>
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<a href="https://git.mike-lindner.net/mike/qplanner-arbeitszeit-auswertung" target="_blank" rel="noopener noreferrer">Repository ↗</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,97 +1,243 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--bg: #f5f8fd;
|
||||||
color: #172033;
|
--surface: rgba(255, 255, 255, .92);
|
||||||
background: #f3f6fb;
|
--surface-solid: #ffffff;
|
||||||
|
--text: #0f1f3d;
|
||||||
|
--muted: #5c6b84;
|
||||||
|
--line: #dbe5f2;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--blue-soft: #eaf2ff;
|
||||||
|
--green: #119b6b;
|
||||||
|
--green-soft: #e7f8f1;
|
||||||
|
--orange: #f28c18;
|
||||||
|
--orange-soft: #fff3df;
|
||||||
|
--red: #dc2626;
|
||||||
|
--red-soft: #fff1f2;
|
||||||
|
--shadow: 0 18px 48px rgba(29, 52, 92, .10);
|
||||||
|
--shadow-soft: 0 10px 26px rgba(29, 52, 92, .07);
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(37, 99, 235, .10), transparent 34rem),
|
||||||
|
radial-gradient(circle at 72% 12%, rgba(17, 155, 107, .09), transparent 24rem),
|
||||||
|
linear-gradient(180deg, #fbfdff 0%, var(--bg) 55%, #f8fbff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(37, 99, 235, .035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(37, 99, 235, .035) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
mask-image: linear-gradient(to bottom, black, transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: rgba(255, 255, 255, .78);
|
||||||
|
border-bottom: 1px solid rgba(219, 229, 242, .9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
width: min(1360px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--blue-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, .13);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span:last-child {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
width: min(1120px, calc(100% - 32px));
|
position: relative;
|
||||||
|
width: min(1360px, calc(100% - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 40px 0;
|
padding: 44px 0 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(0, 1.25fr) minmax(360px, .75fr);
|
||||||
gap: 24px;
|
gap: 32px;
|
||||||
align-items: end;
|
align-items: stretch;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 12px;
|
||||||
color: #566277;
|
color: var(--blue);
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: .08em;
|
letter-spacing: .10em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: .8rem;
|
font-size: .78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
max-width: 760px;
|
||||||
|
font-size: clamp(2.35rem, 5vw, 4.2rem);
|
||||||
|
line-height: .98;
|
||||||
|
letter-spacing: -.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intro {
|
.intro {
|
||||||
max-width: 700px;
|
max-width: 770px;
|
||||||
color: #566277;
|
margin: 20px 0 30px;
|
||||||
line-height: 1.6;
|
color: var(--muted);
|
||||||
|
line-height: 1.75;
|
||||||
|
font-size: 1.04rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings {
|
.settings {
|
||||||
background: white;
|
|
||||||
border: 1px solid #dde5f2;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 16px;
|
|
||||||
min-width: 280px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
grid-template-columns: repeat(2, minmax(250px, 1fr));
|
||||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
gap: 18px;
|
||||||
|
max-width: 820px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings label {
|
.setting-card,
|
||||||
|
.stat-card,
|
||||||
|
.panel,
|
||||||
|
.drop-zone {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(219, 229, 242, .95);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-card {
|
||||||
|
min-height: 124px;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-icon,
|
||||||
|
.stat-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-icon.clock,
|
||||||
|
.stat-days .stat-icon { color: var(--blue); background: var(--blue-soft); }
|
||||||
|
.setting-icon.location,
|
||||||
|
.stat-work .stat-icon,
|
||||||
|
.stat-saldo .stat-icon { color: var(--green); background: var(--green-soft); }
|
||||||
|
.stat-target .stat-icon { color: var(--orange); background: var(--orange-soft); }
|
||||||
|
|
||||||
|
.setting-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: .9rem;
|
width: 100%;
|
||||||
color: #566277;
|
}
|
||||||
|
|
||||||
|
.setting-label,
|
||||||
|
.stat-card > span:not(.stat-icon) {
|
||||||
|
color: #31415d;
|
||||||
|
font-size: .92rem;
|
||||||
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings input,
|
.settings input,
|
||||||
.settings select {
|
.settings select {
|
||||||
border: 1px solid #cbd5e1;
|
width: 100%;
|
||||||
border-radius: 10px;
|
border: 1px solid #cfd9e8;
|
||||||
padding: 10px 12px;
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: #172033;
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(15, 31, 61, .03);
|
||||||
|
transition: border-color .18s ease, box-shadow .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings small {
|
.settings input:focus,
|
||||||
color: #566277;
|
.settings select:focus {
|
||||||
line-height: 1.4;
|
border-color: rgba(37, 99, 235, .6);
|
||||||
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, .10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
min-height: 190px;
|
min-height: 302px;
|
||||||
border: 2px dashed #9aa8bd;
|
border: 2px dashed rgba(37, 99, 235, .48);
|
||||||
border-radius: 24px;
|
border-radius: 28px;
|
||||||
background: white;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: .2s ease;
|
overflow: hidden;
|
||||||
|
transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drop-zone::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: radial-gradient(circle, rgba(37, 99, 235, .12), transparent 52%);
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover,
|
||||||
.drop-zone.dragover {
|
.drop-zone.dragover {
|
||||||
border-color: #2f6fed;
|
transform: translateY(-2px);
|
||||||
background: #eef4ff;
|
border-color: var(--blue);
|
||||||
|
background: rgba(234, 242, 255, .84);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone input {
|
.drop-zone input {
|
||||||
@@ -99,60 +245,131 @@ h1 {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-content {
|
.drop-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #566277;
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 28px;
|
||||||
|
color: var(--blue);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
font-size: 2.8rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-content strong {
|
.drop-content strong {
|
||||||
color: #172033;
|
color: var(--text);
|
||||||
font-size: 1.25rem;
|
font-size: 1.15rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-content em {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--red-soft);
|
||||||
|
color: #be123c;
|
||||||
|
border: 1px solid #fecdd3;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.format-hint {
|
||||||
|
min-height: 26px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .94rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary {
|
.summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.stat-card {
|
||||||
background: white;
|
position: relative;
|
||||||
border: 1px solid #dde5f2;
|
min-height: 146px;
|
||||||
border-radius: 20px;
|
border-radius: 22px;
|
||||||
padding: 20px;
|
padding: 22px;
|
||||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
display: grid;
|
||||||
|
grid-template-columns: 64px 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon label"
|
||||||
|
"icon value"
|
||||||
|
"icon hint";
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card span {
|
.stat-card::after {
|
||||||
display: block;
|
content: "";
|
||||||
color: #566277;
|
position: absolute;
|
||||||
font-size: .9rem;
|
left: 0;
|
||||||
margin-bottom: 8px;
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card strong {
|
.stat-work::after,
|
||||||
display: block;
|
.stat-saldo::after { background: var(--green); }
|
||||||
font-size: 1.8rem;
|
.stat-target::after { background: var(--orange); }
|
||||||
|
|
||||||
|
.stat-icon { grid-area: icon; }
|
||||||
|
.stat-card > span:not(.stat-icon) { grid-area: label; }
|
||||||
|
.stat-card strong {
|
||||||
|
grid-area: value;
|
||||||
|
font-size: clamp(1.7rem, 3vw, 2.25rem);
|
||||||
|
letter-spacing: -.04em;
|
||||||
|
}
|
||||||
|
.stat-card small {
|
||||||
|
grid-area: hint;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.positive { color: #137333; }
|
.stat-saldo strong.positive { color: var(--green); }
|
||||||
.negative { color: #b3261e; }
|
.stat-saldo strong.negative { color: var(--red); }
|
||||||
.neutral { color: #172033; }
|
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
background: white;
|
display: grid;
|
||||||
border: 1px solid #dde5f2;
|
grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr);
|
||||||
border-radius: 24px;
|
gap: 18px;
|
||||||
padding: 24px;
|
align-items: start;
|
||||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header {
|
.panel {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header,
|
||||||
|
.details summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -160,74 +377,162 @@ h1 {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header h2 { margin: 0; }
|
.section-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title > span {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--blue-soft);
|
||||||
|
color: var(--blue);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header h2,
|
||||||
|
.details summary strong {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: 0;
|
border: 1px solid rgba(37, 99, 235, .18);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
background: #172033;
|
|
||||||
color: white;
|
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--blue);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-weight: 750;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: var(--blue-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
margin: 0 -22px -22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
min-width: 680px;
|
font-size: .93rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th,
|
||||||
padding: 12px 10px;
|
td {
|
||||||
border-bottom: 1px solid #e6edf7;
|
padding: 14px 22px;
|
||||||
|
border-bottom: 1px solid #e8eef6;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
color: #566277;
|
color: #3c4a63;
|
||||||
font-size: .85rem;
|
font-size: .82rem;
|
||||||
text-transform: uppercase;
|
font-weight: 850;
|
||||||
letter-spacing: .04em;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: rgba(234, 242, 255, .55);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.holiday-row td {
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.weekend-row td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive { color: var(--green); font-weight: 850; }
|
||||||
|
.negative { color: var(--red); font-weight: 850; }
|
||||||
|
.neutral { color: var(--muted); font-weight: 850; }
|
||||||
|
|
||||||
.details {
|
.details {
|
||||||
margin-top: 24px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details summary {
|
.details summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
list-style: none;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.details summary::-webkit-details-marker { display: none; }
|
||||||
margin-top: 16px;
|
|
||||||
padding: 14px 16px;
|
.summary-hint {
|
||||||
border-radius: 14px;
|
color: var(--muted);
|
||||||
background: #fff1f0;
|
border: 1px solid var(--line);
|
||||||
border: 1px solid #ffccc7;
|
border-radius: 12px;
|
||||||
color: #8a1f11;
|
padding: 8px 10px;
|
||||||
|
font-size: .86rem;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden { display: none; }
|
.app-footer {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 30;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255, 255, 255, .78);
|
||||||
|
border: 1px solid rgba(219, 229, 242, .9);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 8px 22px rgba(29, 52, 92, .08);
|
||||||
|
font-size: .82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.hero,
|
||||||
|
.results { grid-template-columns: 1fr; }
|
||||||
|
.drop-zone { min-height: 240px; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.hero { display: grid; align-items: start; }
|
.app { padding-top: 28px; }
|
||||||
|
.settings,
|
||||||
.summary { grid-template-columns: 1fr 1fr; }
|
.summary { grid-template-columns: 1fr 1fr; }
|
||||||
|
.brand { height: 62px; }
|
||||||
|
.hero { gap: 22px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 620px) {
|
||||||
|
.app,
|
||||||
|
.brand { width: min(100% - 22px, 1360px); }
|
||||||
|
.settings,
|
||||||
.summary { grid-template-columns: 1fr; }
|
.summary { grid-template-columns: 1fr; }
|
||||||
}
|
.setting-card,
|
||||||
|
.stat-card { border-radius: 18px; }
|
||||||
.hint {
|
.table-header,
|
||||||
color: #566277;
|
.details summary { align-items: flex-start; flex-direction: column; }
|
||||||
margin: 16px 0 0;
|
.app-footer { left: 12px; right: 12px; justify-content: center; }
|
||||||
font-size: .95rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user