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
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { Icon, type IconName } from '../ui/Icon';
import { Avatar } from '../ui/controls';
import { PulseMark } from './PulseMark';
import { useAuth } from '../../lib/auth';
import { ROLE_LABELS, VIEW_LABELS, homeFor, viewsFor, type ViewKey } from '../../types';
export const VIEW_ICONS: Record<ViewKey, IconName> = {
dashboard: 'dashboard',
buchung: 'clock',
projekte: 'folder',
team: 'team',
kunden: 'building',
reports: 'chart',
einstellungen: 'settings',
};
interface SidebarProps {
current: ViewKey;
onNavigate: (view: ViewKey) => void;
collapsed: boolean;
onAccount: () => void;
}
/** Seitenleiste fuer Desktop/Tablet. Auf dem Handy uebernimmt die MobileNav. */
export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed, onAccount }) => {
const { user, logout } = useAuth();
if (!user) return null;
const views = viewsFor(user.role);
const main = views.filter((v) => v !== 'einstellungen');
const home = homeFor(user.role);
const item = (view: ViewKey) => {
const active = current === view;
return (
<button
key={view}
onClick={() => onNavigate(view)}
title={collapsed ? VIEW_LABELS[view] : undefined}
aria-current={active ? 'page' : undefined}
className={`group relative flex w-full items-center rounded-xl transition ${collapsed ? 'justify-center py-2.5' : 'gap-3 px-3 py-2.5'} ${
active ? 'bg-brand-500 text-white shadow-card' : 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
}`}
>
<Icon name={VIEW_ICONS[view]} className="h-5 w-5 shrink-0" strokeWidth={active ? 2.2 : 1.7} />
{!collapsed && <span className="truncate text-sm font-bold">{VIEW_LABELS[view]}</span>}
</button>
);
};
return (
<aside
className={`app-sidebar hidden h-full shrink-0 flex-col overflow-hidden border-r border-ink-200 bg-surface transition-[width] duration-300 md:flex ${
collapsed ? 'w-[76px]' : 'w-64'
}`}
>
<button
onClick={() => onNavigate(home)}
className={`flex shrink-0 items-center border-b border-ink-100 transition hover:bg-ink-50 ${collapsed ? 'justify-center py-5' : 'gap-3 px-5 py-4'}`}
title="Zur Startseite"
>
{collapsed ? (
<PulseMark size={34} />
) : (
<div className="flex w-full items-center justify-between gap-2">
<img src="/logo.png" alt="perinco" className="logo-adaptive h-9 w-auto" />
<span className="flex items-center gap-1.5 rounded-full bg-brand-50 px-2.5 py-1 text-xs font-extrabold tracking-wide text-brand-700">
<PulseMark size={14} bare />
Pulse
</span>
</div>
)}
</button>
<nav className="scrollbar-slim flex-1 space-y-1 overflow-y-auto px-3 py-4">
{!collapsed && <p className="mb-2 px-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Navigation</p>}
{main.map(item)}
</nav>
{views.includes('einstellungen') && <div className="shrink-0 border-t border-ink-100 px-3 py-3">{item('einstellungen')}</div>}
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
{!collapsed && (
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="block truncate text-xs text-ink-400">{ROLE_LABELS[user.role]}</span>
</span>
)}
</button>
{!collapsed && (
<button onClick={logout} title="Abmelden" className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
</button>
)}
</div>
{collapsed && (
<button onClick={logout} title="Abmelden" className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
</button>
)}
</div>
</aside>
);
};