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:
co-authored by
Claude Sonnet 5
parent
8beb0fcf52
commit
b6c1389c55
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
.env
|
||||
@@ -0,0 +1,46 @@
|
||||
# CLAUDE.md — omsorgapp (OMSORG Desktop)
|
||||
|
||||
Gilt zusätzlich zur Root-`CLAUDE.md`. Dies ist das Electron/React-Desktop-Projekt für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha).
|
||||
|
||||
## Stack & Struktur
|
||||
|
||||
- **Electron** (Hauptprozess) + **React 18** + **Vite** (Renderer), reines JSX (kein TS trotz `typescript`-Dependency — bisher ungenutzt).
|
||||
- `electron/main.cjs` — Electron-Hauptprozess. Legt beim Start `~/Documents/Omsorg Business Controls Pro/` mit Unterordnern (`database/`, `documents/employees`, `documents/customers`, `backups/`) an. Aktuell einzige lokale Datenhaltung (für die Fachmodule, nicht für Auth): **eine lokale JSON-Datei** (`database/omsorg-local-db.json`), gelesen/geschrieben über IPC-Handler (`db:get`, `db:set`, `app:paths`, `app:openRoot`, `backup:create`).
|
||||
- `electron/backend/` — Adapterschicht zu `omsorgCore` (HTTP), ausschließlich im Main-Prozess genutzt: `config.cjs` (einzige Stelle mit der Backend-URL, Default `http://localhost:5245`, überschreibbar per `OMSORG_CORE_URL`), `httpClient.cjs` (einzige Stelle mit `fetch`/Auth-Header), `authClient.cjs` (`login`/`refresh`/`logout` gegen `/api/auth/*`), `employeesClient.cjs` (`list`/`get`/`create`/`update` gegen `/api/employees`, kein `delete` da der Endpunkt in `omsorgCore` fehlt). Für künftige Ressourcen (Einrichtungen, ...) entsteht nach demselben Muster je eine eigene `<kategorie>Client.cjs`-Datei, analog zu den Controllern in `omsorgCore` — siehe `main.cjs`s `api:get`/`api:post`-Proxy für Ressourcen ohne eigene Client-Datei.
|
||||
- **Login + Session:** `main.cjs` hält den Access-Token nur im Speicher, verschlüsselt den Refresh-Token via `safeStorage` und speichert ihn unter `app.getPath('userData')/session.enc`. Silent Refresh läuft per Timer (kurz vor Ablauf) und reaktiv bei 401 (z. B. nach Laptop-Standby). IPC-Handler: `auth:login`, `auth:logout`, `auth:getSession`, `api:get`/`api:post` (authentifizierter Proxy). Kein Token verlässt je den Main-Prozess in Richtung Renderer.
|
||||
- `electron/preload.cjs` — exponiert `window.omsorg` (contextBridge) mit `getDb`, `setDb`, `paths`, `openRoot`, `createBackup`, `auth.{login,logout,getSession,onSessionChanged}`, `api.{get,post}`. Renderer darf nie direkt auf Node/`fs` zugreifen — immer über diese Brücke.
|
||||
- `src/main.jsx` — Renderer-Einstiegspunkt, wrappt `App` in `AuthProvider` (`src/app/AuthContext.jsx`) und rendert in `#root`.
|
||||
- `src/app/AuthContext.jsx` — React-Context um `window.omsorg.auth`, stellt `useAuth()` mit `{ isAuthenticated, user, isLoading, login, logout }` bereit.
|
||||
- `src/app/app.jsx` — gated zuerst auf Auth (`isLoading` → Ladehinweis, `!isAuthenticated` → `src/modules/auth/LoginPage.jsx`), danach Top-Level-Router: hält `activePage` als lokalen State (kein Routing-Framework), switcht zwischen Modulen. Noch nicht implementierte Module rendern `PlaceholderPage`.
|
||||
- `src/layouts/AppLayout.jsx` — Grundlayout: `Sidebar` + `Header` + `main.page-content`.
|
||||
- `src/components/` — geteilte UI: `Sidebar`, `Header`, `OmsorgCard`, und `components/ui/` (`OmsorgButton`, `OmsorgBadge`, `OmsorgStatCard`).
|
||||
- `src/modules/<modulname>/` — ein Ordner pro Fachmodul, z. B. `modules/home/` (HomePage, HomeStats, ContractWidget), `modules/employees/` (EmployeesPage, EmployeeDetailPanel, EmployeeTabs), `modules/debug/` (`DebugSessionsPage.jsx`). Neue Module folgen diesem Muster: eigener Ordner unter `src/modules/`, Einstiegskomponente `<Modul>Page.jsx`.
|
||||
- **Debug-Sicht (`modules/debug/DebugSessionsPage.jsx`):** listet aktive Sessions (`GET /api/admin/sessions` über den generischen `api:get`-Proxy), erlaubt Einzel- und Gesamt-Widerruf (`POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`) — invalidiert serverseitig sofort alle betroffenen Access-Tokens (Details: `omsorgCore/CLAUDE.md` Abschnitt "Session-Killswitch"). Im `Sidebar.jsx`-Menü nur sichtbar, wenn `useAuth().hasPermission("UserManagement", "View")` — dieselbe granulare Rechteprüfung, die serverseitig über `RequirePermission(ModuleType.UserManagement, ...)` auf `AdminSessionsController` erzwungen wird (kein Rollennamen-Vergleich mehr, siehe "Rechtesystem im Client" unten).
|
||||
- **Rechtesystem im Client:** Rechte kommen nicht aus dem JWT (das trägt nur `sub`/`name`/`role`/`sst`). `main.cjs` lädt nach jedem Login/Refresh zusätzlich `GET /api/auth/me` (`electron/backend/authClient.cjs#me`) und ersetzt `session.user` durch `{ username, role, permissions }` — `permissions` ist die vom Backend aufgelöste Liste aus `PermissionService.GetGrantedPermissionsAsync` (Rollen-Default + Overrides, je `{ module, action }` als String). `AuthContext.jsx` stellt darauf `hasPermission(module, action)` bereit; UI-Komponenten prüfen darüber, nie über `user.role` direkt.
|
||||
- **Verbindliche Regel — UI folgt den Rechten, für jedes Modul:** Jeder Sidebar-Tab und jede Aktion (Anlegen/Bearbeiten/Löschen/...) muss über `hasPermission(module, action)` gegated werden — das ist kein Sonderfall für Mitarbeiter, sondern das Standardmuster für jedes neue Modul (mindestens `View` fürs Sichtbarsein des Tabs, `Create`/`Edit`/... für einzelne Aktionen darin). Referenzimplementierung: `src/modules/employees/EmployeesPage.jsx` (`canCreate = hasPermission("Employees", "Create")`) und `src/modules/employees/EmployeeDetailPanel.jsx` (`canEdit = hasPermission("Employees", "Edit")`).
|
||||
- Die Zuordnung Sidebar-Tab → `ModuleType` steht zentral in `src/app/navPermissions.js` (`NAV_MODULES`) und wird sowohl von `Sidebar.jsx` (blendet nicht erlaubte Tabs aus) als auch von `app.jsx` (fällt auf `"Home"` zurück, falls der aktive Tab durch eine Rechteänderung nicht mehr erlaubt ist) genutzt — neue Zuordnungen nur dort eintragen, nicht duplizieren.
|
||||
- Aktuelles Mapping: Mitarbeiter→`Employees`, Kunden→`Facilities`, Disposition→`Orders`, Rechnungen→`Invoices`, Controlling→`Controlling`, Einstellungen→`UserManagement`, Debug→`UserManagement`. Home hat kein Modul und ist immer sichtbar. Kalkulation und Fahrzeuge sind reine `PlaceholderPage`-Stubs ohne Fachlogik und haben (noch) kein passendes `ModuleType` — bewusst ungegated, bis ein echtes Modul dahintersteht; dann Eintrag in `navPermissions.js` ergänzen (ggf. mit neuem `ModuleType`-Wert in `omsorgCore`, additiv, keine Migration nötig).
|
||||
- `src/style.css` — einziges Stylesheet, kein CSS-Framework (`style.backup.css` ist eine Sicherungskopie, keine aktive Datei).
|
||||
|
||||
## Wichtige Startbefehle
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite-Devserver + Electron parallel (concurrently/wait-on)
|
||||
npm run web # nur Vite, im Browser statt Electron
|
||||
npm run start # nur Electron (erwartet gebauten dist/)
|
||||
```
|
||||
|
||||
## Aktueller Ist-Stand (siehe auch `REQUIREMENTS.md`)
|
||||
|
||||
- Vollständig: `HomePage`, `EmployeesPage` (Grundgerüst; Daten kommen über `electron/backend/employeesClient.cjs` echt aus `omsorgCore`, keine hartcodierten Beispieldaten mehr für dieses Modul), Login-Screen + persistente Session gegen `omsorgCore`.
|
||||
- Alle anderen Menüpunkte (Kunden, Disposition, Kalkulation, Fahrzeuge, Rechnungen, Controlling, Einstellungen) sind reine `PlaceholderPage`-Platzhalter in `app.jsx`.
|
||||
- **Auth und Mitarbeiter sprechen bereits gegen `omsorgCore`** (siehe `employeesClient.cjs`, IPC-Handler `employees:list/create/update` in `main.cjs`), die übrigen Fachmodule (Kunden, ...) noch nicht — die lokale JSON-Datei bleibt für diese vorerst die Datenquelle (Übergangslösung, README: "SQLite wird später sauber über eine entkoppelte Persistenzschicht eingebaut"). Nächster Schritt: weitere Fachmodule nach und nach auf `electron/backend/<kategorie>Client.cjs` + `omsorgCore`-Endpunkte umstellen, analog zum Mitarbeiter-/Auth-Vorbild.
|
||||
- Keine Verbindung zu `omsorgWeb`/MySQL — Mitarbeiterdaten hier sind komplett getrennt von denen in OMSORG Connect. Nicht durch neue Kopplungen/Workarounds "beheben"; die eigentliche Lösung ist die gemeinsame Datenbasis in `omsorgCore`.
|
||||
|
||||
## Konventionen
|
||||
|
||||
- Neue Module: Ordner `src/modules/<name>/<Name>Page.jsx` + Unterkomponenten, in `app.jsx`-Switch eintragen, in `Sidebar.jsx` verlinken.
|
||||
- UI-Bausteine aus `src/components/ui/` wiederverwenden statt neue Button/Badge/Card-Varianten zu bauen.
|
||||
- Jeglicher Dateisystem-/Datenzugriff nur über die `window.omsorg`-Brücke aus `preload.cjs`, nie direkt im Renderer.
|
||||
- **Neue `omsorgCore`-Endpunkte:** pro Endpunkt-Kategorie eine eigene Datei `electron/backend/<kategorie>Client.cjs` (z. B. künftig `employeesClient.cjs` für den `EmployeesController`), die ausschließlich `httpClient.request(...)` nutzt und Routen/Feldnamen genau dieser einen Ressource kapselt — spiegelt die Controller-Aufteilung in `omsorgCore` 1:1. `main.cjs`/IPC-Handler importieren nur diese Client-Dateien, nie `httpClient.cjs` direkt und nie rohe URLs. Solange eine Ressource noch keine eigene Client-Datei hat, läuft sie über den generischen `api:get`/`api:post`-Proxy in `main.cjs`; sobald die Client-Datei existiert, bekommt sie eigene IPC-Kanäle nach demselben Muster wie `auth:*`. Grund: Backend soll austauschbar bleiben, ohne `preload.cjs` oder den React-Teil anzufassen (siehe Adapterschicht oben).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Omsorg Business Controls Pro – Release 0.1.1 Foundation
|
||||
|
||||
Stabile lokale Electron/React-Basis ohne native SQLite-Abhängigkeit.
|
||||
|
||||
## Warum 0.1.1?
|
||||
Release 0.1.0 nutzte `better-sqlite3`. Auf macOS/Electron kann dieses native Modul Versionsprobleme verursachen.
|
||||
Release 0.1.1 speichert Daten zuerst in einer lokalen JSON-Datenbank im Benutzerordner.
|
||||
|
||||
## Start
|
||||
```bash
|
||||
cd ~/Desktop/omsorg-business-controls-pro-release-0.1.1
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Lokale Daten
|
||||
Die App legt Ordner unter `~/Documents/Omsorg Business Controls Pro` an:
|
||||
- database/omsorg-local-db.json
|
||||
- documents/employees
|
||||
- documents/customers
|
||||
- backups
|
||||
|
||||
## Nächster Sprint
|
||||
SQLite wird später sauber über eine entkoppelte Persistenzschicht eingebaut.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
const { request } = require('./httpClient.cjs');
|
||||
|
||||
// Kapselt /api/auth/* von omsorgCore. Kennt die Feldnamen der LoginResponse
|
||||
// (camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt).
|
||||
function toTokenPair(data) {
|
||||
return {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
expiresAt: data.expiresAt,
|
||||
mustChangePassword: !!data.mustChangePassword
|
||||
};
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const result = await request('POST', '/api/auth/login', { body: { username, password } });
|
||||
if (!result.ok) {
|
||||
return { ok: false, status: result.status, error: result.error || result.data?.error };
|
||||
}
|
||||
return { ok: true, ...toTokenPair(result.data) };
|
||||
}
|
||||
|
||||
async function refresh(refreshToken) {
|
||||
const result = await request('POST', '/api/auth/refresh', { body: { refreshToken } });
|
||||
if (!result.ok) return { ok: false, status: result.status };
|
||||
return { ok: true, ...toTokenPair(result.data) };
|
||||
}
|
||||
|
||||
async function logout(refreshToken) {
|
||||
const result = await request('POST', '/api/auth/logout', { body: { refreshToken } });
|
||||
return { ok: result.ok, status: result.status };
|
||||
}
|
||||
|
||||
async function me(accessToken) {
|
||||
const result = await request('GET', '/api/auth/me', { accessToken });
|
||||
if (!result.ok) return { ok: false, status: result.status };
|
||||
return { ok: true, username: result.data.username, role: result.data.role, permissions: result.data.permissions };
|
||||
}
|
||||
|
||||
async function requestPasswordReset(username) {
|
||||
const result = await request('POST', '/api/auth/forgot-password/request', { body: { username } });
|
||||
if (!result.ok) return { ok: false, status: result.status };
|
||||
return { ok: true, status: result.data.status };
|
||||
}
|
||||
|
||||
async function verifyPasswordResetCode(username, pin) {
|
||||
const result = await request('POST', '/api/auth/forgot-password/verify', { body: { username, pin } });
|
||||
if (!result.ok) return { ok: false, status: result.status, error: result.data?.error };
|
||||
return { ok: true, resetToken: result.data.resetToken };
|
||||
}
|
||||
|
||||
async function resetPassword(resetToken, newPassword) {
|
||||
const result = await request('POST', '/api/auth/forgot-password/reset', { body: { resetToken, newPassword } });
|
||||
return { ok: result.ok, status: result.status };
|
||||
}
|
||||
|
||||
async function changePassword(accessToken, currentPassword, newPassword) {
|
||||
const result = await request('POST', '/api/auth/change-password', {
|
||||
body: { currentPassword, newPassword },
|
||||
accessToken
|
||||
});
|
||||
return { ok: result.ok, status: result.status, data: result.data };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
refresh,
|
||||
logout,
|
||||
me,
|
||||
requestPasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
resetPassword,
|
||||
changePassword
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Einzige Stelle im Projekt, die die Adresse von omsorgCore kennt.
|
||||
// Default passt zum lokalen `dotnet run --project src/OmsorgCore.Api` (http-Profil, Port 5245),
|
||||
// überschreibbar per Umgebungsvariable für andere Umgebungen (Produktion, anderer Rechner, ...).
|
||||
module.exports = {
|
||||
API_BASE: process.env.OMSORG_CORE_URL || 'http://localhost:5245'
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
const { request } = require('./httpClient.cjs');
|
||||
|
||||
// Kapselt /api/employees von omsorgCore (EmployeesController). Feldnamen sind
|
||||
// camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt.
|
||||
async function listEmployees(accessToken, { search, status, employmentType, page = 1, pageSize = 20 } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (status) params.set('status', status);
|
||||
if (employmentType) params.set('employmentType', employmentType);
|
||||
params.set('page', page);
|
||||
params.set('pageSize', pageSize);
|
||||
|
||||
return request('GET', `/api/employees?${params.toString()}`, { accessToken });
|
||||
}
|
||||
|
||||
async function createEmployee(accessToken, payload) {
|
||||
return request('POST', '/api/employees', { body: payload, accessToken });
|
||||
}
|
||||
|
||||
async function getEmployee(accessToken, id) {
|
||||
return request('GET', `/api/employees/${id}`, { accessToken });
|
||||
}
|
||||
|
||||
async function updateEmployee(accessToken, id, payload) {
|
||||
return request('PUT', `/api/employees/${id}`, { body: payload, accessToken });
|
||||
}
|
||||
|
||||
module.exports = { listEmployees, createEmployee, getEmployee, updateEmployee };
|
||||
@@ -0,0 +1,34 @@
|
||||
const { API_BASE } = require('./config.cjs');
|
||||
|
||||
// Einzige Stelle, die fetch/API_BASE/Authorization-Header kennt. Kennt keine konkreten
|
||||
// Endpunkte - die kommen aus den <kategorie>Client.cjs-Dateien (z.B. authClient.cjs).
|
||||
async function request(method, path, { body, accessToken } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, data: null, error: err.message };
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return { ok: true, status: 204, data: null };
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
return { ok: response.ok, status: response.status, data };
|
||||
}
|
||||
|
||||
module.exports = { request };
|
||||
@@ -0,0 +1,12 @@
|
||||
const { request } = require('./httpClient.cjs');
|
||||
|
||||
// Kapselt /api/users von omsorgCore (UsersController).
|
||||
async function listUsers(accessToken) {
|
||||
return request('GET', '/api/users', { accessToken });
|
||||
}
|
||||
|
||||
async function createUser(accessToken, payload) {
|
||||
return request('POST', '/api/users', { body: payload, accessToken });
|
||||
}
|
||||
|
||||
module.exports = { listUsers, createUser };
|
||||
@@ -0,0 +1,296 @@
|
||||
const { app, BrowserWindow, ipcMain, shell, safeStorage } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const authClient = require('./backend/authClient.cjs');
|
||||
const httpClient = require('./backend/httpClient.cjs');
|
||||
const employeesClient = require('./backend/employeesClient.cjs');
|
||||
const usersClient = require('./backend/usersClient.cjs');
|
||||
|
||||
app.commandLine.appendSwitch('lang', 'de');
|
||||
|
||||
let mainWindow;
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
let session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false };
|
||||
let refreshTimer = null;
|
||||
const REFRESH_BUFFER_MS = 2 * 60 * 1000;
|
||||
|
||||
function sessionFilePath() { return path.join(app.getPath('userData'), 'session.enc'); }
|
||||
|
||||
function saveRefreshToken(rawToken) {
|
||||
if (!safeStorage.isEncryptionAvailable()) return;
|
||||
fs.writeFileSync(sessionFilePath(), safeStorage.encryptString(rawToken));
|
||||
}
|
||||
function loadRefreshToken() {
|
||||
const file = sessionFilePath();
|
||||
if (!fs.existsSync(file) || !safeStorage.isEncryptionAvailable()) return null;
|
||||
try {
|
||||
return safeStorage.decryptString(fs.readFileSync(file));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function clearRefreshToken() {
|
||||
const file = sessionFilePath();
|
||||
if (fs.existsSync(file)) fs.unlinkSync(file);
|
||||
}
|
||||
|
||||
// Nur für die Anzeige "angemeldet als ..." im Renderer - die Signaturprüfung passiert
|
||||
// serverseitig bei jedem authentifizierten Aufruf, hier wird nichts sicherheitsrelevantes entschieden.
|
||||
function decodeJwtClaims(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function userFromAccessToken(accessToken) {
|
||||
const claims = decodeJwtClaims(accessToken);
|
||||
return {
|
||||
username: claims['name'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null,
|
||||
role: claims['role'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role'] || null
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastSession() {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('auth:sessionChanged', {
|
||||
isAuthenticated: !!session.accessToken,
|
||||
user: session.user,
|
||||
mustChangePassword: session.mustChangePassword
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
if (!session.expiresAt) return;
|
||||
const delay = Math.max(new Date(session.expiresAt).getTime() - Date.now() - REFRESH_BUFFER_MS, 5000);
|
||||
refreshTimer = setTimeout(refreshSilently, delay);
|
||||
}
|
||||
|
||||
function applySession(tokenPair) {
|
||||
session = {
|
||||
accessToken: tokenPair.accessToken,
|
||||
expiresAt: tokenPair.expiresAt,
|
||||
user: userFromAccessToken(tokenPair.accessToken),
|
||||
mustChangePassword: !!tokenPair.mustChangePassword
|
||||
};
|
||||
saveRefreshToken(tokenPair.refreshToken);
|
||||
scheduleRefresh();
|
||||
broadcastSession();
|
||||
refreshPermissions();
|
||||
}
|
||||
|
||||
// Rechte kommen nicht aus dem JWT (das trägt nur den Rollennamen), sondern aus /api/auth/me -
|
||||
// läuft nach dem schnellen JWT-basierten Zwischenstand nach und broadcastet erneut, sobald da.
|
||||
async function refreshPermissions() {
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
const profile = await authClient.me(accessToken);
|
||||
if (!profile.ok || session.accessToken !== accessToken) return;
|
||||
session.user = { username: profile.username, role: profile.role, permissions: profile.permissions };
|
||||
broadcastSession();
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false };
|
||||
clearRefreshToken();
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
broadcastSession();
|
||||
}
|
||||
|
||||
// Proaktiver Timer kurz vor Ablauf UND Fallback bei 401 (z.B. nach Standby) - siehe Plan.
|
||||
async function refreshSilently() {
|
||||
const rawRefreshToken = loadRefreshToken();
|
||||
if (!rawRefreshToken) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
const result = await authClient.refresh(rawRefreshToken);
|
||||
if (!result.ok) {
|
||||
clearSession();
|
||||
return false;
|
||||
}
|
||||
applySession(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrapSession() {
|
||||
const rawRefreshToken = loadRefreshToken();
|
||||
if (!rawRefreshToken) return;
|
||||
const result = await authClient.refresh(rawRefreshToken);
|
||||
if (result.ok) {
|
||||
applySession(result);
|
||||
} else {
|
||||
clearRefreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
// Ruft callFn(accessToken) auf und wiederholt einmal nach Silent-Refresh bei 401 -
|
||||
// gemeinsame Grundlage für den generischen api:*-Proxy und die <kategorie>Client.cjs-Aufrufe.
|
||||
async function withAuthRetry(callFn) {
|
||||
if (!session.accessToken) return { ok: false, status: 401, data: null };
|
||||
let result = await callFn(session.accessToken);
|
||||
if (result.status === 401 && (await refreshSilently())) {
|
||||
result = await callFn(session.accessToken);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function authorizedRequest(method, resourcePath, body) {
|
||||
return withAuthRetry((accessToken) => httpClient.request(method, resourcePath, { body, accessToken }));
|
||||
}
|
||||
|
||||
function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); }
|
||||
function appRoot() {
|
||||
const base = path.join(app.getPath('documents'), 'Omsorg Business Controls Pro');
|
||||
ensureDir(base);
|
||||
ensureDir(path.join(base, 'database'));
|
||||
ensureDir(path.join(base, 'documents'));
|
||||
ensureDir(path.join(base, 'documents', 'employees'));
|
||||
ensureDir(path.join(base, 'documents', 'customers'));
|
||||
ensureDir(path.join(base, 'backups'));
|
||||
return base;
|
||||
}
|
||||
function dbPath() { return path.join(appRoot(), 'database', 'omsorg-local-db.json'); }
|
||||
function defaultData() {
|
||||
return {
|
||||
version: '0.1.1',
|
||||
createdAt: new Date().toISOString(),
|
||||
users: [
|
||||
{ id:'u1', name:'Sabina Dautovic', role:'Geschäftsführung' },
|
||||
{ id:'u2', name:'Malik Neumann', role:'Geschäftsführung' },
|
||||
{ id:'u3', name:'Sabrina Berggötz', role:'Disposition' }
|
||||
],
|
||||
employees: [
|
||||
{ id:'e1', name:'Dirk Artmann', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' },
|
||||
{ id:'e2', name:'Carina Vetter', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' },
|
||||
{ id:'e3', name:'Dilara Anders', qualification:'1-jährige Pflegekraft', status:'Aktiv' },
|
||||
{ id:'e4', name:'Pascal Galliot', qualification:'1-jährige Pflegekraft', status:'Aktiv' }
|
||||
],
|
||||
customers: [],
|
||||
assignments: [],
|
||||
notes: []
|
||||
};
|
||||
}
|
||||
function readDb() {
|
||||
const file = dbPath();
|
||||
if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify(defaultData(), null, 2));
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
}
|
||||
function writeDb(data) { fs.writeFileSync(dbPath(), JSON.stringify(data, null, 2)); return data; }
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 920,
|
||||
minWidth: 1100,
|
||||
minHeight: 760,
|
||||
title: 'Omsorg Business Controls Pro',
|
||||
backgroundColor: '#08111f',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
});
|
||||
if (isDev) mainWindow.loadURL('http://127.0.0.1:5173');
|
||||
else mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
app.whenReady().then(async () => {
|
||||
appRoot();
|
||||
createWindow();
|
||||
await bootstrapSession();
|
||||
broadcastSession();
|
||||
});
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||
|
||||
ipcMain.handle('db:get', () => readDb());
|
||||
ipcMain.handle('db:set', (_event, data) => writeDb(data));
|
||||
ipcMain.handle('app:paths', () => ({ root: appRoot(), db: dbPath() }));
|
||||
ipcMain.handle('app:openRoot', () => shell.openPath(appRoot()));
|
||||
ipcMain.handle('backup:create', () => {
|
||||
const root = appRoot();
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupFile = path.join(root, 'backups', `backup-${stamp}.json`);
|
||||
fs.copyFileSync(dbPath(), backupFile);
|
||||
return backupFile;
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:login', async (_event, { username, password }) => {
|
||||
const result = await authClient.login(username, password);
|
||||
if (!result.ok) {
|
||||
const error = result.status === 0
|
||||
? 'network_error'
|
||||
: result.status === 429
|
||||
? 'too_many_attempts'
|
||||
: 'invalid_credentials';
|
||||
return { success: false, error };
|
||||
}
|
||||
applySession(result);
|
||||
return { success: true, user: session.user, mustChangePassword: session.mustChangePassword };
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:logout', async () => {
|
||||
const rawRefreshToken = loadRefreshToken();
|
||||
if (rawRefreshToken) {
|
||||
try { await authClient.logout(rawRefreshToken); } catch { /* best effort */ }
|
||||
}
|
||||
clearSession();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:getSession', () => ({
|
||||
isAuthenticated: !!session.accessToken,
|
||||
user: session.user,
|
||||
mustChangePassword: session.mustChangePassword
|
||||
}));
|
||||
|
||||
ipcMain.handle('auth:requestPasswordReset', async (_event, { username }) => {
|
||||
const result = await authClient.requestPasswordReset(username);
|
||||
if (!result.ok) return { success: false, error: 'network_error' };
|
||||
return { success: true, status: result.status };
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:verifyPasswordResetCode', async (_event, { username, pin }) => {
|
||||
const result = await authClient.verifyPasswordResetCode(username, pin);
|
||||
if (!result.ok) return { success: false, error: result.error || 'invalid_or_expired' };
|
||||
return { success: true, resetToken: result.resetToken };
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:resetPassword', async (_event, { resetToken, newPassword }) => {
|
||||
const result = await authClient.resetPassword(resetToken, newPassword);
|
||||
return { success: result.ok };
|
||||
});
|
||||
|
||||
// Erfolgreicher Wechsel widerruft serverseitig alle Sessions (siehe UserService.ChangeOwnPasswordAsync) -
|
||||
// der aktuelle Access-Token ist danach tot, also Session lokal beenden statt weiterzumachen.
|
||||
ipcMain.handle('auth:changePassword', async (_event, { currentPassword, newPassword }) => {
|
||||
if (!session.accessToken) return { success: false, error: 'not_authenticated' };
|
||||
const result = await authClient.changePassword(session.accessToken, currentPassword, newPassword);
|
||||
if (!result.ok) {
|
||||
return { success: false, error: result.status === 401 ? 'invalid_current_password' : 'unknown' };
|
||||
}
|
||||
clearSession();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Generischer Proxy für Ressourcen ohne eigene <kategorie>Client.cjs-Datei (siehe Plan).
|
||||
ipcMain.handle('api:get', (_event, resourcePath) => authorizedRequest('GET', resourcePath));
|
||||
ipcMain.handle('api:post', (_event, resourcePath, body) => authorizedRequest('POST', resourcePath, body));
|
||||
|
||||
ipcMain.handle('employees:list', (_event, params) => withAuthRetry((accessToken) => employeesClient.listEmployees(accessToken, params)));
|
||||
ipcMain.handle('employees:create', (_event, payload) =>
|
||||
withAuthRetry((accessToken) => employeesClient.createEmployee(accessToken, payload))
|
||||
);
|
||||
ipcMain.handle('employees:update', (_event, id, payload) =>
|
||||
withAuthRetry((accessToken) => employeesClient.updateEmployee(accessToken, id, payload))
|
||||
);
|
||||
|
||||
ipcMain.handle('users:list', () => withAuthRetry((accessToken) => usersClient.listUsers(accessToken)));
|
||||
ipcMain.handle('users:create', (_event, payload) =>
|
||||
withAuthRetry((accessToken) => usersClient.createUser(accessToken, payload))
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('omsorg', {
|
||||
getDb: () => ipcRenderer.invoke('db:get'),
|
||||
setDb: (data) => ipcRenderer.invoke('db:set', data),
|
||||
paths: () => ipcRenderer.invoke('app:paths'),
|
||||
openRoot: () => ipcRenderer.invoke('app:openRoot'),
|
||||
createBackup: () => ipcRenderer.invoke('backup:create'),
|
||||
auth: {
|
||||
login: (username, password) => ipcRenderer.invoke('auth:login', { username, password }),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
getSession: () => ipcRenderer.invoke('auth:getSession'),
|
||||
onSessionChanged: (callback) => {
|
||||
const listener = (_event, session) => callback(session);
|
||||
ipcRenderer.on('auth:sessionChanged', listener);
|
||||
return () => ipcRenderer.removeListener('auth:sessionChanged', listener);
|
||||
},
|
||||
requestPasswordReset: (username) => ipcRenderer.invoke('auth:requestPasswordReset', { username }),
|
||||
verifyPasswordResetCode: (username, pin) => ipcRenderer.invoke('auth:verifyPasswordResetCode', { username, pin }),
|
||||
resetPassword: (resetToken, newPassword) => ipcRenderer.invoke('auth:resetPassword', { resetToken, newPassword }),
|
||||
changePassword: (currentPassword, newPassword) =>
|
||||
ipcRenderer.invoke('auth:changePassword', { currentPassword, newPassword })
|
||||
},
|
||||
api: {
|
||||
get: (path) => ipcRenderer.invoke('api:get', path),
|
||||
post: (path, body) => ipcRenderer.invoke('api:post', path, body)
|
||||
},
|
||||
employees: {
|
||||
list: (params) => ipcRenderer.invoke('employees:list', params),
|
||||
create: (payload) => ipcRenderer.invoke('employees:create', payload),
|
||||
update: (id, payload) => ipcRenderer.invoke('employees:update', id, payload)
|
||||
},
|
||||
users: {
|
||||
list: () => ipcRenderer.invoke('users:list'),
|
||||
create: (payload) => ipcRenderer.invoke('users:create', payload)
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>Omsorg Business Controls Pro</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
||||
Generated
+2132
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "omsorg-business-controls-pro",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"description": "Omsorg Business Controls Pro - Release 0.1.1 Foundation",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"",
|
||||
"web": "vite --host 127.0.0.1",
|
||||
"start": "electron ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest",
|
||||
"typescript": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"electron": "latest",
|
||||
"lucide-react": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "latest",
|
||||
"wait-on": "latest"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user