feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
/*
* Datumsrechnung ohne Zeitzonenfallen: alles laeuft ueber 'YYYY-MM-DD' und
* UTC-Mitternacht, damit ein Tag nie durch die Sommerzeit verrutscht.
*/
export function parseDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00Z`);
return Number.isNaN(date.getTime()) || iso(date) !== value ? null : date;
}
export function iso(date) {
return date.toISOString().slice(0, 10);
}
export function today() {
const now = new Date();
// Lokales Datum des Servers (Europe/Berlin), nicht UTC.
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 10);
}
export function addDays(value, days) {
const date = parseDate(value);
date.setUTCDate(date.getUTCDate() + days);
return iso(date);
}
export function daysBetween(from, to) {
return Math.round((parseDate(to) - parseDate(from)) / 86400000);
}
/** Arbeitstage Mo-Fr im geschlossenen Intervall. */
export function workdays(from, to) {
let count = 0;
const end = parseDate(to);
for (const d = parseDate(from); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const day = d.getUTCDay();
if (day !== 0 && day !== 6) count++;
}
return count;
}
export function monthRange(month) {
if (!/^\d{4}-\d{2}$/.test(month || '')) return null;
const [y, m] = month.split('-').map(Number);
if (m < 1 || m > 12) return null;
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
}
export function isoWeek(value) {
const date = parseDate(value);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return { year: date.getUTCFullYear(), week: Math.ceil(((date - yearStart) / 86400000 + 1) / 7) };
}
export function mondayOf(value) {
const date = parseDate(value);
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() - day + 1);
return iso(date);
}
export const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
export function formatDateDe(value) {
const [y, m, d] = String(value).slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
/** Zeitraum, in dem `value` liegt: day | week | month | quarter | year. */
export function periodRange(granularity, value) {
const [y, m] = value.split('-').map(Number);
switch (granularity) {
case 'day':
return { from: value, to: value };
case 'week': {
const from = mondayOf(value);
return { from, to: addDays(from, 6) };
}
case 'month':
return monthRange(value.slice(0, 7));
case 'quarter': {
const q = Math.floor((m - 1) / 3);
const startMonth = String(q * 3 + 1).padStart(2, '0');
const end = monthRange(`${y}-${String(q * 3 + 3).padStart(2, '0')}`);
return { from: `${y}-${startMonth}-01`, to: end.to };
}
case 'year':
return { from: `${y}-01-01`, to: `${y}-12-31` };
default:
return null;
}
}
/** Beginn des vorherigen Zeitraums (fuer Reihen und Vergleich). */
export function previousStart(granularity, from) {
const d = parseDate(from);
if (granularity === 'day') d.setUTCDate(d.getUTCDate() - 1);
if (granularity === 'week') d.setUTCDate(d.getUTCDate() - 7);
if (granularity === 'month') d.setUTCMonth(d.getUTCMonth() - 1);
if (granularity === 'quarter') d.setUTCMonth(d.getUTCMonth() - 3);
if (granularity === 'year') d.setUTCFullYear(d.getUTCFullYear() - 1);
return iso(d);
}
export function periodLabel(granularity, from) {
const [y, m, d] = from.split('-');
if (granularity === 'day') return `${d}.${m}.`;
if (granularity === 'week') return `KW ${isoWeek(from).week}`;
if (granularity === 'month') return `${MONTHS_DE[Number(m) - 1].slice(0, 3)} ${y.slice(2)}`;
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
return y;
}