Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c24b4d71d |
+15
-12
@@ -1,17 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
- Bundesland-Auswahl für Feiertage ergänzt
|
||||
- Unterstützte Bundesländer: Bayern, Niedersachsen, Hamburg, Nordrhein-Westfalen
|
||||
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
|
||||
- Feiertage werden automatisch aus dem Datum berechnet
|
||||
- Feiertagslogik für Nordrhein-Westfalen, Bayern, Niedersachsen und Hamburg ergänzt
|
||||
- Zusatzoption Bayern inkl. Mariä Himmelfahrt
|
||||
- Feiertage werden mit 0:00 Sollzeit bewertet
|
||||
- Arbeit an Feiertagen zählt vollständig als Pluszeit
|
||||
- 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
|
||||
- Tagesdetails und Export um Feiertagsinformationen ergänzt
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden.
|
||||
|
||||
## Version
|
||||
|
||||
Aktueller Stand: v0.41.1
|
||||
|
||||
## Funktionen
|
||||
|
||||
- CSV-Upload im Browser
|
||||
@@ -13,8 +17,12 @@ Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- un
|
||||
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
|
||||
- Monatsübersicht
|
||||
- Tagesdetails inklusive Wochentag und Feiertag
|
||||
- CSV-Export
|
||||
- CSV-Export inklusive Tool-Version
|
||||
|
||||
## Datenschutz
|
||||
|
||||
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.41.1';
|
||||
|
||||
const fileInput = document.getElementById('csvFile');
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const targetTimeInput = document.getElementById('targetTime');
|
||||
const federalStateSelect = document.getElementById('federalState');
|
||||
const federalStateInput = document.getElementById('federalState');
|
||||
const errorBox = document.getElementById('errorBox');
|
||||
const summary = document.getElementById('summary');
|
||||
const results = document.getElementById('results');
|
||||
@@ -9,6 +11,9 @@ const monthTable = document.getElementById('monthTable');
|
||||
const dayTable = document.getElementById('dayTable');
|
||||
const exportButton = document.getElementById('exportCsv');
|
||||
const formatHint = document.getElementById('formatHint');
|
||||
const appVersion = document.getElementById('appVersion');
|
||||
|
||||
if (appVersion) appVersion.textContent = APP_VERSION;
|
||||
|
||||
let lastResult = null;
|
||||
let lastFileText = null;
|
||||
@@ -18,13 +23,8 @@ fileInput.addEventListener('change', event => {
|
||||
if (file) readFile(file);
|
||||
});
|
||||
|
||||
targetTimeInput.addEventListener('change', () => {
|
||||
if (lastFileText) analyseCsv(lastFileText);
|
||||
});
|
||||
|
||||
federalStateSelect.addEventListener('change', () => {
|
||||
if (lastFileText) analyseCsv(lastFileText);
|
||||
});
|
||||
targetTimeInput.addEventListener('change', rerunAnalysis);
|
||||
federalStateInput.addEventListener('change', rerunAnalysis);
|
||||
|
||||
exportButton.addEventListener('click', () => {
|
||||
if (lastResult) exportResultCsv(lastResult);
|
||||
@@ -49,6 +49,10 @@ dropZone.addEventListener('drop', event => {
|
||||
if (file) readFile(file);
|
||||
});
|
||||
|
||||
function rerunAnalysis() {
|
||||
if (lastFileText) analyseCsv(lastFileText);
|
||||
}
|
||||
|
||||
function readFile(file) {
|
||||
hideError();
|
||||
|
||||
@@ -67,161 +71,337 @@ function readFile(file) {
|
||||
}
|
||||
|
||||
function analyseCsv(text) {
|
||||
try {
|
||||
const rows = parseCsv(text);
|
||||
if (rows.length < 2) throw new Error('Die CSV enthält keine auswertbaren Daten.');
|
||||
hideError();
|
||||
|
||||
const headers = rows[0].map(h => h.trim().replace(/^\uFEFF/, ''));
|
||||
const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== ''));
|
||||
const rows = parseCsv(text);
|
||||
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 federalState = federalStateSelect.value || 'NW';
|
||||
const detectedFormat = detectFormat(headers);
|
||||
const state = federalStateInput.value || 'NW';
|
||||
|
||||
let days;
|
||||
if (detectedFormat === 'daily') {
|
||||
days = analyseDailyFormat(headers, dataRows, targetMinutes, federalState);
|
||||
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.`;
|
||||
} 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".');
|
||||
const dailyRows = analyseDailyRows(headers, dataRows, targetMinutes, state);
|
||||
if (!dailyRows.length) {
|
||||
showError('Es wurden keine Arbeitstage mit auswertbarer Dauer gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
const totals = {
|
||||
days: days.length,
|
||||
workMinutes: days.reduce((sum, day) => sum + day.workMinutes, 0),
|
||||
targetMinutes: days.reduce((sum, day) => sum + day.targetMinutes, 0),
|
||||
saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0)
|
||||
lastResult = { dailyRows, monthlyRows, totals, state, version: APP_VERSION };
|
||||
renderResult(lastResult);
|
||||
}
|
||||
|
||||
function analyseDailyRows(headers, dataRows, targetMinutes, state) {
|
||||
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]) || '-'
|
||||
};
|
||||
|
||||
lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) };
|
||||
render(lastResult);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function detectFormat(headers) {
|
||||
const hasDuration = headers.includes('Dauer Arbeitstag');
|
||||
if (!hasDuration) return 'unknown';
|
||||
if (headers.includes('Tag')) return 'daily';
|
||||
if (headers.includes('Monat')) return 'monthly';
|
||||
return 'unknown';
|
||||
}
|
||||
function groupByMonth(dailyRows) {
|
||||
const months = new Map();
|
||||
|
||||
function analyseDailyFormat(headers, dataRows, targetMinutes, federalState) {
|
||||
const dateIndex = headers.indexOf('Tag');
|
||||
const startIndex = headers.indexOf('Arbeitstag Beginn');
|
||||
const endIndex = headers.indexOf('Arbeitstag Ende');
|
||||
const durationIndex = headers.indexOf('Dauer Arbeitstag');
|
||||
const breakIndex = headers.indexOf('Ruhepause eingehalten');
|
||||
const warningIndex = headers.indexOf('Warnungen');
|
||||
|
||||
const days = [];
|
||||
for (const row of dataRows) {
|
||||
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])
|
||||
dailyRows.forEach(row => {
|
||||
if (!months.has(row.monthKey)) {
|
||||
months.set(row.monthKey, {
|
||||
key: row.monthKey,
|
||||
label: row.monthLabel,
|
||||
days: 0,
|
||||
holidays: 0,
|
||||
work: 0,
|
||||
target: 0,
|
||||
saldo: 0
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) {
|
||||
const monthIndex = headers.indexOf('Monat');
|
||||
const durationIndex = headers.indexOf('Dauer Arbeitstag');
|
||||
const stampIndex = headers.indexOf('Stempelzeit');
|
||||
|
||||
const days = [];
|
||||
for (const row of dataRows) {
|
||||
const month = row[monthIndex] || 'Unbekannter Monat';
|
||||
const durations = extractDurations(row[durationIndex] || '');
|
||||
const dates = stampIndex >= 0 ? extractDates(row[stampIndex] || '') : [];
|
||||
|
||||
durations.forEach((workMinutes, index) => {
|
||||
const date = dates[index] || `Tag ${index + 1}`;
|
||||
days.push({
|
||||
date,
|
||||
month,
|
||||
start: '',
|
||||
end: '',
|
||||
workMinutes,
|
||||
weekday: weekdayNameFromDate(date),
|
||||
holiday: holidayNameForDate(date, federalState),
|
||||
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
|
||||
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
|
||||
breakOk: '',
|
||||
warnings: ''
|
||||
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 renderResult(result) {
|
||||
summary.classList.remove('hidden');
|
||||
results.classList.remove('hidden');
|
||||
|
||||
document.getElementById('totalDays').textContent = String(result.totals.days);
|
||||
document.getElementById('totalWork').textContent = minutesToHours(result.totals.work);
|
||||
document.getElementById('totalTarget').textContent = minutesToHours(result.totals.target);
|
||||
|
||||
const saldoElement = document.getElementById('totalSaldo');
|
||||
saldoElement.textContent = minutesToSignedHours(result.totals.saldo);
|
||||
saldoElement.className = saldoClass(result.totals.saldo);
|
||||
|
||||
monthTable.innerHTML = result.monthlyRows.map(month => `
|
||||
<tr>
|
||||
<td>${escapeHtml(month.label)}</td>
|
||||
<td>${month.days}</td>
|
||||
<td>${month.holidays}</td>
|
||||
<td>${minutesToHours(month.work)}</td>
|
||||
<td>${minutesToHours(month.target)}</td>
|
||||
<td class="${saldoClass(month.saldo)}">${minutesToSignedHours(month.saldo)}</td>
|
||||
</tr>
|
||||
`).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
|
||||
]);
|
||||
});
|
||||
}
|
||||
return days;
|
||||
|
||||
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;
|
||||
|
||||
function targetMinutesForDate(date, weekdayTargetMinutes, federalState) {
|
||||
const weekday = getWeekdayIndex(date);
|
||||
// 0 = Sonntag, 6 = Samstag. Rufbereitschafts-/Wochenendarbeit zählt komplett als Pluszeit.
|
||||
if (weekday === 0 || weekday === 6) return 0;
|
||||
if (holidayNameForDate(date, federalState)) return 0;
|
||||
return weekdayTargetMinutes;
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
if (!text || text === '-') return null;
|
||||
const match = text.match(/(?:=|^)(\d{1,3}):(\d{2})/);
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
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) {
|
||||
const labels = {
|
||||
NW: 'Nordrhein-Westfalen',
|
||||
BY: 'Bayern',
|
||||
BY_MH: 'Bayern inkl. Mariä Himmelfahrt',
|
||||
BY_MARIA: 'Bayern inkl. Mariä Himmelfahrt',
|
||||
NI: 'Niedersachsen',
|
||||
HH: 'Hamburg'
|
||||
};
|
||||
return labels[state] || 'Nordrhein-Westfalen';
|
||||
return labels[state] || state;
|
||||
}
|
||||
|
||||
function parseGermanDate(date) {
|
||||
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
day: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
year: Number(match[3]),
|
||||
key: `${match[3]}-${match[2]}-${match[1]}`
|
||||
};
|
||||
function getHolidayName(date, state) {
|
||||
const year = date.getFullYear();
|
||||
const key = dateKey(date);
|
||||
const easter = easterSunday(year);
|
||||
|
||||
const holidays = new Map([
|
||||
[`${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']
|
||||
]);
|
||||
|
||||
if (state === 'BY' || state === 'BY_MARIA') {
|
||||
holidays.set(`${year}-01-06`, 'Heilige Drei Könige');
|
||||
holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam');
|
||||
holidays.set(`${year}-11-01`, 'Allerheiligen');
|
||||
if (state === 'BY_MARIA') holidays.set(`${year}-08-15`, 'Mariä Himmelfahrt');
|
||||
}
|
||||
|
||||
function dateKeyFromDate(year, month, day) {
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
if (state === 'NW') {
|
||||
holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam');
|
||||
holidays.set(`${year}-11-01`, 'Allerheiligen');
|
||||
}
|
||||
|
||||
function addDaysKey(date, offset) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||
return dateKeyFromDate(copy.getFullYear(), copy.getMonth() + 1, copy.getDate());
|
||||
if (state === 'NI' || state === 'HH') {
|
||||
holidays.set(`${year}-10-31`, 'Reformationstag');
|
||||
}
|
||||
|
||||
return holidays.get(key) || null;
|
||||
}
|
||||
|
||||
function easterSunday(year) {
|
||||
// Gaußsche Osterformel für den gregorianischen Kalender.
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
@@ -234,246 +414,34 @@ function easterSunday(year) {
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
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;
|
||||
return new Date(year, month - 1, day);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
function holidaysForYear(year, federalState) {
|
||||
const easter = easterSunday(year);
|
||||
const holidays = new Map();
|
||||
|
||||
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');
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
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) {
|
||||
const parsed = parseGermanDate(date);
|
||||
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 dateKey(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function csvEscape(value) {
|
||||
const text = String(value ?? '');
|
||||
if (/[;"\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||
return text;
|
||||
return `"${text.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, char => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"'
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
@@ -488,11 +456,7 @@ function hideError() {
|
||||
errorBox.classList.add('hidden');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
function throwCsvError(message) {
|
||||
showError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
+21
-14
@@ -3,35 +3,34 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">CSV-Auswertung</p>
|
||||
<p class="eyebrow">qPlanner CSV-Auswertung</p>
|
||||
<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>
|
||||
|
||||
<section class="settings" aria-label="Einstellungen">
|
||||
<label>
|
||||
Sollzeit pro Arbeitstag
|
||||
Sollzeit pro Arbeitstag Montag bis Freitag
|
||||
<input id="targetTime" type="time" value="08:00" step="60">
|
||||
<small>Gilt für Montag bis Freitag. Samstag/Sonntag und Feiertage = 0:00.</small>
|
||||
</label>
|
||||
<label>
|
||||
Bundesland / Feiertage
|
||||
Bundesland / Feiertagsregel
|
||||
<select id="federalState">
|
||||
<option value="NW" selected>Nordrhein-Westfalen</option>
|
||||
<option value="NW">Nordrhein-Westfalen</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="HH">Hamburg</option>
|
||||
</select>
|
||||
<small>Feiertage werden automatisch aus dem Datum berechnet.</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="dropZone" class="drop-zone">
|
||||
@@ -42,10 +41,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="formatHint" class="hint"></section>
|
||||
|
||||
<section id="errorBox" class="error hidden"></section>
|
||||
|
||||
<p id="formatHint" class="format-hint"></p>
|
||||
|
||||
<section id="summary" class="summary hidden" aria-live="polite">
|
||||
<article class="card">
|
||||
<span>Arbeitstage</span>
|
||||
@@ -76,6 +75,7 @@
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
<th>Arbeitstage</th>
|
||||
<th>Feiertage</th>
|
||||
<th>Arbeitszeit</th>
|
||||
<th>Sollzeit</th>
|
||||
<th>Saldo</th>
|
||||
@@ -93,12 +93,12 @@
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Wochentag</th>
|
||||
<th>Feiertag</th>
|
||||
<th>Beginn</th>
|
||||
<th>Ende</th>
|
||||
<th>Arbeitszeit</th>
|
||||
<th>Sollzeit</th>
|
||||
<th>Saldo</th>
|
||||
<th>Feiertag</th>
|
||||
<th>Ruhepause</th>
|
||||
<th>Warnungen</th>
|
||||
</tr>
|
||||
@@ -110,6 +110,13 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer class="app-footer" aria-label="Projektinformationen">
|
||||
<span id="appVersion">v0.41.1</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,7 +12,7 @@ body {
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0;
|
||||
}
|
||||
@@ -40,20 +40,45 @@ h1 {
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 700px;
|
||||
max-width: 760px;
|
||||
color: #566277;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings {
|
||||
.settings,
|
||||
.card,
|
||||
.results {
|
||||
background: white;
|
||||
border: 1px solid #dde5f2;
|
||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
||||
}
|
||||
|
||||
.version-card {
|
||||
border-radius: 18px;
|
||||
padding: 14px 18px;
|
||||
min-width: 130px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.version-card span {
|
||||
display: block;
|
||||
color: #566277;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.version-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-width: 280px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings label {
|
||||
@@ -70,11 +95,7 @@ h1 {
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.settings small {
|
||||
color: #566277;
|
||||
line-height: 1.4;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
@@ -90,8 +111,8 @@ h1 {
|
||||
}
|
||||
|
||||
.drop-zone.dragover {
|
||||
border-color: #2f6fed;
|
||||
background: #eef4ff;
|
||||
border-color: #2f6feb;
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.drop-zone input {
|
||||
@@ -113,6 +134,22 @@ h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
border: 1px solid #fecdd3;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.format-hint {
|
||||
color: #566277;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -121,35 +158,26 @@ h1 {
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #dde5f2;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.card span {
|
||||
display: block;
|
||||
color: #566277;
|
||||
font-size: .9rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card strong {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.positive { color: #137333; }
|
||||
.negative { color: #b3261e; }
|
||||
.neutral { color: #172033; }
|
||||
.saldo-card strong.positive { color: #15803d; }
|
||||
.saldo-card strong.negative { color: #b91c1c; }
|
||||
|
||||
.results {
|
||||
background: white;
|
||||
border: 1px solid #dde5f2;
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -160,18 +188,22 @@ h1 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-header h2 { margin: 0; }
|
||||
.table-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #172033;
|
||||
color: white;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { opacity: .9; }
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -179,55 +211,79 @@ button {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 680px;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #e6edf7;
|
||||
border-bottom: 1px solid #e5eaf3;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #566277;
|
||||
font-size: .85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
tr.holiday-row td {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
tr.weekend-row td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.positive { color: #15803d; font-weight: 700; }
|
||||
.negative { color: #b91c1c; font-weight: 700; }
|
||||
.neutral { color: #566277; font-weight: 700; }
|
||||
|
||||
.details {
|
||||
margin-top: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.details summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #fff1f0;
|
||||
border: 1px solid #ffccc7;
|
||||
color: #8a1f11;
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 10px;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: rgba(255, 255, 255, .85);
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
box-shadow: 0 6px 18px rgba(29, 45, 68, .08);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
.app-footer a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero { display: grid; align-items: start; }
|
||||
.hero { align-items: start; flex-direction: column; }
|
||||
.settings { grid-template-columns: 1fr; }
|
||||
.summary { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@media (max-width: 540px) {
|
||||
.summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #566277;
|
||||
margin: 16px 0 0;
|
||||
font-size: .95rem;
|
||||
.app-footer { right: 8px; bottom: 8px; font-size: 11px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user