Quellcode des ICON CRM (5)

This commit is contained in:
2026-09-14 16:55:38 +02:00
parent e383400d7c
commit 4fcb34f324
25 changed files with 7616 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react';
import { aiApi, type AiConfig } from '../../lib/api';
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
/*
* Reiter "KI" in den Einstellungen (nur Administratoren).
*
* Der Schlüssel lag bisher ausschließlich in server/.env. Das hieß: ein
* abgelaufener Schlüssel ist ein Serverbesuch. Er steht jetzt in der Datenbank
* und lässt sich hier wechseln - die .env bleibt als Startwert und als Rückfall.
*
* Zwei Dinge sind hier nicht Bequemlichkeit, sondern Bedingung:
*
* * Der Schlüssel kommt **nie** zurück zum Browser. Angezeigt wird nur eine
* verkürzte Form; das Eingabefeld ist leer und schreibt nur.
* * Ein Schlüssel gilt erst als richtig, wenn ein Aufruf damit geklappt hat.
* Deshalb der Test - sonst merkt man den Tippfehler erst, wenn mitten in
* der Arbeit ein Lebenslauf nicht ausgelesen wird.
*/
export const AiSettings: React.FC = () => {
const { toast } = useToast();
const [config, setConfig] = useState<AiConfig | null>(null);
const [key, setKey] = useState('');
const [model, setModel] = useState('');
const [busy, setBusy] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
const [askRemove, setAskRemove] = useState(false);
const loadConfig = async () => {
try {
const data = await aiApi.config();
setConfig(data);
setModel(data.model);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
useEffect(() => {
void loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async (changes: { key?: string; model?: string }) => {
setBusy(true);
setResult(null);
try {
const data = await aiApi.saveConfig(changes);
setConfig(data);
setModel(data.model);
setKey('');
toast('Gespeichert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
const runTest = async () => {
setTesting(true);
setResult(null);
try {
const data = await aiApi.test();
setResult({ ok: true, text: `Antwort in ${(data.ms / 1000).toFixed(1)} s von ${data.model}.` });
} catch (err) {
setResult({ ok: false, text: err instanceof Error ? err.message : 'Der Test ist fehlgeschlagen.' });
} finally {
setTesting(false);
}
};
if (!config) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
const sourceLabel =
config.source === 'db'
? 'hier hinterlegt'
: config.source === 'env'
? 'aus server/.env'
: 'keiner hinterlegt';
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">OpenAI</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Schlüssel treibt drei Dinge an: das Auslesen der Lebensläufe, die Schreibhilfe in den
Ausschreibungen und die erweiterte Suche. Er bleibt auf dem Server – der Browser spricht
nie direkt mit OpenAI. Fehlt er, läuft das CRM normal weiter und blendet die KI-Knöpfe
aus.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3">
<span
className={`inline-flex h-2.5 w-2.5 shrink-0 rounded-full ${
config.configured ? 'bg-emerald-500' : 'bg-ink-300'
}`}
aria-hidden="true"
/>
<span className="text-sm font-medium text-ink-800">
{config.configured ? 'Schlüssel hinterlegt' : 'Kein Schlüssel hinterlegt'}
</span>
{config.masked && (
<code className="rounded bg-surface px-2 py-0.5 text-xs text-ink-600">{config.masked}</code>
)}
<span className="text-xs text-ink-400">({sourceLabel})</span>
<span className="ml-auto text-xs text-ink-400">Modell {config.model}</span>
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="mb-4 text-sm font-semibold text-ink-900">Schlüssel setzen</h3>
<Field
label="Neuer Schlüssel"
hint="Wird beim Speichern übernommen und danach nicht mehr angezeigt. Ein bestehender Schlüssel wird ersetzt."
>
<input
type="password"
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="sk-proj-…"
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
</Field>
<div className="mt-4 flex flex-wrap gap-2">
<PrimaryButton
type="button"
disabled={busy || !key.trim()}
onClick={() => void save({ key: key.trim() })}
>
Schlüssel speichern
</PrimaryButton>
{config.source === 'db' && (
<SecondaryButton type="button" disabled={busy} onClick={() => setAskRemove(true)}>
Schlüssel entfernen
</SecondaryButton>
)}
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3>
<Field
label="Modellname"
hint={`Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`}
>
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={config.defaultModel}
spellCheck={false}
className={`${inputClass} font-mono`}
/>
</Field>
<div className="mt-4 flex flex-wrap gap-2">
<PrimaryButton
type="button"
disabled={busy || model.trim() === config.model}
onClick={() => void save({ model: model.trim() })}
>
Modell speichern
</PrimaryButton>
{model.trim() !== config.defaultModel && (
<SecondaryButton
type="button"
disabled={busy}
onClick={() => void save({ model: config.defaultModel })}
>
Zurück zur Vorgabe
</SecondaryButton>
)}
</div>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="text-sm font-semibold text-ink-900">Verbindung prüfen</h3>
<p className="mt-1 text-sm text-ink-500">
Ein kurzer Aufruf an das Modell. Er sagt, ob Schlüssel, Modellname und Kontingent
zusammenpassen.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<PrimaryButton type="button" disabled={testing || !config.configured} onClick={() => void runTest()}>
{testing ? 'Prüft …' : 'Test starten'}
</PrimaryButton>
{!config.configured && (
<span className="text-xs font-medium text-ink-400">Erst einen Schlüssel hinterlegen.</span>
)}
</div>
{result && (
<p
className={`mt-4 rounded-lg border px-4 py-3 text-sm font-medium ${
result.ok
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-red-200 bg-red-50 text-red-700'
}`}
>
{result.text}
</p>
)}
</section>
<ConfirmDialog
open={askRemove}
title="Schlüssel entfernen?"
message={
config.envAvailable
? 'Der hier hinterlegte Schlüssel wird gelöscht. Danach gilt wieder der Schlüssel aus server/.env.'
: 'Der Schlüssel wird gelöscht. Danach sind CV-Auslesung, Schreibhilfe und erweiterte Suche ohne Funktion, bis ein neuer hinterlegt ist.'
}
confirmLabel="Entfernen"
onConfirm={async () => {
setAskRemove(false);
await save({ key: '' });
}}
onCancel={() => setAskRemove(false)}
/>
</div>
);
};
+200
View File
@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { optionsApi, type OptionField } from '../../lib/api';
import { useOptions } from '../../lib/options';
import { Pill, PrimaryButton, inputClass } from '../ui/form';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
/*
* Stammdaten: die gepflegten Auswahllisten.
*
* Branche und Kontaktart waren Freitext bzw. fest im Quelltext. Beides wächst
* im Betrieb - eine neue Branche, eine neue Art von Kontakt - und soll ohne
* Codeänderung gehen.
*
* Zwei Eigenschaften, die man beim Pflegen kennen sollte und die deshalb in der
* Oberfläche stehen:
*
* * Umbenennen zieht den Bestand mit. Sonst trüge ein Unternehmen eine
* Branche, die seine Auswahlliste nicht mehr kennt.
* * Entfernen geht nur, solange ein Wert nirgends benutzt wird. Deshalb steht
* hinter jedem Eintrag, wie oft er vorkommt.
*/
export const MasterData: React.FC = () => {
const { toast } = useToast();
const { reload: reloadOptions } = useOptions();
const [fields, setFields] = useState<OptionField[]>([]);
const [loading, setLoading] = useState(true);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [editing, setEditing] = useState<{ id: number; value: string } | null>(null);
const [deleting, setDeleting] = useState<{ id: number; value: string } | null>(null);
const load = async () => {
setLoading(true);
try {
const { fields: rows } = await optionsApi.list();
setFields(rows);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const after = async () => {
await load();
// Die Formulare der Anwendung ziehen ihre Auswahl aus demselben Zustand.
await reloadOptions();
};
const add = async (field: string) => {
const value = (drafts[field] ?? '').trim();
if (!value) return;
try {
await optionsApi.create(field, value);
setDrafts((current) => ({ ...current, [field]: '' }));
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen.', 'error');
}
};
const rename = async () => {
if (!editing) return;
try {
const { renamed } = await optionsApi.rename(editing.id, editing.value.trim());
toast(renamed > 0 ? `Umbenannt – ${renamed} Datensatz/Datensätze mitgezogen.` : 'Umbenannt.', 'success');
setEditing(null);
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error');
}
};
const remove = async () => {
if (!deleting) return;
try {
await optionsApi.remove(deleting.id);
await after();
} catch (err) {
toast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
};
if (loading) return <p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>;
return (
<>
<div className="grid gap-5 lg:grid-cols-2">
{fields.map((field) => (
<section key={field.field} className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-1 text-sm font-semibold text-ink-900">{field.label}</h2>
<p className="mb-3 text-xs text-ink-400">
Speist die Auswahlfelder in den Formularen. Umbenennen zieht den Bestand mit.
</p>
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
{field.values.length === 0 && (
<li className="px-3 py-6 text-center text-sm text-ink-400">Noch kein Eintrag.</li>
)}
{field.values.map((option) => (
<li key={option.id} className="flex items-center gap-2 px-3 py-2">
{editing?.id === option.id ? (
<>
<input
autoFocus
value={editing.value}
onChange={(event) => setEditing({ ...editing, value: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') void rename();
if (event.key === 'Escape') setEditing(null);
}}
className={`${inputClass} py-1`}
/>
<button
type="button"
onClick={rename}
className="rounded-lg px-2 py-1 text-sm font-medium text-brand-700 hover:bg-brand-50"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100"
>
Abbrechen
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate text-sm text-ink-900">{option.value}</span>
<Pill tone={option.used > 0 ? 'brand' : 'ink'}>{option.used}×</Pill>
<button
type="button"
onClick={() => setEditing({ id: option.id, value: option.value })}
className="rounded-lg px-2 py-1 text-sm text-ink-500 hover:bg-ink-100 hover:text-ink-800"
>
Umbenennen
</button>
<button
type="button"
disabled={option.used > 0}
title={
option.used > 0
? `Wird noch ${option.used}× verwendet und kann nicht entfernt werden.`
: 'Entfernen'
}
onClick={() => setDeleting({ id: option.id, value: option.value })}
className="rounded-lg p-1 text-ink-400 hover:bg-ink-100 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-400"
>
×
</button>
</>
)}
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<input
value={drafts[field.field] ?? ''}
onChange={(event) =>
setDrafts((current) => ({ ...current, [field.field]: event.target.value }))
}
onKeyDown={(event) => {
if (event.key === 'Enter') void add(field.field);
}}
placeholder="Neuer Eintrag …"
className={inputClass}
/>
<PrimaryButton
type="button"
onClick={() => add(field.field)}
disabled={!(drafts[field.field] ?? '').trim()}
>
Hinzufügen
</PrimaryButton>
</div>
</section>
))}
</div>
<ConfirmDialog
open={deleting !== null}
title="Eintrag entfernen"
message={`„${deleting?.value}" wird aus der Liste entfernt.`}
confirmLabel="Entfernen"
onConfirm={remove}
onCancel={() => setDeleting(null)}
/>
</>
);
};
+253
View File
@@ -0,0 +1,253 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MasterData } from './MasterData';
import { WhisperSettings } from './WhisperSettings';
import { AiSettings } from './AiSettings';
import type { SettingsTab } from '../../lib/navigation';
import { useAuth } from '../../lib/auth';
import { useTheme } from '../../lib/theme';
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
import { authApi } from '../../lib/api';
import { AvatarPicker } from '../ui/AvatarPicker';
import { Field, PrimaryButton, inputClass, labelClass } from '../ui/form';
import { useToast } from '../Toast';
interface SettingsProps {
/** Reiter, der beim Oeffnen gezeigt werden soll - sonst der uebliche. */
initialTab?: SettingsTab | null;
/** Meldet zurueck, dass der Wunsch erfuellt ist. */
onTabConsumed?: () => void;
}
export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed }) => {
const { user, isAdmin, updateUser } = useAuth();
const { theme, setTheme, mode, setMode, followsSystem, resetMode } = useTheme();
const { toast } = useToast();
const [tab, setTab] = useState<SettingsTab>(initialTab ?? (isAdmin ? 'benutzer' : 'konto'));
/*
* Ein von aussen gewuenschter Reiter gewinnt - aber nur einmal. Bliebe der
* Wunsch stehen, koennte man den Reiter danach nicht mehr wechseln.
*/
useEffect(() => {
if (!initialTab) return;
setTab(initialTab);
onTabConsumed?.();
}, [initialTab, onTabConsumed]);
const [color, setColor] = useState(user?.color ?? '#c1862f');
const [avatar, setAvatar] = useState<string | null>(user?.avatar ?? null);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const saveProfile = async () => {
try {
const { user: updated } = await authApi.updateMe({ color, avatar });
updateUser(updated);
toast('Konto gespeichert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
const changePassword = async () => {
try {
await authApi.changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
toast('Passwort geändert.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
}
};
return (
<div className="space-y-5">
<nav className="flex gap-1 border-b border-ink-200">
{isAdmin && (
<button
type="button"
onClick={() => setTab('benutzer')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'benutzer' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Nutzerverwaltung
</button>
)}
{isAdmin && (
<button
type="button"
onClick={() => setTab('stammdaten')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'stammdaten' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Stammdaten
</button>
)}
{isAdmin && (
<button
type="button"
onClick={() => setTab('ki')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'ki' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
KI
</button>
)}
<button
type="button"
onClick={() => setTab('konto')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'konto' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Mein Konto
</button>
{/* Diktat richtet jeder auf seinem eigenen Geraet ein - keine Rolle noetig. */}
<button
type="button"
onClick={() => setTab('diktat')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
tab === 'diktat' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
}`}
>
Diktat
</button>
</nav>
{tab === 'ki' && isAdmin && <AiSettings />}
{tab === 'diktat' && <WhisperSettings />}
{tab === 'benutzer' && isAdmin && <UserManagement />}
{tab === 'stammdaten' && isAdmin && <MasterData />}
{tab === 'konto' && (
<div className="grid gap-5 lg:grid-cols-2">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Profil</h2>
<AvatarPicker name={user?.name ?? '?'} value={avatar} onChange={setAvatar} />
<div className="mt-4">
<label className={labelClass}>Persönliche Farbe</label>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={(event) => setColor(event.target.value)}
className="h-9 w-16 cursor-pointer rounded border border-ink-200 bg-surface"
/>
<span className="text-sm text-ink-500">{color}</span>
</div>
<p className="mt-1 text-xs text-ink-400">
Trägt Initialen-Avatare und die Punkte in der Historie.
</p>
</div>
<PrimaryButton type="button" onClick={saveProfile} className="mt-4">
Speichern
</PrimaryButton>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Darstellung</h2>
<label className={labelClass}>Modus</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setMode('light')}
className={`rounded-lg border px-3 py-1.5 text-sm ${
!followsSystem && mode === 'light'
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600'
}`}
>
Hell
</button>
<button
type="button"
onClick={() => setMode('dark')}
className={`rounded-lg border px-3 py-1.5 text-sm ${
!followsSystem && mode === 'dark'
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600'
}`}
>
Dunkel
</button>
<button
type="button"
onClick={resetMode}
className={`rounded-lg border px-3 py-1.5 text-sm ${
followsSystem ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-600'
}`}
>
Dem System folgen
</button>
</div>
<label className={`${labelClass} mt-4`}>Design</label>
<ul className="space-y-2">
{THEME_PALETTES.map((value) => (
<li key={value}>
<button
type="button"
onClick={() => setTheme(value)}
className={`w-full rounded-lg border px-3 py-2 text-left text-sm ${
theme === value
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
}`}
>
<span className="font-medium">{THEME_PALETTE_LABELS[value].name}</span>
<span className="ml-2 text-xs text-ink-400">{THEME_PALETTE_LABELS[value].hint}</span>
</button>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-400">
Dasselbe steht oben rechts im Kopf – dort auch der Umschalter zwischen hell und dunkel.
</p>
</section>
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="mb-4 text-sm font-semibold text-ink-900">Passwort ändern</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Aktuelles Passwort">
<input
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
className={inputClass}
/>
</Field>
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen.">
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
className={inputClass}
/>
</Field>
</div>
<PrimaryButton
type="button"
onClick={changePassword}
disabled={!currentPassword || newPassword.length < 8}
className="mt-4"
>
Passwort ändern
</PrimaryButton>
</section>
</div>
)}
</div>
);
};
+269
View File
@@ -0,0 +1,269 @@
import React, { useEffect, useState } from 'react';
import type { AppUser } from '../../types';
import { userColor } from '../../types';
import { usersApi } from '../../lib/api';
import { Modal, ConfirmDialog } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { useAuth } from '../../lib/auth';
import { useToast } from '../Toast';
export const UserManagement: React.FC = () => {
const { user: me } = useAuth();
const { toast } = useToast();
const [users, setUsers] = useState<AppUser[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<AppUser | null | undefined>(undefined);
const [deleting, setDeleting] = useState<AppUser | null>(null);
const load = async () => {
setLoading(true);
try {
const { users: rows } = await usersApi.list();
setUsers(rows);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const remove = async () => {
if (!deleting) return;
try {
await usersApi.remove(deleting.id);
toast('Benutzer gelöscht.', 'success');
void load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setDeleting(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-ink-400">{users.length} Benutzer</p>
<PrimaryButton type="button" onClick={() => setEditing(null)}>
Neuer Benutzer
</PrimaryButton>
</div>
{loading ? (
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
) : (
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
<th className="px-4 py-2.5 font-semibold">Name</th>
<th className="px-4 py-2.5 font-semibold">Benutzername</th>
<th className="px-4 py-2.5 font-semibold">E-Mail</th>
<th className="px-4 py-2.5 font-semibold">Rolle</th>
<th className="px-4 py-2.5 font-semibold">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-ink-50">
<td className="px-4 py-2.5">
<span className="flex items-center gap-2">
<Avatar name={user.name} src={user.avatar} size={28} color={userColor(user)} />
<span className="font-medium text-ink-900">{user.name}</span>
</span>
</td>
<td className="px-4 py-2.5 text-ink-600">{user.username}</td>
<td className="px-4 py-2.5 text-ink-600">{user.email || '–'}</td>
<td className="px-4 py-2.5">
<Pill tone={user.role === 'admin' ? 'brand' : 'ink'}>
{user.role === 'admin' ? 'Administrator' : 'Benutzer'}
</Pill>
</td>
<td className="px-4 py-2.5">
<Pill tone={user.is_active ? 'emerald' : 'red'}>
{user.is_active ? 'aktiv' : 'deaktiviert'}
</Pill>
</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => setEditing(user)}
className="rounded-lg px-2 py-1 text-sm font-medium text-ink-600 hover:bg-ink-100"
>
Bearbeiten
</button>
{user.id !== me?.id && (
<button
type="button"
onClick={() => setDeleting(user)}
className="ml-1 rounded-lg px-2 py-1 text-sm font-medium text-red-600 hover:bg-red-50"
>
Löschen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing !== undefined && (
<UserModal
user={editing}
onClose={() => setEditing(undefined)}
onSaved={() => {
void load();
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Benutzer löschen"
message={`${deleting?.name} wird gelöscht. Historieneinträge bleiben erhalten, verlieren aber die Zuordnung.`}
onConfirm={remove}
onCancel={() => setDeleting(null)}
/>
</div>
);
};
const UserModal: React.FC<{
user: AppUser | null;
onClose: () => void;
onSaved: () => void;
}> = ({ user, onClose, onSaved }) => {
const { toast } = useToast();
const [form, setForm] = useState({
username: user?.username ?? '',
name: user?.name ?? '',
email: user?.email ?? '',
role: user?.role ?? 'user',
password: '',
is_active: user?.is_active ?? true,
color: user?.color ?? '#c1862f',
});
const [saving, setSaving] = useState(false);
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true);
try {
if (user) {
const body: Record<string, unknown> = {
name: form.name,
email: form.email,
role: form.role,
is_active: form.is_active,
color: form.color,
};
// Ein leeres Feld heisst "Passwort nicht anfassen" - nicht "leeres
// Passwort setzen".
if (form.password) body.password = form.password;
await usersApi.update(user.id, body);
} else {
await usersApi.create(form);
}
toast('Gespeichert.', 'success');
onSaved();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open
onClose={onClose}
title={user ? `Benutzer ${user.name}` : 'Neuer Benutzer'}
footer={
<>
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={save} disabled={saving}>
{saving ? 'Speichern …' : 'Speichern'}
</PrimaryButton>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Benutzername *">
<input
value={form.username}
onChange={(event) => set('username', event.target.value)}
disabled={Boolean(user)}
className={inputClass}
/>
</Field>
<Field label="Name *">
<input value={form.name} onChange={(event) => set('name', event.target.value)} className={inputClass} />
</Field>
<Field label="E-Mail">
<input
type="email"
value={form.email ?? ''}
onChange={(event) => set('email', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Rolle">
<select
value={form.role}
onChange={(event) => set('role', event.target.value as 'admin' | 'user')}
className={inputClass}
>
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>
</Field>
<Field
label={user ? 'Neues Passwort' : 'Passwort *'}
hint={user ? 'Leer lassen, um das Passwort nicht zu ändern.' : 'Mindestens 8 Zeichen.'}
>
<input
type="password"
value={form.password}
onChange={(event) => set('password', event.target.value)}
autoComplete="new-password"
className={inputClass}
/>
</Field>
<Field label="Farbe">
<input
type="color"
value={form.color}
onChange={(event) => set('color', event.target.value)}
className="h-[38px] w-full cursor-pointer rounded-lg border border-ink-200 bg-surface"
/>
</Field>
</div>
{user && (
<label className="mt-4 flex items-center gap-2 text-sm text-ink-700">
<input
type="checkbox"
checked={form.is_active}
onChange={(event) => set('is_active', event.target.checked)}
className="h-4 w-4 accent-[var(--color-brand-600)]"
/>
Konto ist aktiv
</label>
)}
</Modal>
);
};
+250
View File
@@ -0,0 +1,250 @@
import React, { useEffect, useState } from 'react';
import { ConfirmDialog } from '../ui/Modal';
import { labelClass } from '../ui/form';
import { useToast } from '../Toast';
import { useRecorder } from '../../lib/dictation';
import { WHISPER_MODEL, useWhisper } from '../../lib/whisper';
import { fetchModelSize, formatSize } from '../../lib/whisperModels';
/*
* Reiter "Diktat" in den Einstellungen - für jede Rolle sichtbar, denn jeder
* richtet das Diktat auf seinem eigenen Gerät ein.
*
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der
* Weg, es wieder loszuwerden.
*/
export const WhisperSettings: React.FC = () => {
const { toast } = useToast();
const {
status,
phase,
progress,
progressLabel,
error,
isCached,
capabilities,
load,
clearCache,
isSupported,
} = useWhisper();
const [testText, setTestText] = useState('');
const [askClear, setAskClear] = useState(false);
/** Downloadgröße, direkt bei Hugging Face erfragt. */
const [sizeLabel, setSizeLabel] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchModelSize(WHISPER_MODEL)
.then((size) => {
if (!cancelled && size) setSizeLabel(formatSize(size.totalBytes));
})
.catch(() => {
// Ohne Angabe läuft es genauso - nur ohne Zahl im Text.
});
return () => {
cancelled = true;
};
}, []);
const recorder = useRecorder(
(text) => {
setTestText(text);
toast('Erkennung abgeschlossen.', 'success');
},
(message) => toast(message, 'error')
);
const handleLoad = async () => {
try {
await load();
toast('Diktat ist bereit.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
const isLoading = status === 'loading';
return (
<div className="space-y-5">
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h2 className="text-base font-semibold text-ink-900">Diktat mit Whisper</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Die Spracherkennung läuft vollständig in diesem Browser. Aufnahmen verlassen das Gerät
nicht und werden nirgends gespeichert – weder auf dem ICON-Server noch bei einem
Dienstleister.
</p>
<p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Download fällt einmal je Browser an; das Modell bleibt danach hier liegen. Aktiviert
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst:
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das
soll niemanden mitten in der Arbeit treffen.
</p>
</section>
{/* Zustand und die beiden Aktionen */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-base font-semibold text-ink-900">
{status === 'ready'
? 'Diktat ist bereit'
: isLoading
? phase === 'init'
? 'Wird eingerichtet …'
: 'Wird geladen …'
: status === 'error'
? 'Laden fehlgeschlagen'
: 'Diktat noch nicht eingerichtet'}
</h3>
<p className="text-sm text-ink-500">
{status === 'ready'
? 'Die Mikrofon-Knöpfe in den Formularen sind aktiv.'
: isLoading
? phase === 'init'
? 'Gleich fertig.'
: progressLabel || 'Download startet …'
: isCached
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.'
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
</p>
</div>
<div className="flex flex-wrap gap-2">
{status !== 'ready' && (
<button
type="button"
onClick={() => void handleLoad()}
disabled={isLoading || !isSupported}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
>
{isLoading
? 'Lädt …'
: isCached
? 'Diktat aktivieren'
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
</button>
)}
{(isCached || status === 'ready') && (
<button
type="button"
onClick={() => setAskClear(true)}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:border-red-300 hover:text-red-600"
>
Sprachmodell löschen
</button>
)}
</div>
</div>
{isLoading && (
<p className="mt-4 rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-600">
Während das Modell eingerichtet wird, kann die Oberfläche kurz stocken. Das legt sich,
sobald oben „Diktat ist bereit" steht.
</p>
)}
{isLoading && (
<div className="mt-4 h-2 overflow-hidden rounded-full bg-ink-100">
<div
className="h-full rounded-full bg-brand-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
{error && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{error}
</p>
)}
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
{!isSupported && (
<p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
Auf diesem Gerät ist kein Diktat möglich – es fehlt ein Mikrofon oder eine sichere
Verbindung. Die Mikrofon-Knöpfe bleiben ausgeblendet.
</p>
)}
{isSupported && !capabilities.webgpu && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieses Gerät hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
Erkennung dauert aber deutlich länger.
</p>
)}
{status === 'ready' && !isCached && (
<p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-700">
Dieser Browser hat das Modell nicht behalten – beim nächsten Start muss es erneut
geladen werden. Ursache ist meist ein privates Fenster oder eine
Speicherbeschränkung.
</p>
)}
</section>
{/* Probe */}
<section className="rounded-xl border border-ink-200 bg-surface p-5">
<h3 className="text-base font-semibold text-ink-900">Probe sprechen</h3>
<p className="mt-1 text-sm text-ink-500">
Kurz etwas diktieren, um zu prüfen, ob alles sitzt.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={recorder.state === 'recording' ? recorder.stop : () => void recorder.start()}
disabled={status !== 'ready' || recorder.state === 'transcribing'}
className={`rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50 ${
recorder.state === 'recording'
? 'border border-red-300 bg-red-50 text-red-700'
: 'bg-brand-600 text-white hover:bg-brand-700'
}`}
>
{recorder.state === 'recording'
? `Aufnahme beenden (${recorder.seconds} s)`
: recorder.state === 'transcribing'
? 'Erkenne …'
: 'Aufnahme starten'}
</button>
{recorder.state === 'recording' && (
<button
type="button"
onClick={recorder.cancel}
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
>
Verwerfen
</button>
)}
{status !== 'ready' && (
<span className="text-xs font-medium text-ink-400">Erst das Sprachmodell laden.</span>
)}
</div>
<div className="mt-4">
<label className={labelClass}>Erkannter Text</label>
<p className="min-h-16 whitespace-pre-wrap rounded-lg border border-ink-200 bg-ink-50 px-4 py-3 text-sm text-ink-700">
{testText || <span className="text-ink-400">Noch keine Probe.</span>}
</p>
</div>
</section>
<ConfirmDialog
open={askClear}
title="Sprachmodell löschen?"
message="Das Modell wird aus diesem Browser entfernt. Für ein weiteres Diktat muss es erneut geladen werden."
confirmLabel="Löschen"
onConfirm={async () => {
await clearCache();
setAskClear(false);
toast('Sprachmodell entfernt.', 'success');
}}
onCancel={() => setAskClear(false)}
/>
</div>
);
};