Reorganize into monorepo layout, move mitarbeiter-app to legacy reference

Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-07 14:21:37 +02:00
co-authored by Claude Sonnet 5
parent 8beb0fcf52
commit b6c1389c55
355 changed files with 24348 additions and 361 deletions
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [mustChangePassword, setMustChangePassword] = useState(false);
useEffect(() => {
let cancelled = false;
window.omsorg.auth.getSession().then((session) => {
if (cancelled) return;
setIsAuthenticated(session.isAuthenticated);
setUser(session.user);
setMustChangePassword(!!session.mustChangePassword);
setIsLoading(false);
});
const unsubscribe = window.omsorg.auth.onSessionChanged((session) => {
setIsAuthenticated(session.isAuthenticated);
setUser(session.user);
setMustChangePassword(!!session.mustChangePassword);
setIsLoading(false);
});
return () => {
cancelled = true;
unsubscribe();
};
}, []);
const login = useCallback(async (username, password) => {
const result = await window.omsorg.auth.login(username, password);
if (result.success) {
setIsAuthenticated(true);
setUser(result.user);
setMustChangePassword(!!result.mustChangePassword);
}
return result;
}, []);
const logout = useCallback(async () => {
await window.omsorg.auth.logout();
setIsAuthenticated(false);
setUser(null);
setMustChangePassword(false);
}, []);
const changePassword = useCallback(async (currentPassword, newPassword) => {
const result = await window.omsorg.auth.changePassword(currentPassword, newPassword);
if (result.success) {
// Serverseitig wurden alle Sessions widerrufen (siehe UserService.ChangeOwnPasswordAsync) -
// der aktuelle Access-Token ist schon tot, also lokal neu einloggen lassen statt weiterzumachen.
setIsAuthenticated(false);
setUser(null);
setMustChangePassword(false);
}
return result;
}, []);
const hasPermission = useCallback(
(module, action) => !!user?.permissions?.some((p) => p.module === module && p.action === action),
[user]
);
return (
<AuthContext.Provider
value={{ isAuthenticated, user, isLoading, mustChangePassword, login, logout, changePassword, hasPermission }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth muss innerhalb von AuthProvider verwendet werden");
return ctx;
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import AppLayout from "../layouts/AppLayout";
import HomePage from "../modules/home/HomePage";
import EmployeesPage from "../modules/employees/EmployeesPage";
import LoginPage from "../modules/auth/LoginPage";
import ChangePasswordScreen from "../modules/auth/ChangePasswordScreen";
import ForgotPasswordUsernamePage from "../modules/auth/ForgotPasswordUsernamePage";
import ForgotPasswordPinPage from "../modules/auth/ForgotPasswordPinPage";
import ForgotPasswordNewPasswordPage from "../modules/auth/ForgotPasswordNewPasswordPage";
import DebugSessionsPage from "../modules/debug/DebugSessionsPage";
import { useAuth } from "./AuthContext";
import { isNavItemVisible } from "./navPermissions";
function PlaceholderPage({ title }) {
return (
<section className="welcome-card">
<h2>{title}</h2>
<p>Dieses Modul bauen wir als Nächstes vollständig aus.</p>
</section>
);
}
export default function App() {
const [activePage, setActivePage] = useState("Home");
const [authView, setAuthView] = useState({ screen: "login" });
const { isAuthenticated, isLoading, hasPermission, mustChangePassword } = useAuth();
useEffect(() => {
if (isAuthenticated && !isNavItemVisible(activePage, hasPermission)) {
setActivePage("Home");
}
}, [isAuthenticated, activePage, hasPermission]);
if (isLoading) {
return <div className="login-screen">Lädt...</div>;
}
if (!isAuthenticated) {
switch (authView.screen) {
case "forgotUsername":
return (
<ForgotPasswordUsernamePage
onNext={(username) => setAuthView({ screen: "forgotPin", username })}
onBackToLogin={() => setAuthView({ screen: "login" })}
/>
);
case "forgotPin":
return (
<ForgotPasswordPinPage
username={authView.username}
onVerified={(resetToken) => setAuthView({ screen: "forgotNewPassword", resetToken })}
onRestart={() => setAuthView({ screen: "forgotUsername" })}
onBackToLogin={() => setAuthView({ screen: "login" })}
/>
);
case "forgotNewPassword":
return (
<ForgotPasswordNewPasswordPage
resetToken={authView.resetToken}
onDone={() => setAuthView({ screen: "login" })}
onBackToLogin={() => setAuthView({ screen: "login" })}
/>
);
default:
return <LoginPage onForgotPassword={() => setAuthView({ screen: "forgotUsername" })} />;
}
}
if (mustChangePassword) {
return <ChangePasswordScreen />;
}
const renderPage = () => {
switch (activePage) {
case "Home":
return <HomePage />;
case "Mitarbeiter":
return <EmployeesPage />;
case "Kunden":
return <PlaceholderPage title="Kunden" />;
case "Disposition":
return <PlaceholderPage title="Disposition" />;
case "Kalkulation":
return <PlaceholderPage title="Kalkulation" />;
case "Fahrzeuge":
return <PlaceholderPage title="Fahrzeuge" />;
case "Rechnungen":
return <PlaceholderPage title="Rechnungen" />;
case "Controlling":
return <PlaceholderPage title="Controlling" />;
case "Einstellungen":
return <PlaceholderPage title="Einstellungen" />;
case "Debug":
return <DebugSessionsPage />;
default:
return <HomePage />;
}
};
return (
<AppLayout
activePage={activePage}
onNavigate={setActivePage}
>
{renderPage()}
</AppLayout>
);
}
+14
View File
@@ -0,0 +1,14 @@
export const NAV_MODULES = {
Mitarbeiter: "Employees",
Kunden: "Facilities",
Disposition: "Orders",
Rechnungen: "Invoices",
Controlling: "Controlling",
Einstellungen: "UserManagement",
Debug: "UserManagement"
};
export function isNavItemVisible(label, hasPermission) {
const module = NAV_MODULES[label];
return !module || hasPermission(module, "View");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+52
View File
@@ -0,0 +1,52 @@
import { Bell, LogOut, Search, UserCircle2 } from "lucide-react";
import { useAuth } from "../app/AuthContext";
export default function Header() {
const { user, logout } = useAuth();
return (
<header className="header">
<div className="header-left">
<h1>Guten Morgen Sabina & Malik </h1>
<p>
Schön, dass ihr heute wieder da seid.
</p>
</div>
<div className="header-right">
<button className="icon-button">
<Search size={20} />
</button>
<button className="icon-button notification">
<Bell size={20} />
<span className="notification-dot">
3
</span>
</button>
<button className="icon-button">
<UserCircle2 size={24} />
</button>
{user?.username && (
<span className="header-username">{user.username}</span>
)}
<button className="icon-button" title="Abmelden" onClick={logout}>
<LogOut size={20} />
</button>
</div>
</header>
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* ==========================================================
* OMSORG BUSINESS CONTROLS PRO
* Component: OmsorgCard
* Sprint: 0.2.0
* ==========================================================
*/
export default function OmsorgCard({
title,
value,
subtitle,
children,
}) {
return (
<section className="omsorg-card">
{title && (
<div className="omsorg-card-header">
<h3>{title}</h3>
</div>
)}
{value && (
<div className="omsorg-card-value">
{value}
</div>
)}
{subtitle && (
<p className="omsorg-card-subtitle">
{subtitle}
</p>
)}
{children}
</section>
);
}
+103
View File
@@ -0,0 +1,103 @@
import logo from "../assets/omsorg_logo.png";
import {
Home,
Users,
Building2,
CalendarDays,
Calculator,
Car,
FileText,
BarChart3,
Settings,
Bug
} from "lucide-react";
import { useAuth } from "../app/AuthContext";
import { isNavItemVisible } from "../app/navPermissions";
const menu = [
{ icon: Home, label: "Home" },
{ icon: Users, label: "Mitarbeiter" },
{ icon: Building2, label: "Kunden" },
{ icon: CalendarDays, label: "Disposition" },
{ icon: Calculator, label: "Kalkulation" },
{ icon: Car, label: "Fahrzeuge" },
{ icon: FileText, label: "Rechnungen" },
{ icon: BarChart3, label: "Controlling" },
{ icon: Settings, label: "Einstellungen" },
{ icon: Bug, label: "Debug" }
];
export default function Sidebar({
activePage,
onNavigate
}) {
const { hasPermission } = useAuth();
const items = menu.filter((item) => isNavItemVisible(item.label, hasPermission));
return (
<aside className="sidebar">
<div className="sidebar-logo">
<img
src={logo}
alt="Omsorg"
/>
<div>
<h2>OMSORG</h2>
<p>
Business Controls Pro
</p>
</div>
</div>
<nav>
{items.map((item) => {
const Icon = item.icon;
const active =
activePage === item.label;
return (
<button
key={item.label}
className={
active
? "menu-button active"
: "menu-button"
}
onClick={() =>
onNavigate(item.label)
}
>
<Icon size={20} />
<span>{item.label}</span>
</button>
);
})}
</nav>
</aside>
);
}
@@ -0,0 +1,58 @@
import {
CheckCircle2,
BriefcaseMedical,
Palmtree,
HeartPulse,
Clock3,
} from "lucide-react";
const badgeConfig = {
aktiv: {
label: "Aktiv",
icon: CheckCircle2,
className: "success",
},
einsatz: {
label: "Einsatz",
icon: BriefcaseMedical,
className: "info",
},
urlaub: {
label: "Urlaub",
icon: Palmtree,
className: "warning",
},
krank: {
label: "Krank",
icon: HeartPulse,
className: "danger",
},
vertrag: {
label: "Läuft aus",
icon: Clock3,
className: "neutral",
},
};
export default function OmsorgBadge({
status = "aktiv",
}) {
const config =
badgeConfig[status] ?? badgeConfig.aktiv;
const Icon = config.icon;
return (
<span
className={`omsorg-badge omsorg-badge--${config.className}`}
>
<Icon size={14} />
{config.label}
</span>
);
}
@@ -0,0 +1,42 @@
/**
* ==========================================================
* OMSORG BUSINESS CONTROLS PRO
* Component: OmsorgButton
* Sprint: 0.2.0
* ==========================================================
*/
export default function OmsorgButton({
children,
variant = "primary",
icon: Icon,
type = "button",
disabled = false,
onClick,
}) {
const className =
`omsorg-button omsorg-button--${variant}`;
return (
<button
type={type}
className={className}
disabled={disabled}
onClick={onClick}
>
{Icon && <Icon size={18} />}
<span>
{children}
</span>
</button>
);
}
@@ -0,0 +1,31 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import OmsorgButton from "./OmsorgButton";
export default function OmsorgPagination({ page, totalPages, onPageChange }) {
return (
<div className="omsorg-pagination">
<OmsorgButton
variant="secondary"
icon={ChevronLeft}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Zurück
</OmsorgButton>
<span className="omsorg-pagination-status">
Seite {page} von {totalPages}
</span>
<OmsorgButton
variant="secondary"
icon={ChevronRight}
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Weiter
</OmsorgButton>
</div>
);
}
@@ -0,0 +1,51 @@
import {
ArrowDownRight,
ArrowUpRight,
Minus,
} from "lucide-react";
export default function OmsorgStatCard({
title,
value,
subtitle,
trend = "neutral",
icon: Icon,
}) {
const TrendIcon =
trend === "up"
? ArrowUpRight
: trend === "down"
? ArrowDownRight
: Minus;
return (
<article className="omsorg-stat-card">
<div className="omsorg-stat-card__top">
<div>
<p className="omsorg-stat-card__title">
{title}
</p>
<strong className="omsorg-stat-card__value">
{value}
</strong>
</div>
{Icon && (
<div className="omsorg-stat-card__icon">
<Icon size={22} />
</div>
)}
</div>
{subtitle && (
<div
className={`omsorg-stat-card__trend omsorg-stat-card__trend--${trend}`}
>
<TrendIcon size={16} />
<span>{subtitle}</span>
</div>
)}
</article>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Sidebar from "../components/Sidebar";
import Header from "../components/Header";
export default function AppLayout({
children,
activePage,
onNavigate,
}) {
return (
<div className="layout">
<Sidebar
activePage={activePage}
onNavigate={onNavigate}
/>
<div className="layout-content">
<Header />
<main className="page-content">
{children}
</main>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./app/app";
import { AuthProvider } from "./app/AuthContext";
import "./style.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Bell, BriefcaseBusiness, Calculator, CalendarClock, Database, FileText, Home, Mail, Phone, Plus, Save, Settings, Shield, Users } from 'lucide-react';
import logo from './assets/omsorg_logo.png';
import './style.css';
const nav = [
['home', Home, 'Home'], ['employees', Users, 'Mitarbeiter'], ['customers', BriefcaseBusiness, 'Kunden'],
['dispo', CalendarClock, 'Disposition'], ['calc', Calculator, 'Kalkulation'], ['docs', FileText, 'Dokumente'], ['settings', Settings, 'Einstellungen']
];
const genId = (prefix) => `${prefix}-${Math.random().toString(36).slice(2, 9)}`;
function App(){
const [active,setActive]=useState('home');
const [data,setData]=useState(null);
const [paths,setPaths]=useState(null);
const [selected,setSelected]=useState(null);
const [toast,setToast]=useState('');
useEffect(()=>{ (async()=>{ setData(await window.omsorg.getDb()); setPaths(await window.omsorg.paths()); })(); },[]);
const save = async (next, msg='Gespeichert') => { setData(next); await window.omsorg.setDb(next); setToast(msg); setTimeout(()=>setToast(''),2200); };
const warnings = useMemo(()=>{
if(!data) return [];
const now = new Date();
return data.assignments.filter(a=>a.endDate).map(a=>({...a, days: Math.ceil((new Date(a.endDate)-now)/86400000)})).filter(a=>a.days>=0 && a.days<=30);
},[data]);
if(!data) return <div className="loading">Omsorg Business Controls Pro startet...</div>;
return <div className="app">
<aside className="sidebar">
<div className="brand"><img src={logo}/><div><b>Business Controls Pro</b><span>Release 0.1.1 Foundation</span></div></div>
<nav>{nav.map(([id,Icon,label])=><button key={id} onClick={()=>setActive(id)} className={active===id?'active':''}><Icon size={18}/>{label}</button>)}</nav>
<div className="sidecard"><Shield size={18}/><div><b>Lokaler Modus</b><span>Daten bleiben auf diesem MacBook.</span></div></div>
</aside>
<main>
<header><div><h1>{nav.find(n=>n[0]===active)?.[2]}</h1><p>Project Aurora · Omsorg Dautovic & Neumann GbR</p></div><div className="header-actions"><button className="bell" onClick={()=>setActive('home')}><Bell size={18}/>{warnings.length>0 && <span>{warnings.length}</span>}</button><button onClick={async()=>{const file=await window.omsorg.createBackup(); setToast('Backup erstellt: '+file)}}>Backup erstellen</button></div></header>
{active==='home' && <HomeView data={data} warnings={warnings} setActive={setActive} setSelected={setSelected}/>}
{active==='employees' && <Employees data={data} save={save} setSelected={setSelected}/>}
{active==='customers' && <Customers data={data} save={save} setSelected={setSelected}/>}
{active==='dispo' && <Disposition data={data} save={save} setSelected={setSelected}/>}
{active==='calc' && <Calc/>}
{active==='docs' && <Docs paths={paths}/>}
{active==='settings' && <SettingsView paths={paths}/>}
</main>
{selected && <Modal item={selected} onClose={()=>setSelected(null)} data={data} save={save}/>}
{toast && <div className="toast">{toast}</div>}
</div>
}
function HomeView({data,warnings,setActive,setSelected}){return <section className="grid"><div className="hero card"><h2>Guten Morgen, Omsorg</h2><p>Die erste echte lokale Projektbasis läuft. Ohne native SQLite-Bindings, stabil auf dem MacBook.</p><div className="chips"><span>{data.employees.length} Mitarbeiter</span><span>{data.customers.length} Kunden</span><span>{data.assignments.length} Einsätze</span></div></div><div className="card"><h3>Heute zu erledigen</h3>{warnings.length?warnings.map(w=><button className="warning" key={w.id} onClick={()=>setSelected({type:'assignment',...w})}> {w.customerName} endet in {w.days} Tagen</button>):<p className="muted">Keine Vertragswarnungen in den nächsten 30 Tagen.</p>}</div><div className="card wide"><h3>Schnellstart</h3><div className="quick"><button onClick={()=>setActive('employees')}>Mitarbeiter verwalten</button><button onClick={()=>setActive('customers')}>Kunden anlegen</button><button onClick={()=>setActive('dispo')}>Einsatz planen</button><button onClick={()=>setActive('calc')}>Kalkulation öffnen</button></div></div></section>}
function Employees({data,save,setSelected}){const [form,setForm]=useState({name:'',qualification:'3-jährige Pflegefachkraft',status:'Aktiv'}); const add=()=>{if(!form.name.trim())return; save({...data,employees:[...data.employees,{id:genId('e'),...form}]}); setForm({...form,name:''});}; const del=(id)=>{if(confirm('Mitarbeiter wirklich löschen?')) save({...data,employees:data.employees.filter(e=>e.id!==id)});}; return <section><div className="form card"><input placeholder="Name" value={form.name} onChange={e=>setForm({...form,name:e.target.value})}/><select value={form.qualification} onChange={e=>setForm({...form,qualification:e.target.value})}><option>3-jährige Pflegefachkraft</option><option>1-jährige Pflegekraft</option><option>Disposition</option><option>Geschäftsführung</option></select><button onClick={add}><Plus size={16}/> Anlegen</button></div><div className="list">{data.employees.map(e=><div className="row" key={e.id} onClick={()=>setSelected({type:'employee',...e})}><div><b>{e.name}</b><span>{e.qualification} · {e.status}</span></div><button onClick={(ev)=>{ev.stopPropagation();del(e.id)}}>Löschen</button></div>)}</div></section>}
function Customers({data,save,setSelected}){const [f,setF]=useState({name:'',phone:'',email:'',contact:''}); const add=()=>{if(!f.name.trim())return; save({...data,customers:[...data.customers,{id:genId('c'),...f}]}); setF({name:'',phone:'',email:'',contact:''});}; return <section><div className="form card"><input placeholder="Einrichtung" value={f.name} onChange={e=>setF({...f,name:e.target.value})}/><input placeholder="Telefon" value={f.phone} onChange={e=>setF({...f,phone:e.target.value})}/><input placeholder="E-Mail" value={f.email} onChange={e=>setF({...f,email:e.target.value})}/><button onClick={add}><Plus size={16}/> Kunde anlegen</button></div><div className="list">{data.customers.map(c=><div className="row" key={c.id} onClick={()=>setSelected({type:'customer',...c})}><div><b>{c.name}</b><span>{c.phone || 'keine Telefonnummer'} · {c.email || 'keine E-Mail'}</span></div></div>)}</div></section>}
function Disposition({data,save,setSelected}){const [f,setF]=useState({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''}); const add=()=>{if(!f.customerName.trim())return; save({...data,assignments:[...data.assignments,{id:genId('a'),...f,createdAt:new Date().toISOString()}]}); setF({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''});}; return <section><div className="form card"><input placeholder="Einrichtung" value={f.customerName} onChange={e=>setF({...f,customerName:e.target.value})}/><input placeholder="Mitarbeiter" value={f.employeeName} onChange={e=>setF({...f,employeeName:e.target.value})}/><input type="date" value={f.startDate} onChange={e=>setF({...f,startDate:e.target.value})}/><input type="date" value={f.endDate} onChange={e=>setF({...f,endDate:e.target.value})}/><input placeholder="Telefon" value={f.phone} onChange={e=>setF({...f,phone:e.target.value})}/><input placeholder="E-Mail" value={f.email} onChange={e=>setF({...f,email:e.target.value})}/><button onClick={add}><Save size={16}/> Einsatz speichern</button></div><div className="list">{data.assignments.map(a=><div className="row" key={a.id} onClick={()=>setSelected({type:'assignment',...a})}><div><b>{a.customerName}</b><span>{a.employeeName || 'ohne Mitarbeiter'} · {a.startDate || '?'} bis {a.endDate || '?'}</span></div></div>)}</div></section>}
function Calc(){const [rate,setRate]=useState(48.95),[hours,setHours]=useState(36.5),[margin,setMargin]=useState(25); const revenue=rate*hours; const targetCost=revenue*(1-margin/100); return <section className="grid"><div className="card"><h3>Wochenkalkulation</h3><label>Stundensatz <input type="number" value={rate} onChange={e=>setRate(+e.target.value)}/></label><label>Stunden <input type="number" value={hours} onChange={e=>setHours(+e.target.value)}/></label><label>Zielmarge % <input type="number" value={margin} onChange={e=>setMargin(+e.target.value)}/></label></div><div className="card"><h3>Ergebnis</h3><div className="big">{revenue.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}</div><p>Maximale Kosten bei Zielmarge: <b>{targetCost.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}</b></p></div></section>}
function Docs({paths}){return <section className="card"><h3>Dokumentenspeicher</h3><p>Release 0.1.1 legt die Ordnerstruktur lokal im Benutzerordner an.</p><code>{paths?.root}</code><br/><button onClick={()=>window.omsorg.openRoot()}>Ordner im Finder öffnen</button></section>}
function SettingsView({paths}){return <section className="card"><h3>Systempfade</h3><p>Datenbank:</p><code>{paths?.db}</code><p>Projektordner:</p><code>{paths?.root}</code></section>}
function Modal({item,onClose,data,save}){ const prolong=(id)=>{const newDate=prompt('Neues Vertragsende YYYY-MM-DD'); if(!newDate)return; const next={...data,assignments:data.assignments.map(a=>a.id===id?{...a,endDate:newDate}:a)}; save(next,'Vertrag verlängert'); onClose();}; return <div className="overlay"><div className="modal"><button className="close" onClick={onClose}>×</button><h2>{item.name || item.customerName}</h2>{item.type==='assignment'&&<><p><b>Mitarbeiter:</b> {item.employeeName || '-'}</p><p><b>Vertrag:</b> {item.startDate || '?'} bis {item.endDate || '?'}</p><p><Phone size={16}/> <a href={`tel:${item.phone}`}>{item.phone || 'keine Nummer'}</a></p><p><Mail size={16}/> <a href={`mailto:${item.email}`}>{item.email || 'keine E-Mail'}</a></p><div className="quick"><button onClick={()=>prolong(item.id)}>Vertrag verlängern</button><a className="button" href={`mailto:${item.email}?subject=Vertragsverlängerung`}>E-Mail senden</a></div></>}{item.type==='employee'&&<p>{item.qualification} · {item.status}</p>}{item.type==='customer'&&<><p><Phone size={16}/> <a href={`tel:${item.phone}`}>{item.phone}</a></p><p><Mail size={16}/> <a href={`mailto:${item.email}`}>{item.email}</a></p></>}</div></div>}
createRoot(document.getElementById('root')).render(<App/>);
@@ -0,0 +1,93 @@
import { useState } from "react";
import { KeyRound } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
export default function ChangePasswordScreen() {
const { changePassword } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setError(null);
if (newPassword.length < 8) {
setError("Das neue Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (newPassword !== confirmPassword) {
setError("Die Passwörter stimmen nicht überein.");
return;
}
setIsSubmitting(true);
const result = await changePassword(currentPassword, newPassword);
setIsSubmitting(false);
if (!result.success) {
setError(
result.error === "invalid_current_password"
? "Aktuelles Passwort ist falsch."
: "Passwort konnte nicht geändert werden."
);
}
// Bei Erfolg beendet changePassword() die Session lokal - der Login-Screen erscheint automatisch.
}
return (
<div className="login-screen">
<OmsorgCard>
<h2>Passwort ändern erforderlich</h2>
<p className="page-description">
Für diesen Account wurde ein initiales Passwort vergeben. Bitte lege jetzt dein eigenes Passwort fest,
bevor du weiterarbeiten kannst.
</p>
<form onSubmit={handleSubmit} className="login-form">
<label>
Aktuelles Passwort
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoFocus
required
/>
</label>
<label>
Neues Passwort
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</label>
<label>
Neues Passwort bestätigen
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<OmsorgButton type="submit" icon={KeyRound} disabled={isSubmitting}>
{isSubmitting ? "Ändert..." : "Passwort ändern"}
</OmsorgButton>
</form>
</OmsorgCard>
</div>
);
}
@@ -0,0 +1,77 @@
import { useState } from "react";
import { KeyRound } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
export default function ForgotPasswordNewPasswordPage({ resetToken, onDone, onBackToLogin }) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Die Passwörter stimmen nicht überein.");
return;
}
setIsSubmitting(true);
const result = await window.omsorg.auth.resetPassword(resetToken, password);
setIsSubmitting(false);
if (result.success) {
onDone();
} else {
setError("Der Code ist abgelaufen. Bitte fordere einen neuen an.");
}
}
return (
<div className="login-screen">
<OmsorgCard>
<h2>
Neues
<span className="accent"> Passwort setzen</span>
</h2>
<form onSubmit={handleSubmit} className="login-form">
<label>
Neues Passwort
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
required
/>
</label>
<label>
Passwort bestätigen
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<OmsorgButton type="submit" icon={KeyRound} disabled={isSubmitting}>
{isSubmitting ? "Wird gespeichert..." : "Passwort speichern"}
</OmsorgButton>
<div className="login-form-links">
<button type="button" onClick={onBackToLogin}>
Zurück zum Login
</button>
</div>
</form>
</OmsorgCard>
</div>
);
}
@@ -0,0 +1,81 @@
import { useState } from "react";
import { ShieldCheck } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
export default function ForgotPasswordPinPage({ username, onVerified, onRestart, onBackToLogin }) {
const [pin, setPin] = useState("");
const [error, setError] = useState(null);
const [tooManyAttempts, setTooManyAttempts] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setError(null);
setTooManyAttempts(false);
setIsSubmitting(true);
const result = await window.omsorg.auth.verifyPasswordResetCode(username, pin);
setIsSubmitting(false);
if (result.success) {
onVerified(result.resetToken);
return;
}
if (result.error === "too_many_attempts") {
setTooManyAttempts(true);
} else {
setError("Code falsch oder abgelaufen.");
}
}
return (
<div className="login-screen">
<OmsorgCard>
<h2>
Code
<span className="accent"> eingeben</span>
</h2>
<p className="login-info">
Wir haben einen 6-stelligen Code an die hinterlegte E-Mail-Adresse gesendet. Der Code ist 5 Minuten gültig.
</p>
<form onSubmit={handleSubmit} className="login-form">
<label>
Code
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
autoFocus
required
/>
</label>
{error && <p className="login-error">{error}</p>}
{tooManyAttempts && (
<p className="login-error">
Zu viele Fehlversuche. Bitte fordere einen neuen Code an.
</p>
)}
<OmsorgButton type="submit" icon={ShieldCheck} disabled={isSubmitting || tooManyAttempts}>
{isSubmitting ? "Wird geprüft..." : "Bestätigen"}
</OmsorgButton>
<div className="login-form-links">
<button type="button" onClick={onRestart}>
Neuen Code anfordern
</button>
<button type="button" onClick={onBackToLogin}>
Zurück zum Login
</button>
</div>
</form>
</OmsorgCard>
</div>
);
}
@@ -0,0 +1,72 @@
import { useState } from "react";
import { Mail } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
export default function ForgotPasswordUsernamePage({ onNext, onBackToLogin }) {
const [username, setUsername] = useState("");
const [error, setError] = useState(null);
const [cannotReset, setCannotReset] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setError(null);
setCannotReset(false);
setIsSubmitting(true);
const result = await window.omsorg.auth.requestPasswordReset(username);
setIsSubmitting(false);
if (!result.success) {
setError("Server nicht erreichbar. Bitte Verbindung prüfen und erneut versuchen.");
return;
}
if (result.status === "sent") {
onNext(username);
} else {
setCannotReset(true);
}
}
return (
<div className="login-screen">
<OmsorgCard>
<h2>
Passwort
<span className="accent"> vergessen</span>
</h2>
<form onSubmit={handleSubmit} className="login-form">
<label>
Benutzername
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
</label>
{error && <p className="login-error">{error}</p>}
{cannotReset && (
<p className="login-error">
Für diesen Account ist kein Passwort-Reset per E-Mail möglich. Bitte wende dich an einen Administrator.
</p>
)}
<OmsorgButton type="submit" icon={Mail} disabled={isSubmitting}>
{isSubmitting ? "Wird gesendet..." : "Code anfordern"}
</OmsorgButton>
<div className="login-form-links">
<button type="button" onClick={onBackToLogin}>
Zurück zum Login
</button>
</div>
</form>
</OmsorgCard>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from "react";
import { LogIn } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
export default function LoginPage({ onForgotPassword }) {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setError(null);
setIsSubmitting(true);
const result = await login(username, password);
setIsSubmitting(false);
if (!result.success) {
let message = "Benutzername oder Passwort falsch.";
if (result.error === "network_error") {
message = "Server nicht erreichbar. Bitte Verbindung prüfen und erneut versuchen.";
} else if (result.error === "too_many_attempts") {
message = "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.";
}
setError(message);
}
}
return (
<div className="login-screen">
<OmsorgCard>
<h2>
Anmelden bei
<span className="accent"> Omsorg Business Controls Pro</span>
</h2>
<form onSubmit={handleSubmit} className="login-form">
<label>
Benutzername
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<OmsorgButton type="submit" icon={LogIn} disabled={isSubmitting}>
{isSubmitting ? "Anmelden..." : "Anmelden"}
</OmsorgButton>
<div className="login-form-links">
<button type="button" onClick={onForgotPassword}>
Passwort vergessen?
</button>
</div>
</form>
</OmsorgCard>
</div>
);
}
@@ -0,0 +1,180 @@
import { useCallback, useEffect, useState } from "react";
import { LogOut, Mail, RotateCw, ShieldAlert } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
function formatDate(iso) {
return new Date(iso).toLocaleString("de-DE");
}
export default function DebugSessionsPage() {
const { logout } = useAuth();
const [sessions, setSessions] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [testEmailAddress, setTestEmailAddress] = useState("");
const [testEmailSubject, setTestEmailSubject] = useState("");
const [testEmailBody, setTestEmailBody] = useState("");
const [isSendingTestEmail, setIsSendingTestEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState(null);
const loadSessions = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.api.get("/api/admin/sessions");
if (result.ok) {
setSessions(result.data ?? []);
} else {
setError("Sessions konnten nicht geladen werden.");
}
setIsLoading(false);
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
async function loadTemplate() {
const result = await window.omsorg.api.get("/api/admin/email/password-reset-template");
if (result.ok) {
setTestEmailSubject(result.data.subject);
setTestEmailBody(result.data.bodyTemplate);
}
}
loadTemplate();
}, []);
async function revokeSession(id) {
await window.omsorg.api.post(`/api/admin/sessions/${id}/revoke`);
await loadSessions();
}
async function revokeAll() {
await window.omsorg.api.post("/api/admin/sessions/revoke-all");
await logout();
}
async function sendTestEmail() {
setIsSendingTestEmail(true);
setTestEmailResult(null);
const result = await window.omsorg.api.post("/api/admin/email/test-send", {
toAddress: testEmailAddress,
subject: testEmailSubject,
body: testEmailBody,
});
setTestEmailResult(
result.ok
? { ok: true, message: `Testmail an ${testEmailAddress} ausgelöst.` }
: { ok: false, message: "Testmail konnte nicht versendet werden." }
);
setIsSendingTestEmail(false);
}
return (
<OmsorgCard title="Debug: Aktive Sessions">
<p>
Erzwingt bei einzelnen oder allen Nutzern sofort einen neuen Login - auch
bereits ausgestellte Access-Tokens werden dabei ungültig, nicht erst nach Ablauf.
</p>
<div className="debug-sessions-actions">
<OmsorgButton icon={RotateCw} variant="secondary" onClick={loadSessions}>
Aktualisieren
</OmsorgButton>
<OmsorgButton icon={ShieldAlert} variant="danger" onClick={revokeAll}>
Alle abmelden
</OmsorgButton>
</div>
{isLoading && <p>Lädt...</p>}
{error && <p className="login-error">{error}</p>}
{!isLoading && !error && sessions.length === 0 && (
<p>Keine aktiven Sessions.</p>
)}
{sessions.length > 0 && (
<ul className="debug-sessions-list">
{sessions.map((session) => (
<li key={session.id} className="debug-sessions-row">
<div>
<strong>{session.username}</strong>
<p className="debug-sessions-meta">
Ausgestellt: {formatDate(session.createdAt)} · Läuft ab: {formatDate(session.expiresAt)}
</p>
</div>
<OmsorgButton
icon={LogOut}
variant="danger"
onClick={() => revokeSession(session.id)}
>
Abmelden
</OmsorgButton>
</li>
))}
</ul>
)}
<hr className="debug-sessions-divider" />
<h3>E-Mail-Versand testen</h3>
<p>
Sendet eine Passwort-Reset-Testmail über den serverseitig konfigurierten Mail-Versand
(<code>Email:Provider</code>, Account <code>Email:PasswordResetAccount</code>). Betreff/Text
sind mit der echten Vorlage vorbefüllt und können vor dem Senden angepasst werden. Zugangsdaten
selbst werden dabei nie an den Client übertragen.
</p>
<div className="debug-sessions-field">
<label htmlFor="test-email-address">Empfänger</label>
<input
id="test-email-address"
type="email"
placeholder="empfaenger@beispiel.de"
value={testEmailAddress}
onChange={(event) => setTestEmailAddress(event.target.value)}
/>
</div>
<div className="debug-sessions-field">
<label htmlFor="test-email-subject">Betreff</label>
<input
id="test-email-subject"
type="text"
value={testEmailSubject}
onChange={(event) => setTestEmailSubject(event.target.value)}
/>
</div>
<div className="debug-sessions-field">
<label htmlFor="test-email-body">Text</label>
<textarea
id="test-email-body"
rows={4}
value={testEmailBody}
onChange={(event) => setTestEmailBody(event.target.value)}
/>
</div>
<div className="debug-sessions-actions">
<OmsorgButton
icon={Mail}
variant="secondary"
disabled={!testEmailAddress || !testEmailSubject || !testEmailBody || isSendingTestEmail}
onClick={sendTestEmail}
>
{isSendingTestEmail ? "Sende..." : "Test-E-Mail senden"}
</OmsorgButton>
</div>
{testEmailResult && (
<p className={testEmailResult.ok ? "debug-sessions-meta" : "login-error"}>
{testEmailResult.message}
</p>
)}
</OmsorgCard>
);
}
@@ -0,0 +1,58 @@
import { useState } from "react";
import EmployeeForm, { emptyEmployeeForm, employeeFormToPayload, FormActions } from "./EmployeeForm";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Mitarbeiter anzulegen.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Mitarbeiter konnte nicht angelegt werden.";
}
export default function CreateEmployeeDialog({ onClose, onCreated }) {
const [form, setForm] = useState(emptyEmployeeForm);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.firstName.trim() || !form.lastName.trim()) {
setError("Vorname und Nachname sind erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.employees.create(employeeFormToPayload(form));
setIsSaving(false);
if (!result.ok) {
setError(errorMessage(result));
return;
}
onCreated(result.data);
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Neuer Mitarbeiter">
<div className="modal-panel">
<h2>Neuer Mitarbeiter</h2>
<form onSubmit={handleSubmit}>
<EmployeeForm form={form} onChange={setForm} />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
);
}
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { FormActions } from "./EmployeeForm";
const PIN_VALIDITY_OPTIONS = [1, 3, 7, 14];
const DEFAULT_PIN_VALIDITY_DAYS = 7;
function suggestUsername(employee) {
const first = (employee.firstName ?? "").trim().toLowerCase();
const last = (employee.lastName ?? "").trim().toLowerCase();
return `${first}.${last}`.replace(/\s+/g, "");
}
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Accounts anzulegen.";
}
if (result.status === 404) {
return "Mitarbeiter oder Rolle nicht gefunden.";
}
if (result.status === 409) {
return typeof result.data === "string" ? result.data : "Dieser Mitarbeiter oder Username ist bereits vergeben.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Account konnte nicht angelegt werden.";
}
export default function CreateUserAccountDialog({ employee, onClose, onCreated }) {
const [username, setUsername] = useState(() => suggestUsername(employee));
const [roles, setRoles] = useState([]);
const [roleId, setRoleId] = useState("");
const [mode, setMode] = useState("Invite");
const [pinValidityDays, setPinValidityDays] = useState(DEFAULT_PIN_VALIDITY_DAYS);
const [initialPassword, setInitialPassword] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
async function loadRoles() {
const result = await window.omsorg.api.get("/api/roles");
if (result.ok) {
const data = result.data ?? [];
setRoles(data);
if (data.length > 0) {
setRoleId(data[0].id);
}
}
}
loadRoles();
}, []);
async function handleSubmit(event) {
event.preventDefault();
if (!username.trim()) {
setError("Username ist erforderlich.");
return;
}
if (!roleId) {
setError("Rolle ist erforderlich.");
return;
}
if (mode === "Direct" && initialPassword.trim().length < 8) {
setError("Initiales Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
setIsSaving(true);
setError(null);
const result = await window.omsorg.users.create({
employeeId: employee.id,
username: username.trim(),
roleId,
mode,
initialPassword: mode === "Direct" ? initialPassword : null,
pinValidityDays: mode === "Invite" ? pinValidityDays : null,
});
setIsSaving(false);
if (!result.ok) {
setError(errorMessage(result));
return;
}
onCreated(result.data);
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Account anlegen">
<div className="modal-panel">
<h2>Account für {employee.firstName} {employee.lastName} anlegen</h2>
<form onSubmit={handleSubmit}>
<label className="form-field">
<span>Username</span>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="form-field">
<span>Rolle</span>
<select value={roleId} onChange={(event) => setRoleId(event.target.value)}>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</label>
<fieldset className="form-field">
<legend>Zugang</legend>
<label>
<input
type="radio"
name="mode"
value="Invite"
checked={mode === "Invite"}
onChange={() => setMode("Invite")}
/>
Einladung per Mail (Mitarbeiter setzt eigenes Passwort)
</label>
<label>
<input
type="radio"
name="mode"
value="Direct"
checked={mode === "Direct"}
onChange={() => setMode("Direct")}
/>
Passwort direkt vergeben (muss beim ersten Login geändert werden)
</label>
</fieldset>
{mode === "Invite" && (
<label className="form-field">
<span>PIN gültig für</span>
<select
value={pinValidityDays}
onChange={(event) => setPinValidityDays(Number(event.target.value))}
>
{PIN_VALIDITY_OPTIONS.map((days) => (
<option key={days} value={days}>
{days} {days === 1 ? "Tag" : "Tage"}
{days === DEFAULT_PIN_VALIDITY_DAYS ? " (Standard)" : ""}
</option>
))}
</select>
</label>
)}
{mode === "Direct" && (
<label className="form-field">
<span>Initiales Passwort</span>
<input
type="text"
value={initialPassword}
onChange={(event) => setInitialPassword(event.target.value)}
placeholder="Mindestens 8 Zeichen"
/>
</label>
)}
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Anlegen" />
</form>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useState } from "react";
import EmployeeForm, { employeeFormToPayload, employeeToFormValues, FormActions } from "./EmployeeForm";
function errorMessage(result) {
if (result.status === 403) {
return "Keine Berechtigung, Mitarbeiter zu bearbeiten.";
}
if (result.status === 400) {
return typeof result.data === "string" ? result.data : "Eingaben prüfen.";
}
return "Mitarbeiter konnte nicht gespeichert werden.";
}
export default function EditEmployeeDialog({ employee, onClose, onUpdated }) {
const [form, setForm] = useState(() => employeeToFormValues(employee));
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(event) {
event.preventDefault();
if (!form.firstName.trim() || !form.lastName.trim()) {
setError("Vorname und Nachname sind erforderlich.");
return;
}
setIsSaving(true);
setError(null);
const payload = employeeFormToPayload(form, { includeStatus: true });
const result = await window.omsorg.employees.update(employee.id, payload);
setIsSaving(false);
if (!result.ok) {
setError(errorMessage(result));
return;
}
onUpdated(result.data);
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="Mitarbeiter bearbeiten">
<div className="modal-panel">
<h2>{`${employee.firstName} ${employee.lastName}`.trim()} bearbeiten</h2>
<form onSubmit={handleSubmit}>
<EmployeeForm form={form} onChange={setForm} includeStatus />
{error && <p className="login-error form-error">{error}</p>}
<FormActions onCancel={onClose} isSaving={isSaving} submitLabel="Speichern" />
</form>
</div>
</div>
);
}
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import { Pencil } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
import OmsorgButton from "../../components/ui/OmsorgButton";
import { useAuth } from "../../app/AuthContext";
import EmployeeTabs from "./EmployeeTabs";
import EditEmployeeDialog from "./EditEmployeeDialog";
function getInitials(firstName = "", lastName = "") {
return `${firstName[0] ?? ""}${lastName[0] ?? ""}`.toUpperCase();
}
function formatDate(value) {
if (!value) return "—";
return new Date(value).toLocaleDateString("de-DE");
}
function formatAddress(employee) {
const line = [employee.postalCode, employee.city].filter(Boolean).join(" ");
const parts = [employee.street, line].filter(Boolean);
if (employee.country && employee.country !== "Deutschland") {
parts.push(employee.country);
}
return parts.length > 0 ? parts.join(", ") : "—";
}
export default function EmployeeDetailPanel({ employee, onUpdated }) {
const { hasPermission } = useAuth();
const canEdit = hasPermission("Employees", "Edit");
const [activeTab, setActiveTab] = useState("overview");
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
useEffect(() => {
setActiveTab("overview");
}, [employee?.id]);
if (!employee) {
return (
<OmsorgCard>
<div className="employee-detail-empty">
<h2>Keine Personalakte ausgewählt</h2>
<p>Wähle links einen Mitarbeiter aus.</p>
</div>
</OmsorgCard>
);
}
const renderTabContent = () => {
switch (activeTab) {
case "documents":
return (
<div className="employee-tab-content">
<h3>Dokumente</h3>
<p>
Hier werden später Arbeitsverträge,
Nachweise und weitere Dokumente angezeigt.
</p>
</div>
);
case "contracts":
return (
<div className="employee-tab-content">
<h3>Verträge</h3>
<p>
Hier erscheint später der Arbeitsvertrag
mit Beginn, Ende und Konditionen.
</p>
</div>
);
case "qualifications":
return (
<div className="employee-tab-content">
<h3>Qualifikationen</h3>
<p>
Hier erscheinen später Ausbildung,
Zertifikate und Fortbildungen.
</p>
</div>
);
case "assignments":
return (
<div className="employee-tab-content">
<h3>Einsätze</h3>
<p>
Hier wird später die komplette
Einsatzhistorie angezeigt.
</p>
</div>
);
case "overview":
default:
return (
<div className="employee-detail-grid">
<div>
<strong>Telefon</strong>
<p>{employee.phoneNumber ?? "—"}</p>
</div>
<div>
<strong>E-Mail</strong>
<p>{employee.email ?? "—"}</p>
</div>
<div>
<strong>Adresse</strong>
<p>{formatAddress(employee)}</p>
</div>
<div>
<strong>Geburtsdatum</strong>
<p>{formatDate(employee.dateOfBirth)}</p>
</div>
<div>
<strong>Eintritt</strong>
<p>{formatDate(employee.entryDate)}</p>
</div>
<div>
<strong>Austritt</strong>
<p>{formatDate(employee.exitDate)}</p>
</div>
<div>
<strong>Beschäftigungsart</strong>
<p>{employee.employmentType ?? "—"}</p>
</div>
<div>
<strong>Qualifikation</strong>
<p>{employee.qualification ?? "—"}</p>
</div>
<div>
<strong>Notfallkontakt</strong>
<p>
{employee.emergencyContactName
? `${employee.emergencyContactName}${
employee.emergencyContactRelation ? ` (${employee.emergencyContactRelation})` : ""
}${employee.emergencyContactPhone ? `${employee.emergencyContactPhone}` : ""}`
: "—"}
</p>
</div>
</div>
);
}
};
return (
<OmsorgCard>
<div className="employee-detail">
<div className="employee-detail-header">
<div className="employee-detail-avatar">
{getInitials(employee.firstName, employee.lastName)}
</div>
<div>
<h2>{`${employee.firstName} ${employee.lastName}`.trim()}</h2>
<OmsorgBadge status={employee.status?.toLowerCase()} />
</div>
{canEdit && (
<OmsorgButton variant="secondary" icon={Pencil} onClick={() => setIsEditDialogOpen(true)}>
Bearbeiten
</OmsorgButton>
)}
</div>
<EmployeeTabs
activeTab={activeTab}
onChange={setActiveTab}
/>
{renderTabContent()}
</div>
{isEditDialogOpen && (
<EditEmployeeDialog
employee={employee}
onClose={() => setIsEditDialogOpen(false)}
onUpdated={(updatedEmployee) => {
setIsEditDialogOpen(false);
onUpdated?.(updatedEmployee);
}}
/>
)}
</OmsorgCard>
);
}
@@ -0,0 +1,214 @@
import OmsorgButton from "../../components/ui/OmsorgButton";
export const EMPLOYMENT_TYPES = [
"Vollzeit",
"Teilzeit",
"Minijob",
"Aushilfe",
"Praktikant",
"Freiberuflich",
];
export const STATUS_OPTIONS = ["Aktiv", "Einsatz", "Urlaub", "Krank"];
export const emptyEmployeeForm = {
firstName: "",
lastName: "",
status: "Aktiv",
dateOfBirth: "",
street: "",
postalCode: "",
city: "",
country: "",
phoneNumber: "",
email: "",
entryDate: "",
exitDate: "",
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
employmentType: "",
qualification: "",
};
export function employeeToFormValues(employee) {
return {
firstName: employee.firstName ?? "",
lastName: employee.lastName ?? "",
status: employee.status ?? "Aktiv",
dateOfBirth: employee.dateOfBirth ?? "",
street: employee.street ?? "",
postalCode: employee.postalCode ?? "",
city: employee.city ?? "",
country: employee.country ?? "",
phoneNumber: employee.phoneNumber ?? "",
email: employee.email ?? "",
entryDate: employee.entryDate ?? "",
exitDate: employee.exitDate ?? "",
emergencyContactName: employee.emergencyContactName ?? "",
emergencyContactPhone: employee.emergencyContactPhone ?? "",
emergencyContactRelation: employee.emergencyContactRelation ?? "",
employmentType: employee.employmentType ?? "",
qualification: employee.qualification ?? "",
};
}
export function employeeFormToPayload(form, { includeStatus = false } = {}) {
const payload = {
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
dateOfBirth: form.dateOfBirth || null,
street: form.street.trim() || null,
postalCode: form.postalCode.trim() || null,
city: form.city.trim() || null,
country: form.country.trim() || null,
phoneNumber: form.phoneNumber.trim() || null,
email: form.email.trim() || null,
entryDate: form.entryDate || null,
exitDate: form.exitDate || null,
emergencyContactName: form.emergencyContactName.trim() || null,
emergencyContactPhone: form.emergencyContactPhone.trim() || null,
emergencyContactRelation: form.emergencyContactRelation.trim() || null,
employmentType: form.employmentType || null,
qualification: form.qualification.trim() || null,
};
if (includeStatus) {
payload.status = form.status;
}
return payload;
}
export default function EmployeeForm({ form, onChange, includeStatus = false }) {
function updateField(field) {
return (event) => onChange({ ...form, [field]: event.target.value });
}
return (
<div className="form-grid">
<label className="form-field">
<span>Vorname *</span>
<input value={form.firstName} onChange={updateField("firstName")} required />
</label>
<label className="form-field">
<span>Nachname *</span>
<input value={form.lastName} onChange={updateField("lastName")} required />
</label>
{includeStatus && (
<label className="form-field">
<span>Status</span>
<select value={form.status} onChange={updateField("status")}>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)}
<label className="form-field">
<span>Geburtsdatum</span>
<input type="date" value={form.dateOfBirth} onChange={updateField("dateOfBirth")} />
</label>
<fieldset className="form-field form-field--fieldset">
<legend>Adresse</legend>
<label className="form-field">
<span>Straße und Hausnummer</span>
<input value={form.street} onChange={updateField("street")} />
</label>
<div className="form-row">
<label className="form-field">
<span>PLZ</span>
<input value={form.postalCode} onChange={updateField("postalCode")} />
</label>
<label className="form-field">
<span>Ort</span>
<input value={form.city} onChange={updateField("city")} />
</label>
</div>
<label className="form-field">
<span>Land</span>
<input value={form.country} onChange={updateField("country")} />
</label>
</fieldset>
<label className="form-field">
<span>Telefon</span>
<input value={form.phoneNumber} onChange={updateField("phoneNumber")} />
</label>
<label className="form-field">
<span>E-Mail</span>
<input type="email" value={form.email} onChange={updateField("email")} />
</label>
<label className="form-field">
<span>Eintrittsdatum</span>
<input type="date" value={form.entryDate} onChange={updateField("entryDate")} />
</label>
<label className="form-field">
<span>Austrittsdatum</span>
<input type="date" value={form.exitDate} onChange={updateField("exitDate")} />
</label>
<label className="form-field">
<span>Beschäftigungsart</span>
<select value={form.employmentType} onChange={updateField("employmentType")}>
<option value=""> nicht angegeben </option>
{EMPLOYMENT_TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Qualifikation</span>
<input value={form.qualification} onChange={updateField("qualification")} />
</label>
<fieldset className="form-field form-field--fieldset">
<legend>Notfallkontakt</legend>
<label className="form-field">
<span>Name</span>
<input value={form.emergencyContactName} onChange={updateField("emergencyContactName")} />
</label>
<label className="form-field">
<span>Telefon</span>
<input value={form.emergencyContactPhone} onChange={updateField("emergencyContactPhone")} />
</label>
<label className="form-field">
<span>Beziehung</span>
<input value={form.emergencyContactRelation} onChange={updateField("emergencyContactRelation")} />
</label>
</fieldset>
</div>
);
}
export function FormActions({ onCancel, isSaving, submitLabel }) {
return (
<div className="form-actions">
<OmsorgButton variant="secondary" type="button" onClick={onCancel} disabled={isSaving}>
Abbrechen
</OmsorgButton>
<OmsorgButton type="submit" disabled={isSaving}>
{isSaving ? "Speichert..." : submitLabel}
</OmsorgButton>
</div>
);
}
@@ -0,0 +1,56 @@
const tabs = [
{
id: "overview",
label: "Übersicht",
},
{
id: "documents",
label: "Dokumente",
},
{
id: "contracts",
label: "Verträge",
},
{
id: "qualifications",
label: "Qualifikationen",
},
{
id: "assignments",
label: "Einsätze",
},
];
export default function EmployeeTabs({
activeTab = "overview",
onChange,
}) {
return (
<div
className="employee-tabs"
role="tablist"
aria-label="Bereiche der Personalakte"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
className={
isActive
? "employee-tab employee-tab--active"
: "employee-tab"
}
onClick={() => onChange?.(tab.id)}
>
{tab.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,328 @@
import { useCallback, useEffect, useState } from "react";
import {
Plus,
Search,
SlidersHorizontal,
UserPlus,
} from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
import OmsorgPagination from "../../components/ui/OmsorgPagination";
import { useAuth } from "../../app/AuthContext";
import EmployeeDetailPanel from "./EmployeeDetailPanel";
import CreateEmployeeDialog from "./CreateEmployeeDialog";
import CreateUserAccountDialog from "./CreateUserAccountDialog";
import { STATUS_OPTIONS, EMPLOYMENT_TYPES } from "./EmployeeForm";
const PAGE_SIZE = 20;
function getInitials(firstName = "", lastName = "") {
return `${firstName[0] ?? ""}${lastName[0] ?? ""}`.toUpperCase();
}
function fullName(employee) {
return `${employee.firstName} ${employee.lastName}`.trim();
}
export default function EmployeesPage() {
const { hasPermission } = useAuth();
const canCreate = hasPermission("Employees", "Create");
const canViewAccounts = hasPermission("UserManagement", "View");
const canCreateAccounts = hasPermission("UserManagement", "Create");
const [employees, setEmployees] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedEmployeeId, setSelectedEmployeeId] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [employmentTypeFilter, setEmploymentTypeFilter] = useState("");
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [page, setPage] = useState(1);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [employeeIdsWithAccount, setEmployeeIdsWithAccount] = useState(new Set());
const [accountDialogEmployee, setAccountDialogEmployee] = useState(null);
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchTerm.trim()), 300);
return () => clearTimeout(timer);
}, [searchTerm]);
useEffect(() => {
setPage(1);
}, [debouncedSearch, statusFilter, employmentTypeFilter]);
const loadAccountStatus = useCallback(async () => {
if (!canViewAccounts) return;
const result = await window.omsorg.users.list();
if (result.ok) {
const ids = (result.data ?? [])
.map((user) => user.employeeId)
.filter((id) => id != null);
setEmployeeIdsWithAccount(new Set(ids));
}
}, [canViewAccounts]);
const loadEmployees = useCallback(async () => {
setIsLoading(true);
setError(null);
const result = await window.omsorg.employees.list({
search: debouncedSearch || undefined,
status: statusFilter || undefined,
employmentType: employmentTypeFilter || undefined,
page,
pageSize: PAGE_SIZE,
});
if (result.ok) {
const data = result.data ?? { items: [], totalCount: 0 };
setEmployees(data.items ?? []);
setTotalCount(data.totalCount ?? 0);
setSelectedEmployeeId((current) =>
(data.items ?? []).some((employee) => employee.id === current)
? current
: data.items?.[0]?.id ?? null
);
} else {
setError("Mitarbeiter konnten nicht geladen werden.");
}
setIsLoading(false);
}, [debouncedSearch, statusFilter, employmentTypeFilter, page]);
useEffect(() => {
loadEmployees();
}, [loadEmployees]);
useEffect(() => {
loadAccountStatus();
}, [loadAccountStatus]);
// Mitarbeiter ohne Account zuerst (das sind die offenen Fälle) - nur die aktuell geladene Seite,
// die Mitarbeiterliste selbst bleibt serverseitig paginiert.
const sortedEmployees = canViewAccounts
? [...employees].sort((a, b) => {
const aHasAccount = employeeIdsWithAccount.has(a.id) ? 1 : 0;
const bHasAccount = employeeIdsWithAccount.has(b.id) ? 1 : 0;
return aHasAccount - bHasAccount;
})
: employees;
function handleAccountCreated() {
setAccountDialogEmployee(null);
loadAccountStatus();
}
const selectedEmployee = employees.find((employee) => employee.id === selectedEmployeeId) ?? null;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
function handleCreated(createdEmployee) {
setIsDialogOpen(false);
loadEmployees();
if (createdEmployee?.id) {
setSelectedEmployeeId(createdEmployee.id);
}
}
function handleUpdated() {
loadEmployees();
}
return (
<div className="employees-page">
<div className="page-heading">
<div>
<p className="page-eyebrow">
Personalverwaltung
</p>
<h1>Mitarbeiter</h1>
<p className="page-description">
Verwalte Personaldaten, Status, Verträge
und Dokumente an einem Ort.
</p>
</div>
{canCreate && (
<OmsorgButton icon={Plus} onClick={() => setIsDialogOpen(true)}>
Neuer Mitarbeiter
</OmsorgButton>
)}
</div>
<OmsorgCard>
<div className="employees-toolbar">
<div className="employees-search">
<Search size={18} />
<input
type="search"
value={searchTerm}
onChange={(event) =>
setSearchTerm(event.target.value)
}
placeholder="Mitarbeiter suchen..."
aria-label="Mitarbeiter suchen"
/>
</div>
<OmsorgButton
variant="secondary"
icon={SlidersHorizontal}
onClick={() => setIsFilterOpen((open) => !open)}
>
Filter
</OmsorgButton>
</div>
{isFilterOpen && (
<div className="employees-filter-panel">
<label className="form-field">
<span>Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="">Alle</option>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="form-field">
<span>Beschäftigungsart</span>
<select
value={employmentTypeFilter}
onChange={(event) => setEmploymentTypeFilter(event.target.value)}
>
<option value="">Alle</option>
{EMPLOYMENT_TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
)}
</OmsorgCard>
{error && <p className="login-error">{error}</p>}
{isLoading && <p>Lädt...</p>}
{!isLoading && (
<div className="employees-layout">
<div className="employees-list">
{sortedEmployees.map((employee) => {
const isSelected = employee.id === selectedEmployeeId;
const hasAccount = employeeIdsWithAccount.has(employee.id);
return (
<div key={employee.id} className="employee-row-wrapper">
<button
type="button"
className={
isSelected
? "employee-list-button employee-list-button--active"
: "employee-list-button"
}
onClick={() =>
setSelectedEmployeeId(employee.id)
}
>
<OmsorgCard>
<div className="employee-row">
<div className="employee-avatar">
{getInitials(employee.firstName, employee.lastName)}
</div>
<div className="employee-main">
<strong>{fullName(employee)}</strong>
</div>
{canViewAccounts && (
<span
className={
hasAccount
? "omsorg-badge omsorg-badge--success"
: "omsorg-badge omsorg-badge--neutral"
}
>
{hasAccount ? "Account vorhanden" : "Kein Account"}
</span>
)}
<div className="employee-status">
<OmsorgBadge
status={employee.status?.toLowerCase()}
/>
</div>
</div>
</OmsorgCard>
</button>
{canCreateAccounts && !hasAccount && (
<OmsorgButton
variant="secondary"
icon={UserPlus}
onClick={(event) => {
event.stopPropagation();
setAccountDialogEmployee(employee);
}}
>
Account anlegen
</OmsorgButton>
)}
</div>
);
})}
{employees.length === 0 && (
<OmsorgCard>
<div className="employees-empty-state">
<strong>
Kein Mitarbeiter gefunden
</strong>
<span>
Prüfe den eingegebenen Suchbegriff oder die Filter.
</span>
</div>
</OmsorgCard>
)}
{employees.length > 0 && (
<OmsorgPagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
<EmployeeDetailPanel
employee={selectedEmployee}
onUpdated={handleUpdated}
/>
</div>
)}
{isDialogOpen && (
<CreateEmployeeDialog
onClose={() => setIsDialogOpen(false)}
onCreated={handleCreated}
/>
)}
{accountDialogEmployee && (
<CreateUserAccountDialog
employee={accountDialogEmployee}
onClose={() => setAccountDialogEmployee(null)}
onCreated={handleAccountCreated}
/>
)}
</div>
);
}
@@ -0,0 +1,142 @@
import {
CalendarClock,
FileText,
Mail,
Phone,
} from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
const contracts = [
{
id: 1,
facility: "AWO Seniorenzentrum",
employee: "Pascal Galliot",
endDate: "25.07.2026",
daysRemaining: 7,
urgency: "danger",
phone: "07231 123456",
email: "disposition@awo-pforzheim.de",
},
{
id: 2,
facility: "Luisenschwestern",
employee: "Carina Vetter",
endDate: "05.08.2026",
daysRemaining: 18,
urgency: "warning",
phone: "07231 234567",
email: "pflege@luisenschwestern.de",
},
{
id: 3,
facility: "ASB Pforzheim",
employee: "Dilara Anders",
endDate: "22.08.2026",
daysRemaining: 35,
urgency: "info",
phone: "07231 345678",
email: "personal@asb-pforzheim.de",
},
];
function getUrgencyLabel(contract) {
if (contract.daysRemaining <= 7) {
return "Sofort handeln";
}
if (contract.daysRemaining <= 21) {
return "Demnächst";
}
return "Information";
}
export default function ContractWidget() {
return (
<OmsorgCard title="Vertragsverlängerungen">
<div className="contract-widget">
<div className="contract-widget__intro">
<CalendarClock size={20} />
<p>
Verträge von vermittelten Mitarbeitern,
die demnächst enden.
</p>
</div>
<div className="contract-widget__list">
{contracts.map((contract) => (
<article
key={contract.id}
className={`contract-item contract-item--${contract.urgency}`}
>
<div className="contract-item__header">
<div>
<strong>{contract.facility}</strong>
<span>
{contract.employee}
</span>
</div>
<OmsorgBadge
status={
contract.daysRemaining <= 7
? "krank"
: contract.daysRemaining <= 21
? "urlaub"
: "einsatz"
}
/>
</div>
<div className="contract-item__meta">
<span>
Vertragsende: {contract.endDate}
</span>
<strong>
{contract.daysRemaining === 1
? "endet morgen"
: `endet in ${contract.daysRemaining} Tagen`}
</strong>
<small>
{getUrgencyLabel(contract)}
</small>
</div>
<div className="contract-item__actions">
<a
href={`tel:${contract.phone.replace(/\s/g, "")}`}
className="contract-action"
>
<Phone size={16} />
Anrufen
</a>
<a
href={`mailto:${contract.email}`}
className="contract-action"
>
<Mail size={16} />
E-Mail
</a>
<button
type="button"
className="contract-action"
>
<FileText size={16} />
Vertrag öffnen
</button>
</div>
</article>
))}
</div>
</div>
</OmsorgCard>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Plus, Building2, CalendarPlus, Calculator } from "lucide-react";
import OmsorgCard from "../../components/OmsorgCard";
import OmsorgButton from "../../components/ui/OmsorgButton";
import HomeStats from "./HomeStats";
import OmsorgBadge from "../../components/ui/OmsorgBadge";
export default function HomePage() {
return (
<>
<OmsorgCard>
<h2>
Willkommen bei
<span className="accent">
{" "}Omsorg Business Controls Pro
</span>
</h2>
<p>
Schön, dass ihr heute wieder da seid.
Hier beginnt euer Arbeitstag.
</p>
</OmsorgCard><div className="badge-test-row">
<OmsorgBadge status="aktiv" />
<OmsorgBadge status="einsatz" />
<OmsorgBadge status="urlaub" />
<OmsorgBadge status="krank" />
<OmsorgBadge status="vertrag" />
</div>
<HomeStats />
<section className="dashboard-grid home-grid">
<OmsorgCard title="Heute zu erledigen">
<ul className="home-task-list">
<li> Vertrag verlängern</li>
<li>📄 3 Rechnungen offen</li>
<li>👤 Neuer Mitarbeiter beginnt</li>
<li> Führerschein läuft bald ab</li>
</ul>
</OmsorgCard>
<OmsorgCard title="Schnellaktionen">
<div className="home-actions">
<OmsorgButton icon={Plus}>
Neuer Mitarbeiter
</OmsorgButton>
<OmsorgButton
variant="secondary"
icon={Building2}
>
Neue Einrichtung
</OmsorgButton>
<OmsorgButton
variant="secondary"
icon={CalendarPlus}
>
Neuer Auftrag
</OmsorgButton>
<OmsorgButton
variant="secondary"
icon={Calculator}
>
Neue Kalkulation
</OmsorgButton>
</div>
</OmsorgCard>
</section>
</>
);
}<OmsorgCard>
<h2>
Willkommen bei
<span className="accent"> Omsorg Business Controls Pro</span>
</h2>
<p>
Schön, dass ihr heute wieder da seid.
Hier beginnt euer Arbeitstag.
</p>
<div className="badge-test-row">
<OmsorgBadge status="aktiv" />
<OmsorgBadge status="einsatz" />
<OmsorgBadge status="urlaub" />
<OmsorgBadge status="krank" />
<OmsorgBadge status="vertrag" />
</div>
</OmsorgCard>
+70
View File
@@ -0,0 +1,70 @@
import {
Euro,
Users,
Building2,
FileText,
CalendarClock,
Activity,
} from "lucide-react";
import OmsorgStatCard from "../../components/ui/OmsorgStatCard";
export default function HomeStats() {
return (
<section className="home-stats-grid">
<OmsorgStatCard
title="Gewinn Woche"
value="18.540 €"
subtitle="+12 % zum Vormonat"
trend="up"
icon={Euro}
/>
<OmsorgStatCard
title="Aktive Mitarbeiter"
value="12"
subtitle="+1 neuer Mitarbeiter"
trend="up"
icon={Users}
/>
<OmsorgStatCard
title="Kunden"
value="8"
subtitle="stabil"
trend="neutral"
icon={Building2}
/>
<OmsorgStatCard
title="Offene Rechnungen"
value="5"
subtitle="-2 diese Woche"
trend="down"
icon={FileText}
/>
<OmsorgStatCard
title="Verträge"
value="3"
subtitle="laufen bald aus"
trend="neutral"
icon={CalendarClock}
/>
<OmsorgStatCard
title="Auslastung"
value="96 %"
subtitle="+4 %"
trend="up"
icon={Activity}
/>
</section>
);
}
+1
View File
@@ -0,0 +1 @@
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#eefbff;background:#06111d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 10%,rgba(104,210,223,.35),transparent 28%),linear-gradient(135deg,#07111d 0%,#0b2a35 45%,#11111f 100%);min-height:100vh}.loading{padding:40px}.app{display:flex;min-height:100vh}.sidebar{width:285px;padding:24px;background:rgba(5,15,26,.72);backdrop-filter:blur(18px);border-right:1px solid rgba(104,210,223,.16)}.brand{display:flex;gap:12px;align-items:center;margin-bottom:28px}.brand img{width:86px;height:auto}.brand b{display:block}.brand span,.muted,.row span,.sidecard span,header p{color:#a9c9d3;font-size:13px}nav{display:grid;gap:8px}button,.button{border:0;border-radius:14px;background:rgba(255,255,255,.08);color:#eefbff;padding:12px 14px;display:inline-flex;gap:10px;align-items:center;cursor:pointer;text-decoration:none}button:hover,.button:hover,nav button.active{background:linear-gradient(135deg,rgba(104,210,223,.30),rgba(104,210,223,.10));box-shadow:0 10px 30px rgba(0,0,0,.18)}nav button{width:100%;justify-content:flex-start}.sidecard{margin-top:30px;padding:14px;border:1px solid rgba(104,210,223,.14);border-radius:18px;background:rgba(255,255,255,.05);display:flex;gap:10px}main{flex:1;padding:28px 34px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}h1,h2,h3{margin:0 0 8px}header h1{font-size:34px}.header-actions{display:flex;gap:10px}.bell{position:relative}.bell span{position:absolute;top:-6px;right:-6px;background:#68d2df;color:#11111f;border-radius:999px;padding:2px 7px;font-weight:800}.grid{display:grid;grid-template-columns:1.4fr 1fr;gap:18px}.wide{grid-column:1/-1}.card{border:1px solid rgba(104,210,223,.16);background:linear-gradient(180deg,rgba(255,255,255,.10),rgba(255,255,255,.045));border-radius:24px;padding:22px;box-shadow:0 24px 70px rgba(0,0,0,.22)}.hero{min-height:190px}.chips{display:flex;gap:10px;margin-top:22px}.chips span{background:rgba(104,210,223,.14);border:1px solid rgba(104,210,223,.20);padding:9px 12px;border-radius:999px}.warning{display:flex;width:100%;justify-content:flex-start;background:rgba(255,190,90,.14);border:1px solid rgba(255,190,90,.20);margin:8px 0}.quick{display:flex;flex-wrap:wrap;gap:10px}.form{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:18px}input,select{width:100%;border:1px solid rgba(104,210,223,.18);border-radius:14px;padding:12px;background:rgba(0,0,0,.2);color:#eefbff}label{display:grid;gap:7px;color:#a9c9d3}.list{display:grid;gap:10px}.row{display:flex;justify-content:space-between;align-items:center;padding:16px 18px;border:1px solid rgba(104,210,223,.12);border-radius:18px;background:rgba(255,255,255,.055);cursor:pointer}.row b{display:block}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);display:grid;place-items:center;padding:20px}.modal{width:min(620px,95vw);background:#0b1a2a;border:1px solid rgba(104,210,223,.22);border-radius:24px;padding:24px;box-shadow:0 30px 80px rgba(0,0,0,.45);position:relative}.close{position:absolute;top:14px;right:14px;font-size:22px;padding:8px 12px}.toast{position:fixed;right:24px;bottom:24px;background:#68d2df;color:#11111f;border-radius:16px;padding:14px 18px;font-weight:800}.big{font-size:38px;font-weight:900;color:#68d2df;margin:20px 0}code{display:inline-block;padding:12px;border-radius:12px;background:rgba(0,0,0,.28);color:#bdf6ff;word-break:break-all}a{color:#68d2df}
File diff suppressed because it is too large Load Diff