fix(ui): echte Umlaute in allen sichtbaren Texten

Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").

Unangetastet bleibt bewusst:

- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
  dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
  'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
  Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
  geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
  Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
  sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar

Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 09:37:24 +02:00
co-authored by Claude Opus 5
parent ea55acdb5c
commit 8b7ce448a6
42 changed files with 168 additions and 168 deletions
+4 -4
View File
@@ -71,7 +71,7 @@ async function fetchAccessToken() {
} }
if (!data?.access_token) { if (!data?.access_token) {
throw new GraphError('Anmeldung lieferte kein Token zurueck.', { status: response.status }); throw new GraphError('Anmeldung lieferte kein Token zurück.', { status: response.status });
} }
return data; return data;
@@ -189,7 +189,7 @@ export async function testConnection() {
ok: false, ok: false,
stage: 'token', stage: 'token',
error: err.message, error: err.message,
hint: 'Tenant-ID, Client-ID und Client-Secret in der .env pruefen.', hint: 'Tenant-ID, Client-ID und Client-Secret in der .env prüfen.',
}; };
} }
@@ -211,8 +211,8 @@ export async function testConnection() {
err.code === 'ErrorAccessDenied' || err.status === 403 err.code === 'ErrorAccessDenied' || err.status === 403
? 'Zugriff verweigert: Anwendungsberechtigung Mail.Read erteilt und Administratorzustimmung gegeben? Erlaubt die ApplicationAccessPolicy dieses Postfach?' ? 'Zugriff verweigert: Anwendungsberechtigung Mail.Read erteilt und Administratorzustimmung gegeben? Erlaubt die ApplicationAccessPolicy dieses Postfach?'
: err.code === 'ResourceNotFound' || err.status === 404 : err.code === 'ResourceNotFound' || err.status === 404
? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse pruefen.` ? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse prüfen.`
: 'Berechtigungen und Zugriffsrichtlinie in Exchange Online pruefen.'; : 'Berechtigungen und Zugriffsrichtlinie in Exchange Online prüfen.';
return { ok: false, stage: 'mailbox', error: err.message, code: err.code ?? null, hint }; return { ok: false, stage: 'mailbox', error: err.message, code: err.code ?? null, hint };
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@ router.patch('/me', requireAuth, async (req, res, next) => {
} }
if (fields.length === 0) { if (fields.length === 0) {
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
} }
try { try {
+6 -6
View File
@@ -51,7 +51,7 @@ router.post('/', async (req, res, next) => {
const toId = Number(req.body?.to_lead_id); const toId = Number(req.body?.to_lead_id);
if (!Number.isInteger(fromId) || !Number.isInteger(toId)) { if (!Number.isInteger(fromId) || !Number.isInteger(toId)) {
return res.status(400).json({ error: 'Beide Kontakte muessen angegeben werden.' }); return res.status(400).json({ error: 'Beide Kontakte müssen angegeben werden.' });
} }
if (fromId === toId) { if (fromId === toId) {
return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' }); return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' });
@@ -90,13 +90,13 @@ router.post('/', async (req, res, next) => {
// PATCH /api/connections/:id // PATCH /api/connections/:id
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verbindungs-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Verbindungs-ID.' });
const fields = []; const fields = [];
const values = []; const values = [];
if (req.body?.type !== undefined) { if (req.body?.type !== undefined) {
if (!TYPES.has(req.body.type)) return res.status(400).json({ error: 'Ungueltiger Verbindungstyp.' }); if (!TYPES.has(req.body.type)) return res.status(400).json({ error: 'Ungültiger Verbindungstyp.' });
fields.push('type = ?'); fields.push('type = ?');
values.push(req.body.type); values.push(req.body.type);
} }
@@ -107,13 +107,13 @@ router.patch('/:id', async (req, res, next) => {
if (req.body?.strength !== undefined) { if (req.body?.strength !== undefined) {
const strength = Number(req.body.strength); const strength = Number(req.body.strength);
if (![1, 2, 3].includes(strength)) { if (![1, 2, 3].includes(strength)) {
return res.status(400).json({ error: 'Verbindungsstaerke muss 1, 2 oder 3 sein.' }); return res.status(400).json({ error: 'Verbindungsstärke muss 1, 2 oder 3 sein.' });
} }
fields.push('strength = ?'); fields.push('strength = ?');
values.push(strength); values.push(strength);
} }
if (fields.length === 0) return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try { try {
values.push(id); values.push(id);
@@ -138,7 +138,7 @@ router.patch('/:id', async (req, res, next) => {
// DELETE /api/connections/:id // DELETE /api/connections/:id
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verbindungs-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Verbindungs-ID.' });
try { try {
const [result] = await pool.query('DELETE FROM crm_lead_connections WHERE id = ?', [id]); const [result] = await pool.query('DELETE FROM crm_lead_connections WHERE id = ?', [id]);
+10 -10
View File
@@ -47,11 +47,11 @@ function emptyToNull(value) {
/** Teilnehmer-IDs aus dem Request pruefen und deduplizieren. */ /** Teilnehmer-IDs aus dem Request pruefen und deduplizieren. */
async function normalizeParticipants(raw) { async function normalizeParticipants(raw) {
if (raw === undefined) return undefined; if (raw === undefined) return undefined;
if (!Array.isArray(raw)) return { error: 'Teilnehmer muessen als Liste uebergeben werden.' }; if (!Array.isArray(raw)) return { error: 'Teilnehmer müssen als Liste übergeben werden.' };
const ids = [...new Set(raw.map((value) => Number(value)))]; const ids = [...new Set(raw.map((value) => Number(value)))];
if (ids.some((id) => !Number.isInteger(id) || id <= 0)) { if (ids.some((id) => !Number.isInteger(id) || id <= 0)) {
return { error: 'Ungueltige Teilnehmer-ID.' }; return { error: 'Ungültige Teilnehmer-ID.' };
} }
if (ids.length === 0) return { ids: [] }; if (ids.length === 0) return { ids: [] };
@@ -142,7 +142,7 @@ router.post('/', async (req, res, next) => {
const startsAt = normalizeDateTime(req.body?.starts_at); const startsAt = normalizeDateTime(req.body?.starts_at);
const endsAt = normalizeDateTime(req.body?.ends_at); const endsAt = normalizeDateTime(req.body?.ends_at);
if (!startsAt || !endsAt) { if (!startsAt || !endsAt) {
return res.status(400).json({ error: 'Start und Ende muessen gueltige Zeitpunkte sein.' }); return res.status(400).json({ error: 'Start und Ende müssen gültige Zeitpunkte sein.' });
} }
if (endsAt < startsAt) { if (endsAt < startsAt) {
return res.status(400).json({ error: 'Das Ende darf nicht vor dem Start liegen.' }); return res.status(400).json({ error: 'Das Ende darf nicht vor dem Start liegen.' });
@@ -186,7 +186,7 @@ function mayModify(user, event) {
// PATCH /api/events/:id // PATCH /api/events/:id
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Termin-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Termin-ID.' });
const conn = await pool.getConnection(); const conn = await pool.getConnection();
try { try {
@@ -196,7 +196,7 @@ router.patch('/:id', async (req, res, next) => {
if (!mayModify(req.user, existing)) { if (!mayModify(req.user, existing)) {
return res return res
.status(403) .status(403)
.json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator aendern.' }); .json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator ändern.' });
} }
const fields = []; const fields = [];
@@ -217,7 +217,7 @@ router.patch('/:id', async (req, res, next) => {
let startsAt = existing.starts_at; let startsAt = existing.starts_at;
if (req.body?.starts_at !== undefined) { if (req.body?.starts_at !== undefined) {
startsAt = normalizeDateTime(req.body.starts_at); startsAt = normalizeDateTime(req.body.starts_at);
if (!startsAt) return res.status(400).json({ error: 'Ungueltiger Startzeitpunkt.' }); if (!startsAt) return res.status(400).json({ error: 'Ungültiger Startzeitpunkt.' });
fields.push('starts_at = ?'); fields.push('starts_at = ?');
values.push(startsAt); values.push(startsAt);
} }
@@ -225,7 +225,7 @@ router.patch('/:id', async (req, res, next) => {
let endsAt = existing.ends_at; let endsAt = existing.ends_at;
if (req.body?.ends_at !== undefined) { if (req.body?.ends_at !== undefined) {
endsAt = normalizeDateTime(req.body.ends_at); endsAt = normalizeDateTime(req.body.ends_at);
if (!endsAt) return res.status(400).json({ error: 'Ungueltiger Endzeitpunkt.' }); if (!endsAt) return res.status(400).json({ error: 'Ungültiger Endzeitpunkt.' });
fields.push('ends_at = ?'); fields.push('ends_at = ?');
values.push(endsAt); values.push(endsAt);
} }
@@ -243,7 +243,7 @@ router.patch('/:id', async (req, res, next) => {
if (participants?.error) return res.status(400).json({ error: participants.error }); if (participants?.error) return res.status(400).json({ error: participants.error });
if (fields.length === 0 && !participants) { if (fields.length === 0 && !participants) {
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
} }
await conn.beginTransaction(); await conn.beginTransaction();
@@ -265,7 +265,7 @@ router.patch('/:id', async (req, res, next) => {
// DELETE /api/events/:id - Teilnehmer entfernt die DB per ON DELETE CASCADE // DELETE /api/events/:id - Teilnehmer entfernt die DB per ON DELETE CASCADE
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Termin-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Termin-ID.' });
try { try {
const [rows] = await pool.query('SELECT created_by FROM crm_events WHERE id = ? LIMIT 1', [id]); const [rows] = await pool.query('SELECT created_by FROM crm_events WHERE id = ? LIMIT 1', [id]);
@@ -274,7 +274,7 @@ router.delete('/:id', async (req, res, next) => {
if (!mayModify(req.user, existing)) { if (!mayModify(req.user, existing)) {
return res return res
.status(403) .status(403)
.json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator loeschen.' }); .json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator löschen.' });
} }
await pool.query('DELETE FROM crm_events WHERE id = ?', [id]); await pool.query('DELETE FROM crm_events WHERE id = ?', [id]);
+11 -11
View File
@@ -115,7 +115,7 @@ router.post('/', async (req, res, next) => {
: Number(req.body.owner_id); : Number(req.body.owner_id);
if (Number.isNaN(ownerId) || !(await userExists(ownerId))) { if (Number.isNaN(ownerId) || !(await userExists(ownerId))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
} }
const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by']; const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by'];
@@ -283,7 +283,7 @@ router.get('/mail-pool/sources', async (req, res, next) => {
*/ */
router.post('/mail-pool/promote', async (req, res, next) => { router.post('/mail-pool/promote', async (req, res, next) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : []; const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : [];
if (ids.length === 0) return res.status(400).json({ error: 'Keine Eintraege uebergeben.' }); if (ids.length === 0) return res.status(400).json({ error: 'Keine Einträge übergeben.' });
const keepPool = req.body?.keep_pool === true; const keepPool = req.body?.keep_pool === true;
@@ -323,7 +323,7 @@ router.post('/mail-pool/promote', async (req, res, next) => {
*/ */
router.post('/:id/promote', async (req, res, next) => { router.post('/:id/promote', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Lead-ID.' });
const keepPool = req.body?.keep_pool === true; const keepPool = req.body?.keep_pool === true;
const targetLeadId = const targetLeadId =
@@ -331,7 +331,7 @@ router.post('/:id/promote', async (req, res, next) => {
? null ? null
: Number(req.body.target_lead_id); : Number(req.body.target_lead_id);
if (targetLeadId !== null && !Number.isInteger(targetLeadId)) { if (targetLeadId !== null && !Number.isInteger(targetLeadId)) {
return res.status(400).json({ error: 'Ungueltige Ziel-Kontakt-ID.' }); return res.status(400).json({ error: 'Ungültige Ziel-Kontakt-ID.' });
} }
try { try {
@@ -345,7 +345,7 @@ router.post('/:id/promote', async (req, res, next) => {
? poolLead.owner_id ?? req.user.id ? poolLead.owner_id ?? req.user.id
: Number(req.body.owner_id); : Number(req.body.owner_id);
if (!Number.isInteger(ownerId) || !(await userExists(ownerId))) { if (!Number.isInteger(ownerId) || !(await userExists(ownerId))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
} }
const leadId = await copyPoolLeadToContacts(poolLead, { userId: req.user.id, ownerId }); const leadId = await copyPoolLeadToContacts(poolLead, { userId: req.user.id, ownerId });
@@ -403,7 +403,7 @@ router.post('/:id/promote', async (req, res, next) => {
// PATCH /api/leads/:id // PATCH /api/leads/:id
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Lead-ID.' });
const fields = []; const fields = [];
const values = []; const values = [];
@@ -412,14 +412,14 @@ router.patch('/:id', async (req, res, next) => {
if (req.body?.[field] === undefined) continue; if (req.body?.[field] === undefined) continue;
const value = emptyToNull(req.body[field]); const value = emptyToNull(req.body[field]);
if ((field === 'first_name' || field === 'last_name') && !value) { if ((field === 'first_name' || field === 'last_name') && !value) {
return res.status(400).json({ error: 'Vorname und Nachname duerfen nicht leer sein.' }); return res.status(400).json({ error: 'Vorname und Nachname dürfen nicht leer sein.' });
} }
fields.push(`${field} = ?`); fields.push(`${field} = ?`);
values.push(value); values.push(value);
} }
if (req.body?.status !== undefined) { if (req.body?.status !== undefined) {
if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungueltiger Status.' }); if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungültiger Status.' });
fields.push('status = ?'); fields.push('status = ?');
values.push(req.body.status); values.push(req.body.status);
} }
@@ -433,13 +433,13 @@ router.patch('/:id', async (req, res, next) => {
const ownerId = const ownerId =
req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id); req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id);
if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) { if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
} }
fields.push('owner_id = ?'); fields.push('owner_id = ?');
values.push(ownerId); values.push(ownerId);
} }
if (fields.length === 0) return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try { try {
values.push(id); values.push(id);
@@ -457,7 +457,7 @@ router.patch('/:id', async (req, res, next) => {
// DELETE /api/leads/:id - Verbindungen entfernt die DB per ON DELETE CASCADE // DELETE /api/leads/:id - Verbindungen entfernt die DB per ON DELETE CASCADE
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Lead-ID.' });
try { try {
const [result] = await pool.query('DELETE FROM crm_leads WHERE id = ?', [id]); const [result] = await pool.query('DELETE FROM crm_leads WHERE id = ?', [id]);
+14 -14
View File
@@ -128,7 +128,7 @@ router.post('/settings', requireAdmin, async (req, res, next) => {
const allowed = Object.keys(MAIL_SETTING_DEFAULTS); const allowed = Object.keys(MAIL_SETTING_DEFAULTS);
const incoming = Object.entries(req.body ?? {}).filter(([key]) => allowed.includes(key)); const incoming = Object.entries(req.body ?? {}).filter(([key]) => allowed.includes(key));
if (incoming.length === 0) { if (incoming.length === 0) {
return res.status(400).json({ error: 'Keine bekannten Einstellungen uebergeben.' }); return res.status(400).json({ error: 'Keine bekannten Einstellungen übergeben.' });
} }
for (const [key, value] of incoming) { for (const [key, value] of incoming) {
@@ -180,7 +180,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` }); return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
} }
if (isMailSyncRunning()) { if (isMailSyncRunning()) {
return res.status(409).json({ error: 'Ein Abgleich laeuft bereits. Bitte kurz warten.' }); return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
} }
// Vollstaendiger Neuaufbau auf Wunsch: Delta-Verweis verwerfen. Vorhandene // Vollstaendiger Neuaufbau auf Wunsch: Delta-Verweis verwerfen. Vorhandene
@@ -188,7 +188,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
if (req.body?.reset === true) await resetDelta(); if (req.body?.reset === true) await resetDelta();
const result = await runMailSync({ trigger: 'manuell' }); const result = await runMailSync({ trigger: 'manuell' });
if (result.skipped) return res.status(409).json({ error: `Abgleich uebersprungen: ${result.skipped}` }); if (result.skipped) return res.status(409).json({ error: `Abgleich übersprungen: ${result.skipped}` });
if (!result.ok) return res.status(502).json({ error: result.error, result }); if (!result.ok) return res.status(502).json({ error: result.error, result });
res.json(result); res.json(result);
} catch (err) { } catch (err) {
@@ -259,7 +259,7 @@ router.get('/messages', async (req, res, next) => {
// GET /api/mail/messages/:id // GET /api/mail/messages/:id
router.get('/messages/:id', async (req, res, next) => { router.get('/messages/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
try { try {
const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]); const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
@@ -323,7 +323,7 @@ router.get('/messages/:id', async (req, res, next) => {
// POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen // POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen
router.post('/messages/:id/links', async (req, res, next) => { router.post('/messages/:id/links', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Mail-ID.' });
const leadId = req.body?.lead_id === undefined || req.body.lead_id === null ? null : Number(req.body.lead_id); const leadId = req.body?.lead_id === undefined || req.body.lead_id === null ? null : Number(req.body.lead_id);
const opportunityId = const opportunityId =
@@ -335,10 +335,10 @@ router.post('/messages/:id/links', async (req, res, next) => {
return res.status(400).json({ error: 'Kontakt oder Opportunity angeben.' }); return res.status(400).json({ error: 'Kontakt oder Opportunity angeben.' });
} }
if (leadId !== null && !Number.isInteger(leadId)) { if (leadId !== null && !Number.isInteger(leadId)) {
return res.status(400).json({ error: 'Ungueltige Kontakt-ID.' }); return res.status(400).json({ error: 'Ungültige Kontakt-ID.' });
} }
if (opportunityId !== null && !Number.isInteger(opportunityId)) { if (opportunityId !== null && !Number.isInteger(opportunityId)) {
return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' }); return res.status(400).json({ error: 'Ungültige Opportunity-ID.' });
} }
try { try {
@@ -378,7 +378,7 @@ router.post('/messages/:id/links', async (req, res, next) => {
} }
if (created.length === 0) { if (created.length === 0) {
return res.status(409).json({ error: 'Diese Verknuepfung besteht bereits.' }); return res.status(409).json({ error: 'Diese Verknüpfung besteht bereits.' });
} }
res.status(201).json({ ok: true, created }); res.status(201).json({ ok: true, created });
@@ -390,11 +390,11 @@ router.post('/messages/:id/links', async (req, res, next) => {
// DELETE /api/mail/links/:id // DELETE /api/mail/links/:id
router.delete('/links/:id', async (req, res, next) => { router.delete('/links/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verknuepfungs-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Verknüpfungs-ID.' });
try { try {
const [result] = await pool.query('DELETE FROM crm_mail_links WHERE id = ?', [id]); const [result] = await pool.query('DELETE FROM crm_mail_links WHERE id = ?', [id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Verknuepfung nicht gefunden.' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Verknüpfung nicht gefunden.' });
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
next(err); next(err);
@@ -416,7 +416,7 @@ router.get('/candidates', async (req, res, next) => {
const where = []; const where = [];
const params = []; const params = [];
if (status !== 'alle') { if (status !== 'alle') {
if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungueltiger Status.' }); if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungültiger Status.' });
where.push('c.status = ?'); where.push('c.status = ?');
params.push(status); params.push(status);
} }
@@ -465,7 +465,7 @@ async function loadCandidate(id) {
// POST /api/mail/candidates/:id/adopt - Vorschlag zum Kontakt machen // POST /api/mail/candidates/:id/adopt - Vorschlag zum Kontakt machen
router.post('/candidates/:id/adopt', async (req, res, next) => { router.post('/candidates/:id/adopt', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Vorschlags-ID.' });
try { try {
const candidate = await loadCandidate(id); const candidate = await loadCandidate(id);
@@ -479,7 +479,7 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
req.body?.owner_id === undefined || req.body.owner_id === null req.body?.owner_id === undefined || req.body.owner_id === null
? req.user.id ? req.user.id
: Number(req.body.owner_id); : Number(req.body.owner_id);
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' }); if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungültige Benutzer-ID.' });
/* /*
* Von Hand uebernommen heisst: richtiger Kontakt. Der Vorbereich "Aus Mails" * Von Hand uebernommen heisst: richtiger Kontakt. Der Vorbereich "Aus Mails"
@@ -505,7 +505,7 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
// POST /api/mail/candidates/:id/reject // POST /api/mail/candidates/:id/reject
router.post('/candidates/:id/reject', async (req, res, next) => { router.post('/candidates/:id/reject', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Vorschlags-ID.' });
try { try {
const candidate = await loadCandidate(id); const candidate = await loadCandidate(id);
+12 -12
View File
@@ -28,7 +28,7 @@ function emptyToNull(value) {
function normalizeDate(value) { function normalizeDate(value) {
if (value === null || value === undefined || String(value).trim() === '') return { value: null }; if (value === null || value === undefined || String(value).trim() === '') return { value: null };
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim()); const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim());
if (!match) return { error: 'Ungueltiges Datum (erwartet JJJJ-MM-TT).' }; if (!match) return { error: 'Ungültiges Datum (erwartet JJJJ-MM-TT).' };
const [, year, month, day] = match; const [, year, month, day] = match;
const probe = new Date(Number(year), Number(month) - 1, Number(day)); const probe = new Date(Number(year), Number(month) - 1, Number(day));
@@ -140,13 +140,13 @@ async function userExists(id) {
/** Prueft die Ansprechpartner-Liste `[{ lead_id, role }]`. */ /** Prueft die Ansprechpartner-Liste `[{ lead_id, role }]`. */
async function normalizeContacts(raw) { async function normalizeContacts(raw) {
if (raw === undefined) return undefined; if (raw === undefined) return undefined;
if (!Array.isArray(raw)) return { error: 'Ansprechpartner muessen als Liste uebergeben werden.' }; if (!Array.isArray(raw)) return { error: 'Ansprechpartner müssen als Liste übergeben werden.' };
const seen = new Map(); const seen = new Map();
for (const entry of raw) { for (const entry of raw) {
const leadId = Number(entry?.lead_id ?? entry); const leadId = Number(entry?.lead_id ?? entry);
if (!Number.isInteger(leadId) || leadId <= 0) { if (!Number.isInteger(leadId) || leadId <= 0) {
return { error: 'Ungueltige Kontakt-ID bei den Ansprechpartnern.' }; return { error: 'Ungültige Kontakt-ID bei den Ansprechpartnern.' };
} }
const role = emptyToNull(entry?.role); const role = emptyToNull(entry?.role);
if (role !== null && role.length > 120) { if (role !== null && role.length > 120) {
@@ -209,7 +209,7 @@ router.post('/', async (req, res, next) => {
// etwas anderes gesetzt. // etwas anderes gesetzt.
const stageGiven = req.body?.stage !== undefined && req.body.stage !== null && req.body.stage !== ''; const stageGiven = req.body?.stage !== undefined && req.body.stage !== null && req.body.stage !== '';
if (stageGiven && !STAGES.has(req.body.stage)) { if (stageGiven && !STAGES.has(req.body.stage)) {
return res.status(400).json({ error: 'Ungueltige Phase.' }); return res.status(400).json({ error: 'Ungültige Phase.' });
} }
const stage = stageGiven ? req.body.stage : 'Neu'; const stage = stageGiven ? req.body.stage : 'Neu';
@@ -241,7 +241,7 @@ router.post('/', async (req, res, next) => {
? req.user.id ? req.user.id
: Number(req.body.owner_id); : Number(req.body.owner_id);
if (Number.isNaN(ownerId) || !(await userExists(ownerId))) { if (Number.isNaN(ownerId) || !(await userExists(ownerId))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
} }
const contacts = await normalizeContacts(req.body?.contacts ?? []); const contacts = await normalizeContacts(req.body?.contacts ?? []);
@@ -289,7 +289,7 @@ router.post('/', async (req, res, next) => {
// PATCH /api/opportunities/:id // PATCH /api/opportunities/:id
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' });
const conn = await pool.getConnection(); const conn = await pool.getConnection();
try { try {
@@ -301,7 +301,7 @@ router.patch('/:id', async (req, res, next) => {
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (!mayModify(req.user, existing)) { if (!mayModify(req.user, existing)) {
return res.status(403).json({ return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator aendern.', error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator ändern.',
}); });
} }
@@ -319,7 +319,7 @@ router.patch('/:id', async (req, res, next) => {
} }
if (req.body?.stage !== undefined) { if (req.body?.stage !== undefined) {
if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungueltige Phase.' }); if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' });
fields.push('stage = ?'); fields.push('stage = ?');
values.push(req.body.stage); values.push(req.body.stage);
} }
@@ -367,7 +367,7 @@ router.patch('/:id', async (req, res, next) => {
const ownerId = const ownerId =
req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id); req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id);
if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) { if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
} }
fields.push('owner_id = ?'); fields.push('owner_id = ?');
values.push(ownerId); values.push(ownerId);
@@ -377,7 +377,7 @@ router.patch('/:id', async (req, res, next) => {
if (contacts?.error) return res.status(400).json({ error: contacts.error }); if (contacts?.error) return res.status(400).json({ error: contacts.error });
if (fields.length === 0 && !contacts) { if (fields.length === 0 && !contacts) {
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
} }
await conn.beginTransaction(); await conn.beginTransaction();
@@ -402,7 +402,7 @@ router.patch('/:id', async (req, res, next) => {
// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE // DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Opportunity-ID.' });
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
@@ -413,7 +413,7 @@ router.delete('/:id', async (req, res, next) => {
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
if (!mayModify(req.user, existing)) { if (!mayModify(req.user, existing)) {
return res.status(403).json({ return res.status(403).json({
error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator loeschen.', error: 'Diese Opportunity darf nur der Ersteller, der Zuständige oder ein Administrator löschen.',
}); });
} }
+2 -2
View File
@@ -273,7 +273,7 @@ router.post('/portals/sync-session', async (req, res) => {
try { try {
parsedState = typeof sessionState === 'string' ? JSON.parse(sessionState) : sessionState; parsedState = typeof sessionState === 'string' ? JSON.parse(sessionState) : sessionState;
} catch { } catch {
return res.status(400).json({ error: 'Ungueltiges Format - es muss JSON sein.' }); return res.status(400).json({ error: 'Ungültiges Format - es muss JSON sein.' });
} }
// Drei Formen sind erlaubt: Playwright storageState, reines Cookie-Array // Drei Formen sind erlaubt: Playwright storageState, reines Cookie-Array
@@ -289,7 +289,7 @@ router.post('/portals/sync-session', async (req, res) => {
const cookies = rawCookies.map(normalizeCookie).filter(Boolean); const cookies = rawCookies.map(normalizeCookie).filter(Boolean);
if (cookies.length === 0) { if (cookies.length === 0) {
return res.status(400).json({ error: 'Keine gueltigen Cookies gefunden.' }); return res.status(400).json({ error: 'Keine gültigen Cookies gefunden.' });
} }
portal.sessionState = JSON.stringify({ cookies, origins }); portal.sessionState = JSON.stringify({ cookies, origins });
+6 -6
View File
@@ -120,7 +120,7 @@ router.post('/', async (req, res, next) => {
// PATCH /api/users/:id - bearbeitet Benutzer inkl. Admin // PATCH /api/users/:id - bearbeitet Benutzer inkl. Admin
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Benutzer-ID.' });
try { try {
const [existingRows] = await pool.query('SELECT * FROM crm_users WHERE id = ? LIMIT 1', [id]); const [existingRows] = await pool.query('SELECT * FROM crm_users WHERE id = ? LIMIT 1', [id]);
@@ -156,7 +156,7 @@ router.patch('/:id', async (req, res, next) => {
const nextRole = req.body?.role !== undefined ? String(req.body.role) : existing.role; const nextRole = req.body?.role !== undefined ? String(req.body.role) : existing.role;
if (req.body?.role !== undefined) { if (req.body?.role !== undefined) {
if (!ROLES.has(nextRole)) return res.status(400).json({ error: 'Ungueltige Rolle.' }); if (!ROLES.has(nextRole)) return res.status(400).json({ error: 'Ungültige Rolle.' });
fields.push('role = ?'); fields.push('role = ?');
values.push(nextRole); values.push(nextRole);
} }
@@ -187,7 +187,7 @@ router.patch('/:id', async (req, res, next) => {
} }
if (fields.length === 0) { if (fields.length === 0) {
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
} }
values.push(id); values.push(id);
@@ -209,9 +209,9 @@ router.patch('/:id', async (req, res, next) => {
// DELETE /api/users/:id // DELETE /api/users/:id
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' }); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Benutzer-ID.' });
if (id === req.user.id) { if (id === req.user.id) {
return res.status(400).json({ error: 'Das eigene Konto kann nicht geloescht werden.' }); return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' });
} }
try { try {
@@ -224,7 +224,7 @@ router.delete('/:id', async (req, res, next) => {
if (target.role === 'admin' && target.is_active && (await countOtherActiveAdmins(id)) === 0) { if (target.role === 'admin' && target.is_active && (await countOtherActiveAdmins(id)) === 0) {
return res return res
.status(409) .status(409)
.json({ error: 'Der letzte aktive Administrator kann nicht geloescht werden.' }); .json({ error: 'Der letzte aktive Administrator kann nicht gelöscht werden.' });
} }
await pool.query('DELETE FROM crm_users WHERE id = ?', [id]); await pool.query('DELETE FROM crm_users WHERE id = ?', [id]);
+1 -1
View File
@@ -69,7 +69,7 @@ app.use((err, req, res, next) => {
// nicht als unbehandelter Serverfehler im Log landen. // nicht als unbehandelter Serverfehler im Log landen.
const status = err?.status || err?.statusCode; const status = err?.status || err?.statusCode;
if (err?.type === 'entity.parse.failed' || status === 400) { if (err?.type === 'entity.parse.failed' || status === 400) {
return res.status(400).json({ error: 'Ungueltige Anfrage (JSON konnte nicht gelesen werden).' }); return res.status(400).json({ error: 'Ungültige Anfrage (JSON konnte nicht gelesen werden).' });
} }
if (err?.type === 'entity.too.large' || status === 413) { if (err?.type === 'entity.too.large' || status === 413) {
return res.status(413).json({ error: 'Die Anfrage ist zu gross (max. 6 MB).' }); return res.status(413).json({ error: 'Die Anfrage ist zu gross (max. 6 MB).' });
+2 -2
View File
@@ -18,7 +18,7 @@ const AVATAR_MAX_LENGTH = 3_000_000;
export function normalizeColor(value) { export function normalizeColor(value) {
if (value === null || value === undefined || String(value).trim() === '') return { value: null }; if (value === null || value === undefined || String(value).trim() === '') return { value: null };
const color = String(value).trim().toLowerCase(); const color = String(value).trim().toLowerCase();
if (!COLOR_RE.test(color)) return { error: 'Ungueltige Farbe (erwartet z. B. #6ba04a).' }; if (!COLOR_RE.test(color)) return { error: 'Ungültige Farbe (erwartet z. B. #6ba04a).' };
return { value: color }; return { value: color };
} }
@@ -30,7 +30,7 @@ export function normalizeAvatar(value) {
return { error: 'Das Foto ist zu gross. Bitte ein kleineres Bild verwenden.' }; return { error: 'Das Foto ist zu gross. Bitte ein kleineres Bild verwenden.' };
} }
if (!AVATAR_RE.test(avatar)) { if (!AVATAR_RE.test(avatar)) {
return { error: 'Ungueltiges Foto (erwartet eine Bild-Data-URL oder eine http(s)-Adresse).' }; return { error: 'Ungültiges Foto (erwartet eine Bild-Data-URL oder eine http(s)-Adresse).' };
} }
return { value: avatar }; return { value: avatar };
} }
+2 -2
View File
@@ -21,8 +21,8 @@ export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar
<button <button
onClick={onToggleSidebar} onClick={onToggleSidebar}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800" className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
title="Menue ein-/ausklappen" title="Menü ein-/ausklappen"
aria-label="Menue ein-/ausklappen" aria-label="Menü ein-/ausklappen"
> >
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
+1 -1
View File
@@ -58,7 +58,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
<button <button
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))} onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
className="ml-auto shrink-0 opacity-50 transition hover:opacity-100" className="ml-auto shrink-0 opacity-50 transition hover:opacity-100"
aria-label="Meldung schliessen" aria-label="Meldung schließen"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+8 -8
View File
@@ -717,11 +717,11 @@ export const Calendar: React.FC<CalendarProps> = ({
if (!eventToDelete) return; if (!eventToDelete) return;
try { try {
await eventsApi.remove(eventToDelete.id); await eventsApi.remove(eventToDelete.id);
addToast('Termin geloescht.', 'success'); addToast('Termin gelöscht.', 'success');
setDetailEventId(null); setDetailEventId(null);
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally { } finally {
setEventToDelete(null); setEventToDelete(null);
} }
@@ -828,7 +828,7 @@ export const Calendar: React.FC<CalendarProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => step(-1)} onClick={() => step(-1)}
aria-label="Zurueck" aria-label="Zurück"
className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700" className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -878,7 +878,7 @@ export const Calendar: React.FC<CalendarProps> = ({
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
Termin hinzufuegen Termin hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -927,13 +927,13 @@ export const Calendar: React.FC<CalendarProps> = ({
{/* Anstehende Termine */} {/* Anstehende Termine */}
<aside className="flex flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface"> <aside className="flex flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div className="shrink-0 border-b border-ink-100 px-4 py-3"> <div className="shrink-0 border-b border-ink-100 px-4 py-3">
<h2 className="text-sm text-ink-900">Naechste Termine</h2> <h2 className="text-sm text-ink-900">Nächste Termine</h2>
<p className="text-xs text-ink-400"> <p className="text-xs text-ink-400">
{upcoming.length === 0 {upcoming.length === 0
? 'Keine anstehenden Termine' ? 'Keine anstehenden Termine'
: `${upcoming.length} ${ : `${upcoming.length} ${
upcoming.length === 1 ? 'Termin' : 'Termine' upcoming.length === 1 ? 'Termin' : 'Termine'
} · naechster zuerst`} } · nächster zuerst`}
</p> </p>
</div> </div>
@@ -1091,12 +1091,12 @@ export const Calendar: React.FC<CalendarProps> = ({
{eventToDelete && ( {eventToDelete && (
<ConfirmDialog <ConfirmDialog
title="Termin loeschen?" title="Termin löschen?"
message={`"${eventToDelete.title}"${ message={`"${eventToDelete.title}"${
eventToDelete.description eventToDelete.description
? ` – ${mentionPlainText(eventToDelete.description).slice(0, 80)}` ? ` – ${mentionPlainText(eventToDelete.description).slice(0, 80)}`
: '' : ''
} wird fuer alle Benutzer entfernt.`} } wird für alle Benutzer entfernt.`}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setEventToDelete(null)} onCancel={() => setEventToDelete(null)}
/> />
+2 -2
View File
@@ -59,7 +59,7 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
onClick={onClose} onClick={onClose}
className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100" className="flex-1 rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
> >
Schliessen Schließen
</button> </button>
{canModify && ( {canModify && (
<> <>
@@ -68,7 +68,7 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
onClick={onDelete} onClick={onDelete}
className="rounded-xl border border-red-200 px-4 py-3 text-sm font-bold text-red-600 transition hover:bg-red-50" className="rounded-xl border border-red-200 px-4 py-3 text-sm font-bold text-red-600 transition hover:bg-red-50"
> >
Loeschen Löschen
</button> </button>
<button <button
type="button" type="button"
+4 -4
View File
@@ -131,7 +131,7 @@ export const EventModal: React.FC<EventModalProps> = ({
subtitle={ subtitle={
event event
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}` ? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
: 'Sichtbar fuer alle Benutzer im Teamkalender' : 'Sichtbar für alle Benutzer im Teamkalender'
} }
onClose={onClose} onClose={onClose}
size="lg" size="lg"
@@ -178,7 +178,7 @@ export const EventModal: React.FC<EventModalProps> = ({
onChange={(inputEvent) => setAllDay(inputEvent.target.checked)} onChange={(inputEvent) => setAllDay(inputEvent.target.checked)}
className="h-4 w-4 accent-brand-500" className="h-4 w-4 accent-brand-500"
/> />
<span className="text-sm font-semibold text-ink-700">Ganztaegig</span> <span className="text-sm font-semibold text-ink-700">Ganztägig</span>
</label> </label>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -238,7 +238,7 @@ export const EventModal: React.FC<EventModalProps> = ({
/> />
<p className="mt-1.5 text-xs text-ink-400"> <p className="mt-1.5 text-xs text-ink-400">
<span className="font-bold text-ink-500">@</span> tippen, um einen Kontakt oder Benutzer <span className="font-bold text-ink-500">@</span> tippen, um einen Kontakt oder Benutzer
zu markieren &ndash; markierte Personen sind spaeter anklickbar. zu markieren &ndash; markierte Personen sind später anklickbar.
</p> </p>
</div> </div>
@@ -247,7 +247,7 @@ export const EventModal: React.FC<EventModalProps> = ({
Teilnehmer Teilnehmer
</h3> </h3>
<p className="mb-3 text-xs text-ink-400"> <p className="mb-3 text-xs text-ink-400">
Mehrfachauswahl moeglich. Die Farbe des Termins im Kalender ist die des Erstellers. Mehrfachauswahl möglich. Die Farbe des Termins im Kalender ist die des Erstellers.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{users.map((user) => { {users.map((user) => {
+3 -3
View File
@@ -66,7 +66,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
onClick={onClose} onClick={onClose}
className="w-full rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100" className="w-full rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
> >
Schliessen Schließen
</button> </button>
} }
> >
@@ -85,7 +85,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
<section> <section>
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400"> <h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Naechste Termine Nächste Termine
</h3> </h3>
{upcoming.length === 0 ? ( {upcoming.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400"> <p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400">
@@ -115,7 +115,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
<section> <section>
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400"> <h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Zustaendig fuer {ownedLeads.length} {ownedLeads.length === 1 ? 'Kontakt' : 'Kontakte'} Zuständig für {ownedLeads.length} {ownedLeads.length === 1 ? 'Kontakt' : 'Kontakte'}
</h3> </h3>
{ownedLeads.length === 0 ? ( {ownedLeads.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400"> <p className="rounded-xl border border-dashed border-ink-200 px-4 py-5 text-center text-xs font-semibold text-ink-400">
+2 -2
View File
@@ -188,8 +188,8 @@ export function formatEventRange(event: CalendarEvent): string {
if (event.all_day) { if (event.all_day) {
return isSameDay(start, end) return isSameDay(start, end)
? 'Ganztaegig' ? 'Ganztägig'
: `Ganztaegig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`; : `Ganztägig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`;
} }
if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`; if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`;
return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', { return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', {
+2 -2
View File
@@ -128,7 +128,7 @@ export const MentionText: React.FC<MentionTextProps> = ({
return ( return (
<span <span
key={index} key={index}
title="Diese Person ist nicht mehr verfuegbar." title="Diese Person ist nicht mehr verfügbar."
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-400" className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-400"
> >
@{segment.label} @{segment.label}
@@ -146,7 +146,7 @@ export const MentionText: React.FC<MentionTextProps> = ({
if (segment.kind === 'lead') onOpenLead?.(segment.id); if (segment.kind === 'lead') onOpenLead?.(segment.id);
else onOpenUser?.(segment.id); else onOpenUser?.(segment.id);
}} }}
title={segment.kind === 'lead' ? 'Kontakt oeffnen' : 'Benutzer anzeigen'} title={segment.kind === 'lead' ? 'Kontakt öffnen' : 'Benutzer anzeigen'}
className="mx-0.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 align-baseline text-xs font-bold transition hover:brightness-95" className="mx-0.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 align-baseline text-xs font-bold transition hover:brightness-95"
style={{ backgroundColor: `${tint}1f`, color: tint }} style={{ backgroundColor: `${tint}1f`, color: tint }}
> >
+1 -1
View File
@@ -52,7 +52,7 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
onDelete(lead); onDelete(lead);
}} }}
className="absolute right-3 top-3 rounded-lg p-2 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100" className="absolute right-3 top-3 rounded-lg p-2 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
aria-label={`${leadFullName(lead)} loeschen`} aria-label={`${leadFullName(lead)} löschen`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
+2 -2
View File
@@ -25,7 +25,7 @@ const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
{ key: null, label: 'Erreichbarkeit' }, { key: null, label: 'Erreichbarkeit' },
{ key: 'connections', label: 'Verbindungen' }, { key: 'connections', label: 'Verbindungen' },
{ key: null, label: 'Angelegt von' }, { key: null, label: 'Angelegt von' },
{ key: 'updated_at', label: 'Geaendert' }, { key: 'updated_at', label: 'Geändert' },
]; ];
export const LeadList: React.FC<LeadListProps> = ({ export const LeadList: React.FC<LeadListProps> = ({
@@ -132,7 +132,7 @@ export const LeadList: React.FC<LeadListProps> = ({
onDelete(lead); onDelete(lead);
}} }}
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600" className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
aria-label={`${leadFullName(lead)} loeschen`} aria-label={`${leadFullName(lead)} löschen`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
+7 -7
View File
@@ -131,7 +131,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
return; return;
} }
if (form.linkedin && !form.linkedin.includes('linkedin.com/')) { if (form.linkedin && !form.linkedin.includes('linkedin.com/')) {
addToast('Bitte eine gueltige LinkedIn-URL angeben.', 'error'); addToast('Bitte eine gültige LinkedIn-URL angeben.', 'error');
return; return;
} }
@@ -311,7 +311,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
<h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Adresse</h3> <h3 className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Adresse</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-6"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-6">
<div className="sm:col-span-6"> <div className="sm:col-span-6">
<label className={labelClass}>Strasse und Hausnummer</label> <label className={labelClass}>Straße und Hausnummer</label>
<input value={form.street} onChange={(e) => set('street', e.target.value)} className={inputClass} /> <input value={form.street} onChange={(e) => set('street', e.target.value)} className={inputClass} />
</div> </div>
<div className="sm:col-span-1"> <div className="sm:col-span-1">
@@ -345,7 +345,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
/> />
</div> </div>
<div> <div>
<label className={labelClass}>Zustaendig</label> <label className={labelClass}>Zuständig</label>
<select value={form.owner_id} onChange={(e) => set('owner_id', e.target.value)} className={inputClass}> <select value={form.owner_id} onChange={(e) => set('owner_id', e.target.value)} className={inputClass}>
<option value="">Niemand</option> <option value="">Niemand</option>
{users.map((user) => ( {users.map((user) => (
@@ -399,7 +399,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
onClick={addTags} onClick={addTags}
className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700" className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
> >
Hinzufuegen Hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -410,7 +410,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
rows={4} rows={4}
value={form.notes} value={form.notes}
onChange={(e) => set('notes', e.target.value)} onChange={(e) => set('notes', e.target.value)}
placeholder="Gespraechsnotizen, naechste Schritte, Hintergrund..." placeholder="Gesprächsnotizen, nächste Schritte, Hintergrund..."
className={`${inputClass} resize-none`} className={`${inputClass} resize-none`}
/> />
</div> </div>
@@ -427,7 +427,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
{!lead ? ( {!lead ? (
<div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400"> <div className="rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
Verbindungen koennen gepflegt werden, sobald der Kontakt gespeichert ist. Verbindungen können gepflegt werden, sobald der Kontakt gespeichert ist.
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -471,7 +471,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
onChange={(e) => setNewConnectionId(e.target.value)} onChange={(e) => setNewConnectionId(e.target.value)}
className={`${inputClass} sm:flex-[2]`} className={`${inputClass} sm:flex-[2]`}
> >
<option value="">Kontakt auswaehlen...</option> <option value="">Kontakt auswählen...</option>
{linkableLeads.map((candidate) => ( {linkableLeads.map((candidate) => (
<option key={candidate.id} value={candidate.id}> <option key={candidate.id} value={candidate.id}>
{leadFullName(candidate)} {leadFullName(candidate)}
+3 -3
View File
@@ -586,7 +586,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
<button <button
onClick={() => setTransform((prev) => ({ ...prev, k: Math.min(prev.k * 1.25, MAX_ZOOM) }))} onClick={() => setTransform((prev) => ({ ...prev, k: Math.min(prev.k * 1.25, MAX_ZOOM) }))}
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600" className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-600 shadow-card transition hover:text-brand-600"
title="Vergroessern" title="Vergrößern"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@@ -642,7 +642,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
Ersteller &rarr; Kontakt Ersteller &rarr; Kontakt
</div> </div>
<p className="mt-2 text-[9px] font-semibold uppercase tracking-wider text-ink-300"> <p className="mt-2 text-[9px] font-semibold uppercase tracking-wider text-ink-300">
Ziehen = verschieben · Rad = Zoom · Doppelklick = loesen Ziehen = verschieben · Rad = Zoom · Doppelklick = lösen
</p> </p>
</div> </div>
@@ -912,7 +912,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
<dd className="truncate font-semibold text-ink-700">{selectedLead.created_by_name || '—'}</dd> <dd className="truncate font-semibold text-ink-700">{selectedLead.created_by_name || '—'}</dd>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<dt className="text-ink-400">Zustaendig</dt> <dt className="text-ink-400">Zuständig</dt>
<dd className="truncate font-semibold text-ink-700">{selectedLead.owner_name || '—'}</dd> <dd className="truncate font-semibold text-ink-700">{selectedLead.owner_name || '—'}</dd>
</div> </div>
</dl> </dl>
+4 -4
View File
@@ -182,10 +182,10 @@ export const Leads: React.FC<LeadsProps> = ({
if (!leadToDelete) return; if (!leadToDelete) return;
try { try {
await leadsApi.remove(leadToDelete.id); await leadsApi.remove(leadToDelete.id);
addToast(`${leadFullName(leadToDelete)} wurde geloescht.`, 'success'); addToast(`${leadFullName(leadToDelete)} wurde gelöscht.`, 'success');
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally { } finally {
setLeadToDelete(null); setLeadToDelete(null);
} }
@@ -278,7 +278,7 @@ export const Leads: React.FC<LeadsProps> = ({
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
Kontakt hinzufuegen Kontakt hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -351,7 +351,7 @@ export const Leads: React.FC<LeadsProps> = ({
{leadToDelete && ( {leadToDelete && (
<ConfirmDialog <ConfirmDialog
title="Kontakt loeschen?" title="Kontakt löschen?"
message={`${leadFullName( message={`${leadFullName(
leadToDelete leadToDelete
)} wird unwiderruflich entfernt - inklusive aller Verbindungen zu anderen Kontakten.`} )} wird unwiderruflich entfernt - inklusive aller Verbindungen zu anderen Kontakten.`}
+1 -1
View File
@@ -102,7 +102,7 @@ export const MailLinkDialog: React.FC<MailLinkDialogProps> = ({
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (leadId === '' && opportunityId === '') { if (leadId === '' && opportunityId === '') {
addToast('Kontakt oder Opportunity auswaehlen.', 'error'); addToast('Kontakt oder Opportunity auswählen.', 'error');
return; return;
} }
@@ -194,7 +194,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
addToast(`"${opportunity.title}" ist jetzt in Phase ${stage}.`, 'success'); addToast(`"${opportunity.title}" ist jetzt in Phase ${stage}.`, 'success');
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Phase konnte nicht geaendert werden.', 'error'); addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error');
} }
}; };
@@ -202,10 +202,10 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
if (!toDelete) return; if (!toDelete) return;
try { try {
await opportunitiesApi.remove(toDelete.id); await opportunitiesApi.remove(toDelete.id);
addToast(`"${toDelete.title}" wurde geloescht.`, 'success'); addToast(`"${toDelete.title}" wurde gelöscht.`, 'success');
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally { } finally {
setToDelete(null); setToDelete(null);
} }
@@ -416,7 +416,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
{toDelete && ( {toDelete && (
<ConfirmDialog <ConfirmDialog
title="Opportunity loeschen?" title="Opportunity löschen?"
message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`} message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setToDelete(null)} onCancel={() => setToDelete(null)}
@@ -73,7 +73,7 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
onDelete(opportunity); onDelete(opportunity);
}} }}
className="shrink-0 rounded-lg p-1.5 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100" className="shrink-0 rounded-lg p-1.5 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
aria-label={`${opportunity.title} loeschen`} aria-label={`${opportunity.title} löschen`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -195,7 +195,7 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
avatar: opportunity.owner_avatar ?? null, avatar: opportunity.owner_avatar ?? null,
}} }}
size={24} size={24}
title={`Zustaendig: ${opportunity.owner_name ?? 'unbekannt'}`} title={`Zuständig: ${opportunity.owner_name ?? 'unbekannt'}`}
/> />
)} )}
</div> </div>
@@ -47,7 +47,7 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
duration: 'Laufzeit', duration: 'Laufzeit',
last_interaction: 'Letzte Interaktion', last_interaction: 'Letzte Interaktion',
start_date: 'Projektstart', start_date: 'Projektstart',
updated_at: 'Geaendert', updated_at: 'Geändert',
}; };
/** Diese Schluessel starten absteigend - bei Geld und Datum ist das erwartbar. */ /** Diese Schluessel starten absteigend - bei Geld und Datum ist das erwartbar. */
@@ -70,7 +70,7 @@ const COLUMNS: { key: OpportunitySortKey | null; label: string; align?: 'right'
{ key: 'duration', label: 'Laufzeit', align: 'right' }, { key: 'duration', label: 'Laufzeit', align: 'right' },
{ key: 'last_interaction', label: 'Letzte Interaktion' }, { key: 'last_interaction', label: 'Letzte Interaktion' },
{ key: null, label: 'Ansprechpartner' }, { key: null, label: 'Ansprechpartner' },
{ key: null, label: 'Zustaendig' }, { key: null, label: 'Zuständig' },
]; ];
interface OpportunityListProps { interface OpportunityListProps {
@@ -238,7 +238,7 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
onDelete(opportunity); onDelete(opportunity);
}} }}
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600" className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
aria-label={`${opportunity.title} loeschen`} aria-label={`${opportunity.title} löschen`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -354,7 +354,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
</div> </div>
<div> <div>
<label className={labelClass} htmlFor="opp-owner"> <label className={labelClass} htmlFor="opp-owner">
Zustaendig Zuständig
</label> </label>
<select <select
id="opp-owner" id="opp-owner"
@@ -467,10 +467,10 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
<select <select
value={contactToAdd} value={contactToAdd}
onChange={(event) => setContactToAdd(event.target.value)} onChange={(event) => setContactToAdd(event.target.value)}
aria-label="Kontakt als Ansprechpartner auswaehlen" aria-label="Kontakt als Ansprechpartner auswählen"
className={`${inputClass} sm:flex-1`} className={`${inputClass} sm:flex-1`}
> >
<option value="">Kontakt auswaehlen...</option> <option value="">Kontakt auswählen...</option>
{addableLeads.map((lead) => ( {addableLeads.map((lead) => (
<option key={lead.id} value={lead.id}> <option key={lead.id} value={lead.id}>
{leadFullName(lead)} {leadFullName(lead)}
@@ -484,7 +484,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
disabled={!contactToAdd} disabled={!contactToAdd}
className="shrink-0 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50" className="shrink-0 rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
> >
Hinzufuegen Hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -540,10 +540,10 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
<div className="mt-4"> <div className="mt-4">
<div className="mb-1.5 flex items-center justify-between gap-3"> <div className="mb-1.5 flex items-center justify-between gap-3">
<label className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500" htmlFor="opp-next"> <label className="block text-[11px] font-extrabold uppercase tracking-wider text-ink-500" htmlFor="opp-next">
Naechster Schritt Nächster Schritt
</label> </label>
<DictateButton <DictateButton
label="Naechster Schritt" label="Nächster Schritt"
onBeforeNavigate={onClose} onBeforeNavigate={onClose}
compact compact
onText={(text) => onText={(text) =>
@@ -602,7 +602,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
onClick={addTags} onClick={addTags}
className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700" className="shrink-0 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
> >
Hinzufuegen Hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -164,7 +164,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
event.stopPropagation(); event.stopPropagation();
onStageChange(opportunity, event.target.value as OpportunityStage); onStageChange(opportunity, event.target.value as OpportunityStage);
}} }}
aria-label={`Phase von ${opportunity.title} aendern`} aria-label={`Phase von ${opportunity.title} ändern`}
className="mt-2 w-full rounded-lg border border-ink-200 bg-surface px-2 py-1 text-[11px] font-bold text-ink-600 outline-none transition focus:border-brand-500" className="mt-2 w-full rounded-lg border border-ink-200 bg-surface px-2 py-1 text-[11px] font-bold text-ink-600 outline-none transition focus:border-brand-500"
> >
{OPPORTUNITY_STAGES.map((option) => ( {OPPORTUNITY_STAGES.map((option) => (
+1 -1
View File
@@ -31,7 +31,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallb
})} })}
<label <label
title="Eigene Farbe waehlen" title="Eigene Farbe wählen"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dashed border-ink-300 text-ink-400 transition hover:border-brand-400 hover:text-brand-600" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dashed border-ink-300 text-ink-400 transition hover:border-brand-400 hover:text-brand-600"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+6 -6
View File
@@ -49,7 +49,7 @@ const AccountTab: React.FC = () => {
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
addToast('Die neuen Passwoerter stimmen nicht ueberein.', 'error'); addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
return; return;
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
@@ -60,12 +60,12 @@ const AccountTab: React.FC = () => {
setIsSaving(true); setIsSaving(true);
try { try {
await authApi.changePassword(currentPassword, newPassword); await authApi.changePassword(currentPassword, newPassword);
addToast('Passwort geaendert.', 'success'); addToast('Passwort geändert.', 'success');
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Aenderung fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Änderung fehlgeschlagen.', 'error');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -126,7 +126,7 @@ const AccountTab: React.FC = () => {
</section> </section>
<section className="rounded-2xl border border-ink-200 bg-surface p-5"> <section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Passwort aendern</h3> <h3 className="text-base text-ink-900">Passwort ändern</h3>
<form onSubmit={handleSubmit} className="mt-4 space-y-4"> <form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div> <div>
<label className={labelClass}>Aktuelles Passwort</label> <label className={labelClass}>Aktuelles Passwort</label>
@@ -152,7 +152,7 @@ const AccountTab: React.FC = () => {
/> />
</div> </div>
<div> <div>
<label className={labelClass}>Neues Passwort bestaetigen</label> <label className={labelClass}>Neues Passwort bestätigen</label>
<input <input
type="password" type="password"
required required
@@ -167,7 +167,7 @@ const AccountTab: React.FC = () => {
disabled={isSaving} disabled={isSaving}
className="w-full rounded-xl bg-brand-500 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60" className="w-full rounded-xl bg-brand-500 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-60"
> >
{isSaving ? 'Speichere...' : 'Passwort aendern'} {isSaving ? 'Speichere...' : 'Passwort ändern'}
</button> </button>
</form> </form>
</section> </section>
+6 -6
View File
@@ -145,10 +145,10 @@ export const UserManagement: React.FC = () => {
if (!userToDelete) return; if (!userToDelete) return;
try { try {
await usersApi.remove(userToDelete.id); await usersApi.remove(userToDelete.id);
addToast(`Benutzer "${userToDelete.name}" wurde geloescht.`, 'success'); addToast(`Benutzer "${userToDelete.name}" wurde gelöscht.`, 'success');
await load(); await load();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally { } finally {
setUserToDelete(null); setUserToDelete(null);
} }
@@ -222,7 +222,7 @@ export const UserManagement: React.FC = () => {
<button <button
onClick={() => setUserToDelete(user)} onClick={() => setUserToDelete(user)}
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600" className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
aria-label={`${user.name} loeschen`} aria-label={`${user.name} löschen`}
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -243,7 +243,7 @@ export const UserManagement: React.FC = () => {
{isModalOpen && ( {isModalOpen && (
<Modal <Modal
title={editingUser ? `${editingUser.name} bearbeiten` : 'Neuer Benutzer'} title={editingUser ? `${editingUser.name} bearbeiten` : 'Neuer Benutzer'}
subtitle={editingUser ? `@${editingUser.username}` : 'Zugang fuer das perinco CRM anlegen'} subtitle={editingUser ? `@${editingUser.username}` : 'Zugang für das perinco CRM anlegen'}
onClose={() => setIsModalOpen(false)} onClose={() => setIsModalOpen(false)}
size="md" size="md"
footer={ footer={
@@ -324,7 +324,7 @@ export const UserManagement: React.FC = () => {
required={!editingUser} required={!editingUser}
value={form.password} value={form.password}
onChange={(event) => set('password', event.target.value)} onChange={(event) => set('password', event.target.value)}
placeholder={editingUser ? 'Leer lassen, um es nicht zu aendern' : 'Mindestens 8 Zeichen'} placeholder={editingUser ? 'Leer lassen, um es nicht zu ändern' : 'Mindestens 8 Zeichen'}
className={inputClass} className={inputClass}
autoComplete="new-password" autoComplete="new-password"
/> />
@@ -352,7 +352,7 @@ export const UserManagement: React.FC = () => {
{userToDelete && ( {userToDelete && (
<ConfirmDialog <ConfirmDialog
title="Benutzer loeschen?" title="Benutzer löschen?"
message={`"${userToDelete.name}" verliert den Zugang. Angelegte Kontakte bleiben erhalten, verlieren aber die Zuordnung.`} message={`"${userToDelete.name}" verliert den Zugang. Angelegte Kontakte bleiben erhalten, verlieren aber die Zuordnung.`}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setUserToDelete(null)} onCancel={() => setUserToDelete(null)}
+2 -2
View File
@@ -40,7 +40,7 @@ export const AvatarPicker: React.FC<AvatarPickerProps> = ({
const result = await compressImage(file); const result = await compressImage(file);
onChange(result.dataUrl); onChange(result.dataUrl);
addToast( addToast(
`Foto uebernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`, `Foto übernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`,
'success' 'success'
); );
} catch (err) { } catch (err) {
@@ -62,7 +62,7 @@ export const AvatarPicker: React.FC<AvatarPickerProps> = ({
{isBusy ? ( {isBusy ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" /> <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" />
) : ( ) : (
'Aendern' 'Ändern'
)} )}
<input <input
type="file" type="file"
+3 -3
View File
@@ -75,7 +75,7 @@ export const DictateButton: React.FC<DictateButtonProps> = ({
isRecording isRecording
? 'Aufnahme beenden und erkennen' ? 'Aufnahme beenden und erkennen'
: isBusy : isBusy
? 'Erkennung laeuft...' ? 'Erkennung läuft...'
: `${label} diktieren` : `${label} diktieren`
} }
aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`} aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`}
@@ -160,7 +160,7 @@ export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }
? 'Spracherkennung wird geladen...' ? 'Spracherkennung wird geladen...'
: status === 'error' : status === 'error'
? 'Die Spracherkennung konnte nicht geladen werden.' ? 'Die Spracherkennung konnte nicht geladen werden.'
: 'Diktieren ist moeglich, das Modell fehlt aber noch.'} : 'Diktieren ist möglich, das Modell fehlt aber noch.'}
</p> </p>
<p className="text-xs text-ink-500"> <p className="text-xs text-ink-500">
{isLoading {isLoading
@@ -169,7 +169,7 @@ export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }
: progressLabel || 'Bitte kurz warten.' : progressLabel || 'Bitte kurz warten.'
: isCached : isCached
? 'Das Modell liegt bereits im Browser - es muss nur noch aktiviert werden.' ? 'Das Modell liegt bereits im Browser - es muss nur noch aktiviert werden.'
: 'Das Modell wird einmalig in diesen Browser geladen und laeuft danach lokal auf diesem Geraet.'} : 'Das Modell wird einmalig in diesen Browser geladen und läuft danach lokal auf diesem Gerät.'}
</p> </p>
{isLoading && ( {isLoading && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink-200"> <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink-200">
+2 -2
View File
@@ -49,7 +49,7 @@ export const Modal: React.FC<ModalProps> = ({
<button <button
onClick={onClose} onClick={onClose}
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800" className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Dialog schliessen" aria-label="Dialog schließen"
> >
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -78,7 +78,7 @@ interface ConfirmDialogProps {
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title, title,
message, message,
confirmLabel = 'Loeschen', confirmLabel = 'Löschen',
onConfirm, onConfirm,
onCancel, onCancel,
}) => ( }) => (
+14 -14
View File
@@ -73,13 +73,13 @@ export const WhisperSettings: React.FC = () => {
<section className="rounded-2xl border border-ink-200 bg-surface p-5"> <section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h2 className="text-base text-ink-900">Diktat mit Whisper</h2> <h2 className="text-base text-ink-900">Diktat mit Whisper</h2>
<p className="mt-2 max-w-3xl text-sm text-ink-500"> <p className="mt-2 max-w-3xl text-sm text-ink-500">
Die Spracherkennung laeuft vollstaendig in diesem Browser. Aufnahmen verlassen das Geraet Die Spracherkennung läuft vollständig in diesem Browser. Aufnahmen verlassen das Gerät
nicht und werden nirgends gespeichert - weder auf dem perinco-Server noch bei einem nicht und werden nirgends gespeichert - weder auf dem perinco-Server noch bei einem
Dienstleister. Dienstleister.
</p> </p>
<p className="mt-2 max-w-3xl text-sm text-ink-500"> <p className="mt-2 max-w-3xl text-sm text-ink-500">
Einmal herunterladen genuegt: das Modell bleibt in diesem Browser und steht danach bei Einmal herunterladen genügt: das Modell bleibt in diesem Browser und steht danach bei
jeder Anmeldung bereit. Auf einem anderen Geraet oder in einem anderen Browser faellt der jeder Anmeldung bereit. Auf einem anderen Gerät oder in einem anderen Browser fällt der
Download erneut an. Download erneut an.
</p> </p>
</section> </section>
@@ -120,7 +120,7 @@ export const WhisperSettings: React.FC = () => {
className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60" className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
> >
{isLoading {isLoading
? 'Laedt...' ? 'Lädt...'
: isCached : isCached
? 'Diktat aktivieren' ? 'Diktat aktivieren'
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`} : `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
@@ -131,7 +131,7 @@ export const WhisperSettings: React.FC = () => {
onClick={() => setAskClear(true)} onClick={() => setAskClear(true)}
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-600" className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-600"
> >
Sprachmodell loeschen Sprachmodell löschen
</button> </button>
)} )}
</div> </div>
@@ -155,22 +155,22 @@ export const WhisperSettings: React.FC = () => {
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */} {/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
{!isSupported && ( {!isSupported && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
Auf diesem Geraet ist kein Diktat moeglich - es fehlt ein Mikrofon oder eine sichere Auf diesem Gerät ist kein Diktat möglich - es fehlt ein Mikrofon oder eine sichere
Verbindung. Die Mikrofon-Buttons bleiben ausgeblendet. Verbindung. Die Mikrofon-Buttons bleiben ausgeblendet.
</p> </p>
)} )}
{isSupported && !capabilities.webgpu && ( {isSupported && !capabilities.webgpu && (
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700"> <p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700">
Dieses Geraet hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die Dieses Gerät hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
Erkennung dauert aber deutlich laenger. Erkennung dauert aber deutlich länger.
</p> </p>
)} )}
{status === 'ready' && !isCached && ( {status === 'ready' && !isCached && (
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700"> <p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700">
Dieser Browser hat das Modell nicht behalten - beim naechsten Start muss es erneut Dieser Browser hat das Modell nicht behalten - beim nächsten Start muss es erneut
geladen werden. Ursache ist meist ein privates Fenster oder eine Speicherbeschraenkung. geladen werden. Ursache ist meist ein privates Fenster oder eine Speicherbeschränkung.
</p> </p>
)} )}
</section> </section>
@@ -179,7 +179,7 @@ export const WhisperSettings: React.FC = () => {
<section className="rounded-2xl border border-ink-200 bg-surface p-5"> <section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Probe sprechen</h3> <h3 className="text-base text-ink-900">Probe sprechen</h3>
<p className="mt-1 text-sm text-ink-500"> <p className="mt-1 text-sm text-ink-500">
Kurz etwas diktieren, um zu pruefen, ob alles sitzt. Kurz etwas diktieren, um zu prüfen, ob alles sitzt.
</p> </p>
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
@@ -223,9 +223,9 @@ export const WhisperSettings: React.FC = () => {
{askClear && ( {askClear && (
<ConfirmDialog <ConfirmDialog
title="Sprachmodell loeschen?" title="Sprachmodell löschen?"
message="Das Modell wird aus diesem Browser entfernt. Fuer ein weiteres Diktat muss es erneut geladen werden." message="Das Modell wird aus diesem Browser entfernt. Für ein weiteres Diktat muss es erneut geladen werden."
confirmLabel="Loeschen" confirmLabel="Löschen"
onConfirm={async () => { onConfirm={async () => {
await clearCache(); await clearCache();
setAskClear(false); setAskClear(false);
+1 -1
View File
@@ -51,7 +51,7 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
try { try {
response = await fetch(`/api${path}`, { ...options, headers }); response = await fetch(`/api${path}`, { ...options, headers });
} catch { } catch {
throw new ApiError('Server nicht erreichbar. Bitte Verbindung pruefen.', 0); throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
} }
if (response.status === 401) { if (response.status === 401) {
+1 -1
View File
@@ -115,7 +115,7 @@ export function useRecorder(
try { try {
const { text } = await transcribe(blob); const { text } = await transcribe(blob);
if (text) onText(text); if (text) onText(text);
else onError('Es war nichts Verstaendliches zu hoeren.'); else onError('Es war nichts Verständliches zu hören.');
} catch (err) { } catch (err) {
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.'); onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
} finally { } finally {
+1 -1
View File
@@ -56,7 +56,7 @@ function dataUrlBytes(dataUrl: string): number {
*/ */
export async function compressImage(file: File): Promise<CompressedImage> { export async function compressImage(file: File): Promise<CompressedImage> {
if (file.size > MAX_SOURCE_BYTES) { if (file.size > MAX_SOURCE_BYTES) {
throw new Error('Die Datei ist zu gross (max. 25 MB).'); throw new Error('Die Datei ist zu groß (max. 25 MB).');
} }
const image = await loadImage(file); const image = await loadImage(file);
+2 -2
View File
@@ -188,7 +188,7 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
const adapter = await gpu.requestAdapter(); const adapter = await gpu.requestAdapter();
if (cancelled) return; if (cancelled) return;
if (!adapter) { if (!adapter) {
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfuegbar' })); setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfügbar' }));
return; return;
} }
const info = adapter.info ?? {}; const info = adapter.info ?? {};
@@ -298,7 +298,7 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
}; };
worker.onerror = (event) => { worker.onerror = (event) => {
failLoad(event.message || 'Der Whisper-Worker ist abgestuerzt.'); failLoad(event.message || 'Der Whisper-Worker ist abgestürzt.');
}; };
workerRef.current = worker; workerRef.current = worker;
+1 -1
View File
@@ -104,7 +104,7 @@ function reportFatal(reason: unknown) {
post({ post({
type: 'error', type: 'error',
message: isMemory message: isMemory
? `Der Speicher reicht fuer dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 waehlen.` ? `Der Speicher reicht für dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 wählen.`
: raw, : raw,
}); });
} }