Initial
EK-DOS-WEB bauen und ausrollen / frontend (push) Failing after 22s
EK-DOS-WEB bauen und ausrollen / backend (push) Failing after 27s
EK-DOS-WEB bauen und ausrollen / deploy (push) Skipped

This commit is contained in:
Kyle Müller
2026-09-06 13:25:44 +02:00
commit 7d82e807fc
71 changed files with 10601 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="EK-DOS WEB Büroanwendung der Elektro Krüger GmbH." />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/ek-dos-favicon.svg" />
<link rel="shortcut icon" href="/ek-dos-favicon.svg" />
<title>EK-DOS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ek-dos-web-frontend",
"displayName": "EK-DOS WEB",
"version": "3.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.16.1",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"eslint": "9.39.4",
"typescript": "5.9.3",
"vite": "8.0.13"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="13" fill="#12344D"/>
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="13" fill="#12344D"/>
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 386 B

+28
View File
@@ -0,0 +1,28 @@
import { AuthProvider, useAuth } from '@/auth/AuthProvider';
import { LoadingScreen, LoginScreen } from '@/auth/LoginScreen';
import Dashboard from '@/views/Dashboard';
/**
* Der Auth-Gate.
*
* Drei Zustände, mehr gibt es nicht: es ist noch nicht entschieden, niemand ist
* angemeldet, oder jemand ist angemeldet. Erst im dritten Fall wird überhaupt
* eine Fachansicht gerendert -- so kann keine Ansicht in einen Zustand ohne
* Benutzer geraten.
*/
function Gate() {
const { user, ready } = useAuth();
if (!ready) return <LoadingScreen />;
if (!user) return <LoginScreen />;
return <Dashboard />;
}
export function App() {
return (
<AuthProvider>
<Gate />
</AuthProvider>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { config } from '@/config';
/**
* Der einzige Weg nach draussen.
*
* `credentials: 'include'` sorgt dafuer, dass das Sitzungscookie mitgeht. Es gibt
* bewusst keinen Authorization-Header und kein Token im Browser: die Sitzung
* liegt vollstaendig im Backend, der Browser haelt nur die undurchsichtige Kennung.
*/
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
/** Die Sitzung ist abgelaufen oder wurde beendet. */
get isUnauthorized(): boolean {
return this.status === 401;
}
/** Angemeldet, aber ohne das noetige Recht. */
get isForbidden(): boolean {
return this.status === 403;
}
}
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
};
/** Wird ausgeloest, sobald das Backend 401 meldet, damit die Anmeldemaske erscheint. */
type UnauthorizedListener = () => void;
const unauthorizedListeners = new Set<UnauthorizedListener>();
export function onUnauthorized(listener: UnauthorizedListener): () => void {
unauthorizedListeners.add(listener);
return () => unauthorizedListeners.delete(listener);
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, signal } = options;
const response = await fetch(`${config.api.baseUrl}${path}`, {
method,
credentials: 'include',
cache: 'no-store',
signal,
headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
}).catch(() => {
throw new ApiError('Das Backend ist nicht erreichbar.', 0);
});
if (response.status === 204) return undefined as T;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
// 401 auf der Auth-Sonde ist eine Antwort, kein Fehler: dort steht,
// dass niemand angemeldet ist. Ueberall sonst heisst es, dass die
// Sitzung gerade abgelaufen ist.
if (response.status === 401 && path !== '/api/auth/me') {
unauthorizedListeners.forEach((listener) => listener());
}
const message = typeof payload?.error === 'string' ? payload.error : `Das Backend antwortet mit HTTP ${response.status}.`;
throw new ApiError(message, response.status);
}
return payload as T;
}
export const api = {
get: <T,>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
post: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
put: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
patch: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
delete: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
};
/** Adresse fuer eine PDF-Anzeige im Browser. Die Sitzung traegt ueber das Cookie. */
export function fileUrl(path: string): string {
return `${config.api.baseUrl}${path}`;
}
/**
* Übergangsbrücke für Dashboard.tsx.
*
* Die große Dashboard-Komponente ruft noch direkt `fetch("/api/…")` auf, geht
* also nicht durch `api` oben. Ohne diesen Aufsatz bliebe eine abgelaufene
* Sitzung dort unbemerkt und die Ansichten zeigten Fehlermeldungen, statt zur
* Anmeldung zurückzufallen.
*
* Diese Funktion darf ersatzlos entfallen, sobald alle Ansichten den Client
* oben verwenden.
*/
let intercepted = false;
export function watchLegacyFetchForExpiry(): void {
if (intercepted) return;
intercepted = true;
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const response = await original(input, init);
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
// Nur eigene API-Aufrufe, und die Auth-Sonde bleibt außen vor: dort ist
// 401 die reguläre Antwort für "niemand angemeldet".
if (response.status === 401 && url.includes('/api/') && !url.includes('/api/auth/me')) {
unauthorizedListeners.forEach((listener) => listener());
}
return response;
};
}
+117
View File
@@ -0,0 +1,117 @@
import { api, onUnauthorized, watchLegacyFetchForExpiry } from '@/api/client';
import { config } from '@/config';
import type { CurrentUser, PermissionName } from '@/auth/types';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type AuthState = {
user: CurrentUser | null;
/** Solange false, ist noch nicht entschieden, ob jemand angemeldet ist. */
ready: boolean;
signIn: (username: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
can: (permission: PermissionName) => boolean;
};
const AuthContext = createContext<AuthState | null>(null);
/**
* Der Auth-Gate der Oberflaeche.
*
* Es wird nichts geraten: beim Start fragt die Anwendung /api/auth/me. 200
* bedeutet angemeldet, 401 bedeutet Anmeldemaske. Danach halten zwei Uhren die
* Sitzung im Griff -- eine verlaengert sie, solange jemand arbeitet, die andere
* meldet nach einer Stunde ohne Eingabe ab.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [ready, setReady] = useState(false);
const lastActivity = useRef(Date.now());
const probe = useCallback(async () => {
try {
const data = await api.get<{ user: CurrentUser | null }>('/api/auth/me');
setUser(data.user);
} catch {
// 401 und "Backend nicht erreichbar" fuehren beide zur Anmeldemaske.
setUser(null);
} finally {
setReady(true);
}
}, []);
useEffect(() => {
void probe();
}, [probe]);
// Meldet das Backend irgendwo 401, faellt die Oberflaeche sofort zurueck.
// Der Aufsatz erfasst dabei auch die direkten fetch-Aufrufe in Dashboard.tsx.
useEffect(() => {
watchLegacyFetchForExpiry();
return onUnauthorized(() => setUser(null));
}, []);
// Jede Eingabe zaehlt als Aktivitaet.
useEffect(() => {
const note = () => {
lastActivity.current = Date.now();
};
const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
events.forEach((event) => window.addEventListener(event, note, { passive: true }));
return () => events.forEach((event) => window.removeEventListener(event, note));
}, []);
// Sitzung verlaengern, solange gearbeitet wird; sonst abmelden.
useEffect(() => {
if (!user) return;
const timer = window.setInterval(() => {
if (Date.now() - lastActivity.current > config.session.idleMs) {
void api.post('/api/auth/logout').catch(() => undefined);
setUser(null);
return;
}
void api
.post<{ user: CurrentUser | null }>('/api/auth/refresh')
.then((data) => setUser(data.user))
.catch(() => setUser(null));
}, config.session.refreshMs);
return () => window.clearInterval(timer);
}, [user]);
const signIn = useCallback(async (username: string, password: string) => {
const data = await api.post<{ user: CurrentUser }>('/api/auth/login', { username, password });
lastActivity.current = Date.now();
setUser(data.user);
}, []);
const signOut = useCallback(async () => {
await api.post('/api/auth/logout').catch(() => undefined);
setUser(null);
}, []);
const value = useMemo<AuthState>(
() => ({
user,
ready,
signIn,
signOut,
can: (permission) => user?.permissions.includes(permission) ?? false,
}),
[user, ready, signIn, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const context = useContext(AuthContext);
if (context === null) throw new Error('useAuth benötigt einen AuthProvider.');
return context;
}
+88
View File
@@ -0,0 +1,88 @@
import { useAuth } from '@/auth/AuthProvider';
import { useState, type FormEvent } from 'react';
/**
* Anmeldemaske.
*
* Die beiden fest verdrahteten Schaltflaechen "Svenja" und "Sascha" sind einem
* Benutzernamensfeld gewichen -- Konten kommen jetzt aus der eigenen Datenbank
* und werden in der Benutzerverwaltung gepflegt. Das uebrige Gestaltungsbild
* bleibt unveraendert.
*/
export function LoginScreen() {
const { signIn } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
try {
await signIn(username.trim(), password);
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Anmeldung ist fehlgeschlagen.');
setPassword('');
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<div className="login-panel">
<div className="login-brand" aria-label="Elektro Krüger Wir setzen Sie unter Strom">
<strong>EK</strong>
<span>
<b>Elektro Krüger</b>
<small>Wir setzen Sie unter Strom</small>
</span>
</div>
<p className="login-kicker">EK-DOS</p>
<h1>Willkommen</h1>
<p className="login-copy">Bitte melde dich an, um die digitale Schaltzentrale zu öffnen.</p>
<form onSubmit={submit}>
<label>
Benutzername
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? 'Anmeldung läuft …' : 'Anmelden →'}
</button>
</form>
</div>
<div className="login-visual">
<img src="/hero-ek-dos.png" alt="" />
</div>
</main>
);
}
export function LoadingScreen() {
return (
<main className="login-page">
<div className="login-loading">EK-DOS wird geladen </div>
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
export type Role = 'admin' | 'inhaber' | 'buero';
/** Genau die Rechte, die das Backend in Permission.php fuehrt. */
export const Permission = {
UsersManage: 'users.manage',
OffersDelete: 'offers.delete',
CustomersDelete: 'customers.delete',
TasksDelete: 'tasks.delete',
PurchasesCreate: 'purchases.create',
PurchasesDelete: 'purchases.delete',
PurchasesComplete: 'purchases.complete',
InvoicesProcess: 'invoices.process',
} as const;
export type PermissionName = (typeof Permission)[keyof typeof Permission];
export type CurrentUser = {
id: string;
username: string;
/** Anzeigename, z. B. "Svenja". Die Oberflaeche zeigt ausschliesslich diesen. */
name: string;
role: Role;
roleLabel: string;
permissions: PermissionName[];
canManageUsers: boolean;
};
export type ManagedUser = {
id: string;
username: string;
displayName: string;
role: Role;
roleLabel: string;
isActive: boolean;
createdAt: string;
lastLoginAt: string | null;
permissions: PermissionName[];
};
export type RoleOption = {
value: Role;
label: string;
permissions: string[];
};
+33
View File
@@ -0,0 +1,33 @@
/**
* Baukonfiguration.
*
* Im Betrieb liefert dasselbe nginx die Oberflaeche und /api aus, deshalb ist
* die Basis leer und alle Aufrufe sind gleichursprünglich (same-origin). Damit
* traegt das Sitzungscookie ohne CORS, und es gibt keine Herkunftsliste zu pflegen.
*
* Sollte die Oberflaeche einmal auf einem eigenen Host liegen, wird hier die
* absolute Backend-Adresse eingetragen; dann braucht das Backend zusaetzlich
* eine CORS-Freigabe mit Anmeldedaten und beide Hosts muessen dieselbe Site sein.
*/
type AppConfig = {
readonly api: { readonly baseUrl: string };
readonly session: {
/** Sitzung im Hintergrund am Leben halten. */
readonly refreshMs: number;
/** Nach dieser Zeit ohne Eingabe wird abgemeldet. */
readonly idleMs: number;
};
readonly polling: {
readonly ticketsMs: number;
};
};
const shared = {
session: { refreshMs: 5 * 60 * 1000, idleMs: 60 * 60 * 1000 },
polling: { ticketsMs: 20_000 },
} as const;
const development: AppConfig = { api: { baseUrl: '' }, ...shared };
const production: AppConfig = { api: { baseUrl: '' }, ...shared };
export const config: AppConfig = import.meta.env.PROD ? production : development;
+20
View File
@@ -0,0 +1,20 @@
/** Datums- und Zeitformate, wie sie im Büro gelesen werden. */
export function formatDateTime(value?: string): string {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
}
export function formatDate(value?: string): string {
if (!value) return '';
// Mittag statt Mitternacht: so kippt ein reines Datum nicht über die Zeitzone.
const date = new Date(`${value.slice(0, 10)}T12:00:00`);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date);
}
+2
View File
@@ -0,0 +1,2 @@
// Bei jedem EK-DOS-WEB-Update hier die sichtbare Versionsnummer erhöhen.
export const EKDOS_VERSION = "V3.0";
+17
View File
@@ -0,0 +1,17 @@
import { App } from '@/App';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/globals.css';
import '@/styles/dashboard-fixes.css';
import '@/styles/users.css';
const container = document.getElementById('root');
if (container === null) throw new Error('Das Wurzelelement #root fehlt in index.html.');
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
+21
View File
@@ -0,0 +1,21 @@
/* Dashboard-Korrekturen nach Designabnahme */
.app-nav button:nth-of-type(even)>span:first-child{background:#173a57;color:#62b7ff}
.helga-card{grid-column:3;grid-row:1}
.dashboard-inbox{grid-column:2}
.helga-photo{display:block;overflow:hidden;background:#102e49}
.helga-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 24%}
.helga-card{grid-template-columns:309px minmax(0,1fr);min-height:369px}.helga-photo{width:309px;height:369px}
.app-nav button .nav-chevron{margin-left:auto}
.app-nav button .nav-chevron+.nav-badge{margin-left:8px}
.dashboard-offers{display:grid;grid-column:3;grid-row:2;align-content:start}.dashboard-offers>button{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:15px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.dashboard-offers b{display:block;font-size:13px}.dashboard-offers small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.dashboard-offers em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.dashboard-offers>button:hover b{color:#65bbff}
.customer-row-actions{display:flex;justify-content:flex-end;gap:8px;white-space:nowrap}
.customers-table .customer-row-actions button{border:1px solid #4a7690;border-radius:7px;padding:7px 9px;background:#183645;color:#d8edf5;font-size:10px;font-weight:900;cursor:pointer}
.customers-table .customer-row-actions button:first-child{border-color:#79c96c;background:#19412d;color:#d9ffc7}
.customers-table .customer-row-actions button:hover{background:#245168;color:#fff}.customers-table .customer-row-actions button:first-child:hover{background:#256b36}
.invoice-modal{width:min(760px,100%)}.invoice-modal-hint{margin:-10px 0 0;color:#a9c1ce;font-size:13px}.invoice-property-list{display:grid;gap:10px}.invoice-property-card{overflow:hidden;border:1px solid #3c6172;border-radius:10px;background:#102633}.invoice-property-card summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:15px 16px;cursor:pointer;list-style:none}.invoice-property-card summary::-webkit-details-marker{display:none}.invoice-property-card summary:hover{background:#173442}.invoice-property-card summary span:nth-child(2){display:grid;gap:3px}.invoice-property-card summary b{color:#f0f8fb;font-size:16px}.invoice-property-card summary small{color:#9be97c;font-size:11px;font-weight:800;text-transform:uppercase}.invoice-property-icon{display:grid;place-items:center;width:33px;height:33px;border:1px solid #75d862;border-radius:8px;background:#19412d;color:#a9f78a;font-size:19px}.invoice-property-card summary i{color:#9be97c;font-size:19px;font-style:normal}.invoice-property-card[open] summary{border-bottom:1px solid #3c6172}.invoice-items{display:grid;gap:8px;padding:12px;background:#0c1d27}.invoice-item{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px;border:1px solid #314e5d;border-radius:8px;background:#0d202a}.invoice-item span{display:grid;gap:3px}.invoice-item b{color:#e9f4f8;font-size:13px}.invoice-item small{color:#a9c1ce;font-size:11px}.invoice-item a,.invoice-modal-actions button{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:9px 12px;background:#184b2a;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;cursor:pointer}.invoice-item a:hover{background:#256b36;color:#fff}.invoice-modal-actions{display:flex;justify-content:flex-end;padding-top:2px}.invoice-modal-actions button{border-color:#608091;background:transparent;color:#dcecf3}@media(max-width:700px){.invoice-item{align-items:stretch;flex-direction:column}.invoice-item a{width:100%}}
@media(max-width:1270px){.helga-card{grid-column:2;grid-row:auto;grid-template-columns:180px minmax(0,1fr);min-height:216px}.helga-photo{width:180px;height:216px}.dashboard-offers{grid-column:1;grid-row:auto}}
@media(max-width:880px){.helga-card,.dashboard-inbox,.dashboard-offers{grid-column:auto;grid-row:auto}}
.activity-list .activity-status.orange{color:#ffa254}
.activity-list .activity-status.green{color:#62d589;text-shadow:0 0 8px #62d58980}
.activity-list .activity-status.blue{color:#4db5ff}
.invoices-overview-page{display:grid;gap:18px;padding:30px;min-width:0}.invoices-overview-page>.offers-heading>button{border:1px solid #4a7690;border-radius:8px;padding:10px 14px;background:#183645;color:#d8edf5;font-weight:900;cursor:pointer}.invoices-overview-page>.offers-heading>button:hover{background:#245168;color:#fff}.invoice-overview-tabs{display:flex;gap:9px;border-bottom:1px solid #294353;padding-bottom:12px}.invoice-overview-tabs button{border:1px solid #426778;border-radius:8px;padding:9px 14px;background:#102633;color:#abc2ce;font-size:12px;font-weight:900;cursor:pointer}.invoice-overview-tabs button.active,.invoice-overview-tabs button:hover{border-color:#67b9ff;background:#173a57;color:#e8f6ff}.invoice-overview-list{display:grid;gap:9px}.invoice-overview-item{display:grid;grid-template-columns:.8fr 1.4fr 1.4fr .8fr auto;align-items:center;gap:16px;padding:16px;border:1px solid #315667;border-radius:10px;background:#112b39}.invoice-overview-item>div{display:grid;gap:4px;min-width:0}.invoice-overview-item small{color:#8fb5c7;font-size:10px;font-weight:900;letter-spacing:.08em}.invoice-overview-item b{overflow:hidden;color:#edf8fb;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item span{overflow:hidden;color:#b8cbd4;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item a{display:inline-flex;align-items:center;justify-content:center;border:1px solid #8fd979;border-radius:8px;padding:9px 12px;background:#19412d;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;white-space:nowrap}.invoice-overview-item a:hover{background:#256b36;color:#fff}@media(max-width:900px){.invoices-overview-page{padding:20px}.invoice-overview-item{grid-template-columns:1fr 1fr}.invoice-overview-item a{grid-column:1/-1;width:100%}}@media(max-width:520px){.invoice-overview-item{grid-template-columns:1fr}.invoice-overview-tabs{flex-direction:column}.invoice-overview-tabs button{width:100%}}
File diff suppressed because one or more lines are too long
+286
View File
@@ -0,0 +1,286 @@
/* Benutzerverwaltung. Setzt auf den vorhandenen Listenstil der Ticketansicht auf
und ergaenzt nur, was es dort noch nicht gibt: Formular, Rollenchips, Protokoll. */
.users-notice {
max-width: 720px;
margin: 0 0 18px;
padding: 14px 18px;
border: 1px solid #9fd6a4;
border-radius: 10px;
background: #f1fbef;
color: #216c2c;
font-size: 13px;
}
.users-form {
margin: 0 0 22px;
padding: 22px 24px;
border: 1px solid #c7dce8;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 24px #294f670e;
}
.users-form h3 {
margin: 0 0 16px;
font-size: 18px;
letter-spacing: -0.03em;
}
.users-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px 18px;
}
.users-form label {
display: grid;
gap: 6px;
color: #3d596d;
font-size: 12px;
font-weight: 800;
}
.users-form input,
.users-form select {
width: 100%;
border: 1px solid #b6cfdd;
border-radius: 8px;
padding: 11px 12px;
background: #fbfdff;
color: #17384e;
font: inherit;
outline: none;
}
.users-form input:focus,
.users-form select:focus {
border-color: #5fb85f;
box-shadow: 0 0 0 3px #9be97c33;
}
.users-form input:disabled,
.users-form select:disabled {
background: #eef3f6;
color: #6b8091;
cursor: not-allowed;
}
.users-form label small {
color: #6a8290;
font-size: 11px;
font-weight: 400;
}
.users-form > button,
.users-form-actions button {
margin-top: 18px;
border: 0;
border-radius: 8px;
padding: 12px 18px;
background: #2f8744;
color: #fff;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.users-form > button:disabled,
.users-form-actions button:disabled {
opacity: 0.62;
cursor: wait;
}
.users-form-actions {
display: flex;
gap: 10px;
}
.users-form-actions .users-secondary {
background: #fff;
border: 1px solid #9fbedb;
color: #1b3047;
}
.users-permissions {
margin-top: 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f2f9fd;
color: #3d596d;
font-size: 12px;
}
.users-permissions b {
display: block;
margin-bottom: 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.users-permissions ul {
margin: 0;
padding-left: 18px;
line-height: 1.6;
}
/* Rollen als farbige Chips, damit die Tabelle auf einen Blick lesbar ist. */
.users-role-admin {
background: #ffe9c9;
color: #8a5411;
}
.users-role-inhaber {
background: #dbe7ff;
color: #24478f;
}
.users-role-buero {
background: #e6f7dd;
color: #25753a;
}
.users-state-active {
color: #25753a;
font-weight: 800;
}
.users-state-inactive {
color: #a82028;
font-weight: 800;
}
.users-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.users-actions button {
border: 1px solid #9fbedb;
border-radius: 6px;
padding: 7px 10px;
background: #fff;
color: #1b3047;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.users-actions button:hover:not(:disabled) {
border-color: #6cc55a;
background: #f1fbf3;
}
.users-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.users-actions .users-danger {
border-color: #e0a9a4;
color: #a1332d;
}
.users-actions .users-danger:hover:not(:disabled) {
border-color: #d66b6b;
background: #fff4f3;
}
.users-audit {
margin-top: 26px;
padding: 20px 22px;
border: 1px solid #c7dce8;
border-radius: 12px;
background: #fff;
}
.users-audit h3 {
margin: 0 0 12px;
font-size: 15px;
}
.users-audit ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.users-audit li {
display: flex;
gap: 14px;
align-items: baseline;
padding-bottom: 8px;
border-bottom: 1px solid #e6eef3;
font-size: 12px;
}
.users-audit li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.users-audit b {
flex: 0 0 150px;
color: #3d596d;
font-size: 11px;
}
.users-audit span {
color: #244258;
}
@media (max-width: 800px) {
.users-audit li {
flex-direction: column;
gap: 2px;
}
.users-audit b {
flex: none;
}
}
/* Dunkles Farbschema, passend zum Rest der Anwendung. */
:root[data-theme='dark'] .users-form,
:root[data-theme='dark'] .users-audit {
border-color: #34505f;
background: #172a36;
color: #e5eff5;
}
:root[data-theme='dark'] .users-form input,
:root[data-theme='dark'] .users-form select {
border-color: #3a5868;
background: #132630;
color: #e3eff5;
}
:root[data-theme='dark'] .users-form label,
:root[data-theme='dark'] .users-permissions,
:root[data-theme='dark'] .users-audit span {
color: #b4c5cf;
}
:root[data-theme='dark'] .users-permissions {
background: #1d3544;
}
:root[data-theme='dark'] .users-notice {
border-color: #39704a;
background: #163023;
color: #d7f3d9;
}
:root[data-theme='dark'] .users-actions button {
border-color: #3a5868;
background: #132630;
color: #dceaf2;
}
:root[data-theme='dark'] .users-audit li {
border-color: #294553;
}
File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
import { api } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import type { ManagedUser, Role, RoleOption } from '@/auth/types';
import { formatDateTime } from '@/lib/format';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
type UsersPayload = { users: ManagedUser[]; roles: RoleOption[] };
type AuditEntry = { actor: string; action: string; subject: string; detail: Record<string, unknown>; createdAt: string };
const emptyDraft = { username: '', displayName: '', role: 'buero' as Role, password: '' };
/**
* Benutzerverwaltung.
*
* Nur fuer die Rolle Administration sichtbar. Das Backend setzt dieselben Regeln
* noch einmal durch -- diese Ansicht macht sie nur sichtbar, sie ersetzt sie nicht.
*/
export function UsersView() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<ManagedUser[]>([]);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState(emptyDraft);
const [editing, setEditing] = useState<ManagedUser | null>(null);
const [editDraft, setEditDraft] = useState({ username: '', displayName: '', role: 'buero' as Role, password: '' });
const [deleteConfirming, setDeleteConfirming] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await api.get<UsersPayload>('/api/users');
setUsers(data.users);
setRoles(data.roles);
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Benutzer konnten nicht geladen werden.');
} finally {
setLoading(false);
}
}, []);
const refreshAudit = useCallback(async () => {
try {
const data = await api.get<{ entries: AuditEntry[] }>('/api/users/audit');
setAudit(data.entries);
} catch {
// Das Protokoll ist eine Beigabe. Faellt es aus, bleibt die Verwaltung nutzbar.
}
}, []);
useEffect(() => {
void refresh();
void refreshAudit();
}, [refresh, refreshAudit]);
const run = async (id: string | null, action: () => Promise<string>) => {
setBusyId(id ?? 'form');
setError('');
setNotice('');
try {
setNotice(await action());
await refresh();
await refreshAudit();
} catch (problem) {
setError(problem instanceof Error ? problem.message : 'Die Aktion ist fehlgeschlagen.');
} finally {
setBusyId(null);
}
};
const create = (event: FormEvent) => {
event.preventDefault();
void run(null, async () => {
await api.post('/api/users', draft);
setDraft(emptyDraft);
setCreating(false);
return `Benutzer „${draft.displayName}“ wurde angelegt.`;
});
};
const saveEdit = (event: FormEvent) => {
event.preventDefault();
if (!editing) return;
void run(editing.id, async () => {
const changes: Record<string, unknown> = {};
if (editDraft.displayName !== editing.displayName) changes.displayName = editDraft.displayName;
if (editDraft.username !== editing.username) changes.username = editDraft.username;
if (editDraft.role !== editing.role) changes.role = editDraft.role;
if (editDraft.password !== '') changes.password = editDraft.password;
if (Object.keys(changes).length === 0) {
setEditing(null);
return 'Es wurde nichts geändert.';
}
const result = await api.patch<{ endedSessions: number }>(`/api/users/${editing.id}`, changes);
setEditing(null);
return result.endedSessions > 0
? `Gespeichert. ${result.endedSessions} offene Sitzung(en) wurden beendet.`
: 'Gespeichert.';
});
};
const toggleActive = (user: ManagedUser) =>
void run(user.id, async () => {
await api.patch(`/api/users/${user.id}`, { isActive: !user.isActive });
return user.isActive ? `${user.displayName}“ ist jetzt deaktiviert.` : `${user.displayName}“ ist wieder aktiv.`;
});
const remove = (user: ManagedUser) =>
void run(user.id, async () => {
await api.delete(`/api/users/${user.id}`);
setDeleteConfirming(null);
return `Benutzer „${user.displayName}“ wurde gelöscht.`;
});
const startEdit = (user: ManagedUser) => {
setEditing(user);
setEditDraft({ username: user.username, displayName: user.displayName, role: user.role, password: '' });
setCreating(false);
setNotice('');
setError('');
};
const activeAdmins = users.filter((user) => user.role === 'admin' && user.isActive).length;
return (
<section className="tickets-page users-page">
<div className="tickets-heading">
<div>
<p className="home-kicker">Verwaltung</p>
<h2>Benutzer</h2>
<p>
Konten, Rollen und Rechte für EK-DOS. Jede Änderung an Rolle, Passwort oder Status beendet die offenen
Sitzungen des betroffenen Kontos sofort.
</p>
</div>
<div className="tickets-actions">
<span>{users.length} Konten · {activeAdmins} aktive Administration</span>
<button
onClick={() => {
setCreating((open) => !open);
setEditing(null);
}}
>
{creating ? 'Abbrechen' : 'Benutzer anlegen'}
</button>
</div>
</div>
{error && <p className="tickets-error">{error}</p>}
{notice && <p className="users-notice">{notice}</p>}
{creating && (
<form className="users-form" onSubmit={create}>
<h3>Neuer Benutzer</h3>
<div className="users-form-grid">
<label>
Benutzername
<input
value={draft.username}
onChange={(event) => setDraft({ ...draft, username: event.target.value })}
placeholder="z. B. svenja"
autoComplete="off"
required
/>
</label>
<label>
Anzeigename
<input
value={draft.displayName}
onChange={(event) => setDraft({ ...draft, displayName: event.target.value })}
placeholder="z. B. Svenja"
required
/>
</label>
<label>
Rolle
<select value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value as Role })}>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</label>
<label>
Passwort
<input
type="password"
value={draft.password}
onChange={(event) => setDraft({ ...draft, password: event.target.value })}
autoComplete="new-password"
minLength={12}
required
/>
<small>Mindestens 12 Zeichen.</small>
</label>
</div>
<RolePermissions roles={roles} selected={draft.role} />
<button type="submit" disabled={busyId === 'form'}>
{busyId === 'form' ? 'Wird angelegt …' : 'Benutzer anlegen'}
</button>
</form>
)}
{editing && (
<form className="users-form" onSubmit={saveEdit}>
<h3>{editing.displayName} bearbeiten</h3>
<div className="users-form-grid">
<label>
Benutzername
<input
value={editDraft.username}
onChange={(event) => setEditDraft({ ...editDraft, username: event.target.value })}
autoComplete="off"
required
/>
</label>
<label>
Anzeigename
<input
value={editDraft.displayName}
onChange={(event) => setEditDraft({ ...editDraft, displayName: event.target.value })}
required
/>
</label>
<label>
Rolle
<select
value={editDraft.role}
onChange={(event) => setEditDraft({ ...editDraft, role: event.target.value as Role })}
disabled={editing.id === currentUser?.id}
>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
{editing.id === currentUser?.id && <small>Die eigene Rolle kann nicht geändert werden.</small>}
</label>
<label>
Neues Passwort
<input
type="password"
value={editDraft.password}
onChange={(event) => setEditDraft({ ...editDraft, password: event.target.value })}
autoComplete="new-password"
placeholder="leer lassen, um es beizubehalten"
/>
</label>
</div>
<RolePermissions roles={roles} selected={editDraft.role} />
<div className="users-form-actions">
<button type="submit" disabled={busyId === editing.id}>
{busyId === editing.id ? 'Wird gespeichert …' : 'Speichern'}
</button>
<button type="button" className="users-secondary" onClick={() => setEditing(null)}>
Abbrechen
</button>
</div>
</form>
)}
<div className="tickets-table-wrap">
<table className="tickets-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzte Anmeldung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{loading && users.length === 0 && (
<tr>
<td colSpan={5}>Benutzer werden geladen </td>
</tr>
)}
{!loading && users.length === 0 && (
<tr>
<td colSpan={5}>Es ist noch kein Benutzer angelegt.</td>
</tr>
)}
{users.map((user) => (
<tr key={user.id}>
<td>
<b>{user.displayName}</b>
<small>{user.username}{user.id === currentUser?.id ? ' · das bist du' : ''}</small>
</td>
<td>
<span className={`ticket-status users-role-${user.role}`}>{user.roleLabel}</span>
</td>
<td>
<span className={user.isActive ? 'users-state-active' : 'users-state-inactive'}>
{user.isActive ? 'aktiv' : 'deaktiviert'}
</span>
</td>
<td>{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'noch nie'}</td>
<td className="users-actions">
<button onClick={() => startEdit(user)} disabled={busyId !== null}>
Bearbeiten
</button>
<button
onClick={() => toggleActive(user)}
disabled={busyId !== null || user.id === currentUser?.id}
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht deaktiviert werden.' : ''}
>
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
{deleteConfirming === user.id ? (
<>
<button className="users-danger" onClick={() => remove(user)} disabled={busyId !== null}>
Wirklich löschen
</button>
<button onClick={() => setDeleteConfirming(null)}>Abbrechen</button>
</>
) : (
<button
className="users-danger"
onClick={() => setDeleteConfirming(user.id)}
disabled={busyId !== null || user.id === currentUser?.id}
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht gelöscht werden.' : ''}
>
Löschen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{audit.length > 0 && (
<div className="users-audit">
<h3>Letzte Änderungen</h3>
<ul>
{audit.map((entry, index) => (
<li key={`${entry.createdAt}-${index}`}>
<b>{formatDateTime(entry.createdAt)}</b>
<span>
{entry.actor} · {describeAction(entry.action)} · {entry.subject}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
function RolePermissions({ roles, selected }: { roles: RoleOption[]; selected: Role }) {
const role = roles.find((candidate) => candidate.value === selected);
if (!role) return null;
return (
<div className="users-permissions">
<b>Diese Rolle darf zusätzlich:</b>
{role.permissions.length === 0 ? (
<span>nur die allgemeinen Büroansichten</span>
) : (
<ul>
{role.permissions.map((permission) => (
<li key={permission}>{permission}</li>
))}
</ul>
)}
</div>
);
}
function describeAction(action: string): string {
switch (action) {
case 'user.create':
return 'angelegt';
case 'user.update':
return 'geändert';
case 'user.delete':
return 'gelöscht';
case 'user.password':
return 'Passwort zurückgesetzt';
case 'password.self':
return 'eigenes Passwort geändert';
default:
return action;
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+48
View File
@@ -0,0 +1,48 @@
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, type UserConfig } from 'vite';
/**
* Funktionsform, damit ein Produktionsbau (Gitea-Runner) nichts vom
* Entwicklungsserver anfasst. Der Bau erzeugt ausschliesslich statische
* Dateien; auf dem Zielsystem laeuft kein Node.
*/
export default defineConfig(({ command }) => {
const config: UserConfig = {
plugins: [react()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
build: {
outDir: 'dist',
// Quellkarten bleiben aus: das Buendel liegt auf einem erreichbaren
// nginx und soll den Quelltext nicht mitliefern.
sourcemap: false,
target: 'es2022',
rollupOptions: {
output: {
// React getrennt halten, damit ein Oberflaechen-Update nicht
// den gesamten Cache der Anwender entwertet.
manualChunks: { react: ['react', 'react-dom'] },
},
},
},
};
// Ab hier nur noch Entwicklungsserver. Ein `vite build` erreicht diese
// Zeilen nie und braucht deshalb weder Proxy noch laufendes Backend.
if (command !== 'serve') return config;
config.server = {
port: 5173,
strictPort: true,
proxy: {
// Im Betrieb macht das nginx. Lokal zeigt der Proxy auf
// `php -S 127.0.0.1:8080 -t backend/public`.
'/api': {
target: process.env.EKDOS_BACKEND ?? 'http://127.0.0.1:8080',
changeOrigin: false,
},
},
};
return config;
});