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:
co-authored by
Claude Opus 5
parent
ea55acdb5c
commit
8b7ce448a6
@@ -71,7 +71,7 @@ async function fetchAccessToken() {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -189,7 +189,7 @@ export async function testConnection() {
|
||||
ok: false,
|
||||
stage: 'token',
|
||||
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
|
||||
? 'Zugriff verweigert: Anwendungsberechtigung Mail.Read erteilt und Administratorzustimmung gegeben? Erlaubt die ApplicationAccessPolicy dieses Postfach?'
|
||||
: err.code === 'ResourceNotFound' || err.status === 404
|
||||
? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse pruefen.`
|
||||
: 'Berechtigungen und Zugriffsrichtlinie in Exchange Online pruefen.';
|
||||
? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse prüfen.`
|
||||
: 'Berechtigungen und Zugriffsrichtlinie in Exchange Online prüfen.';
|
||||
return { ok: false, stage: 'mailbox', error: err.message, code: err.code ?? null, hint };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' });
|
||||
return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -51,7 +51,7 @@ router.post('/', async (req, res, next) => {
|
||||
const toId = Number(req.body?.to_lead_id);
|
||||
|
||||
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) {
|
||||
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
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
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 values = [];
|
||||
|
||||
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 = ?');
|
||||
values.push(req.body.type);
|
||||
}
|
||||
@@ -107,13 +107,13 @@ router.patch('/:id', async (req, res, next) => {
|
||||
if (req.body?.strength !== undefined) {
|
||||
const strength = Number(req.body.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 = ?');
|
||||
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 {
|
||||
values.push(id);
|
||||
@@ -138,7 +138,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
// DELETE /api/connections/:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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 {
|
||||
const [result] = await pool.query('DELETE FROM crm_lead_connections WHERE id = ?', [id]);
|
||||
|
||||
+10
-10
@@ -47,11 +47,11 @@ function emptyToNull(value) {
|
||||
/** Teilnehmer-IDs aus dem Request pruefen und deduplizieren. */
|
||||
async function normalizeParticipants(raw) {
|
||||
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)))];
|
||||
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: [] };
|
||||
|
||||
@@ -142,7 +142,7 @@ router.post('/', async (req, res, next) => {
|
||||
const startsAt = normalizeDateTime(req.body?.starts_at);
|
||||
const endsAt = normalizeDateTime(req.body?.ends_at);
|
||||
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) {
|
||||
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
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
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();
|
||||
try {
|
||||
@@ -196,7 +196,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
if (!mayModify(req.user, existing)) {
|
||||
return res
|
||||
.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 = [];
|
||||
@@ -217,7 +217,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
let startsAt = existing.starts_at;
|
||||
if (req.body?.starts_at !== undefined) {
|
||||
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 = ?');
|
||||
values.push(startsAt);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
let endsAt = existing.ends_at;
|
||||
if (req.body?.ends_at !== undefined) {
|
||||
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 = ?');
|
||||
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 (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();
|
||||
@@ -265,7 +265,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
// DELETE /api/events/:id - Teilnehmer entfernt die DB per ON DELETE CASCADE
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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 {
|
||||
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)) {
|
||||
return res
|
||||
.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]);
|
||||
|
||||
+11
-11
@@ -115,7 +115,7 @@ router.post('/', async (req, res, next) => {
|
||||
: Number(req.body.owner_id);
|
||||
|
||||
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'];
|
||||
@@ -283,7 +283,7 @@ router.get('/mail-pool/sources', 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) : [];
|
||||
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;
|
||||
|
||||
@@ -323,7 +323,7 @@ router.post('/mail-pool/promote', async (req, res, next) => {
|
||||
*/
|
||||
router.post('/:id/promote', async (req, res, next) => {
|
||||
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 targetLeadId =
|
||||
@@ -331,7 +331,7 @@ router.post('/:id/promote', async (req, res, next) => {
|
||||
? null
|
||||
: Number(req.body.target_lead_id);
|
||||
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 {
|
||||
@@ -345,7 +345,7 @@ router.post('/:id/promote', async (req, res, next) => {
|
||||
? poolLead.owner_id ?? req.user.id
|
||||
: Number(req.body.owner_id);
|
||||
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 });
|
||||
@@ -403,7 +403,7 @@ router.post('/:id/promote', async (req, res, next) => {
|
||||
// PATCH /api/leads/:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
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 values = [];
|
||||
@@ -412,14 +412,14 @@ router.patch('/:id', async (req, res, next) => {
|
||||
if (req.body?.[field] === undefined) continue;
|
||||
const value = emptyToNull(req.body[field]);
|
||||
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} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
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 = ?');
|
||||
values.push(req.body.status);
|
||||
}
|
||||
@@ -433,13 +433,13 @@ router.patch('/:id', async (req, res, next) => {
|
||||
const ownerId =
|
||||
req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id);
|
||||
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 = ?');
|
||||
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 {
|
||||
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
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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 {
|
||||
const [result] = await pool.query('DELETE FROM crm_leads WHERE id = ?', [id]);
|
||||
|
||||
+14
-14
@@ -128,7 +128,7 @@ router.post('/settings', requireAdmin, async (req, res, next) => {
|
||||
const allowed = Object.keys(MAIL_SETTING_DEFAULTS);
|
||||
const incoming = Object.entries(req.body ?? {}).filter(([key]) => allowed.includes(key));
|
||||
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) {
|
||||
@@ -180,7 +180,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
|
||||
}
|
||||
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
|
||||
@@ -188,7 +188,7 @@ router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||
if (req.body?.reset === true) await resetDelta();
|
||||
|
||||
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 });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@@ -259,7 +259,7 @@ router.get('/messages', async (req, res, next) => {
|
||||
// GET /api/mail/messages/:id
|
||||
router.get('/messages/:id', async (req, res, next) => {
|
||||
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 {
|
||||
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
|
||||
router.post('/messages/:id/links', async (req, res, next) => {
|
||||
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 opportunityId =
|
||||
@@ -335,10 +335,10 @@ router.post('/messages/:id/links', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Kontakt oder Opportunity angeben.' });
|
||||
}
|
||||
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)) {
|
||||
return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' });
|
||||
return res.status(400).json({ error: 'Ungültige Opportunity-ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -378,7 +378,7 @@ router.post('/messages/:id/links', async (req, res, next) => {
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -390,11 +390,11 @@ router.post('/messages/:id/links', async (req, res, next) => {
|
||||
// DELETE /api/mail/links/:id
|
||||
router.delete('/links/:id', async (req, res, next) => {
|
||||
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 {
|
||||
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 });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -416,7 +416,7 @@ router.get('/candidates', async (req, res, next) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
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 = ?');
|
||||
params.push(status);
|
||||
}
|
||||
@@ -465,7 +465,7 @@ async function loadCandidate(id) {
|
||||
// POST /api/mail/candidates/:id/adopt - Vorschlag zum Kontakt machen
|
||||
router.post('/candidates/:id/adopt', async (req, res, next) => {
|
||||
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 {
|
||||
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.user.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"
|
||||
@@ -505,7 +505,7 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
|
||||
// POST /api/mail/candidates/:id/reject
|
||||
router.post('/candidates/:id/reject', async (req, res, next) => {
|
||||
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 {
|
||||
const candidate = await loadCandidate(id);
|
||||
|
||||
@@ -28,7 +28,7 @@ function emptyToNull(value) {
|
||||
function normalizeDate(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
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 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 }]`. */
|
||||
async function normalizeContacts(raw) {
|
||||
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();
|
||||
for (const entry of raw) {
|
||||
const leadId = Number(entry?.lead_id ?? entry);
|
||||
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);
|
||||
if (role !== null && role.length > 120) {
|
||||
@@ -209,7 +209,7 @@ router.post('/', async (req, res, next) => {
|
||||
// etwas anderes gesetzt.
|
||||
const stageGiven = req.body?.stage !== undefined && req.body.stage !== null && 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';
|
||||
|
||||
@@ -241,7 +241,7 @@ router.post('/', async (req, res, next) => {
|
||||
? req.user.id
|
||||
: Number(req.body.owner_id);
|
||||
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 ?? []);
|
||||
@@ -289,7 +289,7 @@ router.post('/', async (req, res, next) => {
|
||||
// PATCH /api/opportunities/:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
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();
|
||||
try {
|
||||
@@ -301,7 +301,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' });
|
||||
if (!mayModify(req.user, existing)) {
|
||||
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 (!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 = ?');
|
||||
values.push(req.body.stage);
|
||||
}
|
||||
@@ -367,7 +367,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
const ownerId =
|
||||
req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id);
|
||||
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 = ?');
|
||||
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 (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();
|
||||
@@ -402,7 +402,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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 {
|
||||
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 (!mayModify(req.user, existing)) {
|
||||
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.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ router.post('/portals/sync-session', async (req, res) => {
|
||||
try {
|
||||
parsedState = typeof sessionState === 'string' ? JSON.parse(sessionState) : sessionState;
|
||||
} 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
|
||||
@@ -289,7 +289,7 @@ router.post('/portals/sync-session', async (req, res) => {
|
||||
|
||||
const cookies = rawCookies.map(normalizeCookie).filter(Boolean);
|
||||
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 });
|
||||
|
||||
@@ -120,7 +120,7 @@ router.post('/', async (req, res, next) => {
|
||||
// PATCH /api/users/:id - bearbeitet Benutzer inkl. Admin
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
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 {
|
||||
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;
|
||||
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 = ?');
|
||||
values.push(nextRole);
|
||||
}
|
||||
@@ -187,7 +187,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -209,9 +209,9 @@ router.patch('/:id', async (req, res, next) => {
|
||||
// DELETE /api/users/:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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) {
|
||||
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 {
|
||||
@@ -224,7 +224,7 @@ router.delete('/:id', async (req, res, next) => {
|
||||
if (target.role === 'admin' && target.is_active && (await countOtherActiveAdmins(id)) === 0) {
|
||||
return res
|
||||
.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]);
|
||||
|
||||
@@ -69,7 +69,7 @@ app.use((err, req, res, next) => {
|
||||
// nicht als unbehandelter Serverfehler im Log landen.
|
||||
const status = err?.status || err?.statusCode;
|
||||
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) {
|
||||
return res.status(413).json({ error: 'Die Anfrage ist zu gross (max. 6 MB).' });
|
||||
|
||||
@@ -18,7 +18,7 @@ const AVATAR_MAX_LENGTH = 3_000_000;
|
||||
export function normalizeColor(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function normalizeAvatar(value) {
|
||||
return { error: 'Das Foto ist zu gross. Bitte ein kleineres Bild verwenden.' };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
|
||||
title="Menue ein-/ausklappen"
|
||||
aria-label="Menue ein-/ausklappen"
|
||||
title="Menü ein-/ausklappen"
|
||||
aria-label="Menü ein-/ausklappen"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
<button
|
||||
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
||||
@@ -717,11 +717,11 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
if (!eventToDelete) return;
|
||||
try {
|
||||
await eventsApi.remove(eventToDelete.id);
|
||||
addToast('Termin geloescht.', 'success');
|
||||
addToast('Termin gelöscht.', 'success');
|
||||
setDetailEventId(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setEventToDelete(null);
|
||||
}
|
||||
@@ -828,7 +828,7 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Termin hinzufuegen
|
||||
Termin hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -927,13 +927,13 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
{/* Anstehende Termine */}
|
||||
<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">
|
||||
<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">
|
||||
{upcoming.length === 0
|
||||
? 'Keine anstehenden Termine'
|
||||
: `${upcoming.length} ${
|
||||
upcoming.length === 1 ? 'Termin' : 'Termine'
|
||||
} · naechster zuerst`}
|
||||
} · nächster zuerst`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1091,12 +1091,12 @@ export const Calendar: React.FC<CalendarProps> = ({
|
||||
|
||||
{eventToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Termin loeschen?"
|
||||
title="Termin löschen?"
|
||||
message={`"${eventToDelete.title}"${
|
||||
eventToDelete.description
|
||||
? ` – ${mentionPlainText(eventToDelete.description).slice(0, 80)}`
|
||||
: ''
|
||||
} wird fuer alle Benutzer entfernt.`}
|
||||
} wird für alle Benutzer entfernt.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setEventToDelete(null)}
|
||||
/>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Schliessen
|
||||
Schließen
|
||||
</button>
|
||||
{canModify && (
|
||||
<>
|
||||
@@ -68,7 +68,7 @@ export const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Loeschen
|
||||
Löschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -131,7 +131,7 @@ export const EventModal: React.FC<EventModalProps> = ({
|
||||
subtitle={
|
||||
event
|
||||
? `Angelegt von ${event.created_by_name ?? 'unbekannt'}`
|
||||
: 'Sichtbar fuer alle Benutzer im Teamkalender'
|
||||
: 'Sichtbar für alle Benutzer im Teamkalender'
|
||||
}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
@@ -178,7 +178,7 @@ export const EventModal: React.FC<EventModalProps> = ({
|
||||
onChange={(inputEvent) => setAllDay(inputEvent.target.checked)}
|
||||
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>
|
||||
|
||||
<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">
|
||||
<span className="font-bold text-ink-500">@</span> tippen, um einen Kontakt oder Benutzer
|
||||
zu markieren – markierte Personen sind spaeter anklickbar.
|
||||
zu markieren – markierte Personen sind später anklickbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,7 @@ export const EventModal: React.FC<EventModalProps> = ({
|
||||
Teilnehmer
|
||||
</h3>
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{users.map((user) => {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Schliessen
|
||||
Schließen
|
||||
</button>
|
||||
}
|
||||
>
|
||||
@@ -85,7 +85,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
|
||||
Naechste Termine
|
||||
Nächste Termine
|
||||
</h3>
|
||||
{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">
|
||||
@@ -115,7 +115,7 @@ export const UserDetailModal: React.FC<UserDetailModalProps> = ({
|
||||
|
||||
<section>
|
||||
<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>
|
||||
{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">
|
||||
|
||||
@@ -188,8 +188,8 @@ export function formatEventRange(event: CalendarEvent): string {
|
||||
|
||||
if (event.all_day) {
|
||||
return isSameDay(start, end)
|
||||
? 'Ganztaegig'
|
||||
: `Ganztaegig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`;
|
||||
? 'Ganztägig'
|
||||
: `Ganztägig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`;
|
||||
}
|
||||
if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`;
|
||||
return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', {
|
||||
|
||||
@@ -128,7 +128,7 @@ export const MentionText: React.FC<MentionTextProps> = ({
|
||||
return (
|
||||
<span
|
||||
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"
|
||||
>
|
||||
@{segment.label}
|
||||
@@ -146,7 +146,7 @@ export const MentionText: React.FC<MentionTextProps> = ({
|
||||
if (segment.kind === 'lead') onOpenLead?.(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"
|
||||
style={{ backgroundColor: `${tint}1f`, color: tint }}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
|
||||
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"
|
||||
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">
|
||||
<path
|
||||
|
||||
@@ -25,7 +25,7 @@ const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
|
||||
{ key: null, label: 'Erreichbarkeit' },
|
||||
{ key: 'connections', label: 'Verbindungen' },
|
||||
{ key: null, label: 'Angelegt von' },
|
||||
{ key: 'updated_at', label: 'Geaendert' },
|
||||
{ key: 'updated_at', label: 'Geändert' },
|
||||
];
|
||||
|
||||
export const LeadList: React.FC<LeadListProps> = ({
|
||||
@@ -132,7 +132,7 @@ export const LeadList: React.FC<LeadListProps> = ({
|
||||
onDelete(lead);
|
||||
}}
|
||||
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">
|
||||
<path
|
||||
|
||||
@@ -131,7 +131,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-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} />
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
@@ -345,7 +345,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
/>
|
||||
</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}>
|
||||
<option value="">Niemand</option>
|
||||
{users.map((user) => (
|
||||
@@ -399,7 +399,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Hinzufuegen
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,7 +410,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Gespraechsnotizen, naechste Schritte, Hintergrund..."
|
||||
placeholder="Gesprächsnotizen, nächste Schritte, Hintergrund..."
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,7 +427,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
|
||||
{!lead ? (
|
||||
<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 className="space-y-3">
|
||||
@@ -471,7 +471,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
onChange={(e) => setNewConnectionId(e.target.value)}
|
||||
className={`${inputClass} sm:flex-[2]`}
|
||||
>
|
||||
<option value="">Kontakt auswaehlen...</option>
|
||||
<option value="">Kontakt auswählen...</option>
|
||||
{linkableLeads.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{leadFullName(candidate)}
|
||||
|
||||
@@ -586,7 +586,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
|
||||
<button
|
||||
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"
|
||||
title="Vergroessern"
|
||||
title="Vergrößern"
|
||||
>
|
||||
<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" />
|
||||
@@ -642,7 +642,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
|
||||
Ersteller → Kontakt
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -912,7 +912,7 @@ export const LeadNetwork: React.FC<LeadNetworkProps> = ({
|
||||
<dd className="truncate font-semibold text-ink-700">{selectedLead.created_by_name || '—'}</dd>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -182,10 +182,10 @@ export const Leads: React.FC<LeadsProps> = ({
|
||||
if (!leadToDelete) return;
|
||||
try {
|
||||
await leadsApi.remove(leadToDelete.id);
|
||||
addToast(`${leadFullName(leadToDelete)} wurde geloescht.`, 'success');
|
||||
addToast(`${leadFullName(leadToDelete)} wurde gelöscht.`, 'success');
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Kontakt hinzufuegen
|
||||
Kontakt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@ export const Leads: React.FC<LeadsProps> = ({
|
||||
|
||||
{leadToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Kontakt loeschen?"
|
||||
title="Kontakt löschen?"
|
||||
message={`${leadFullName(
|
||||
leadToDelete
|
||||
)} wird unwiderruflich entfernt - inklusive aller Verbindungen zu anderen Kontakten.`}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const MailLinkDialog: React.FC<MailLinkDialogProps> = ({
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (leadId === '' && opportunityId === '') {
|
||||
addToast('Kontakt oder Opportunity auswaehlen.', 'error');
|
||||
addToast('Kontakt oder Opportunity auswählen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
||||
addToast(`"${opportunity.title}" ist jetzt in Phase ${stage}.`, 'success');
|
||||
reload();
|
||||
} 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;
|
||||
try {
|
||||
await opportunitiesApi.remove(toDelete.id);
|
||||
addToast(`"${toDelete.title}" wurde geloescht.`, 'success');
|
||||
addToast(`"${toDelete.title}" wurde gelöscht.`, 'success');
|
||||
reload();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setToDelete(null);
|
||||
}
|
||||
@@ -416,7 +416,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title="Opportunity loeschen?"
|
||||
title="Opportunity löschen?"
|
||||
message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setToDelete(null)}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
|
||||
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"
|
||||
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">
|
||||
<path
|
||||
@@ -195,7 +195,7 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
|
||||
avatar: opportunity.owner_avatar ?? null,
|
||||
}}
|
||||
size={24}
|
||||
title={`Zustaendig: ${opportunity.owner_name ?? 'unbekannt'}`}
|
||||
title={`Zuständig: ${opportunity.owner_name ?? 'unbekannt'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const SORT_LABELS: Record<OpportunitySortKey, string> = {
|
||||
duration: 'Laufzeit',
|
||||
last_interaction: 'Letzte Interaktion',
|
||||
start_date: 'Projektstart',
|
||||
updated_at: 'Geaendert',
|
||||
updated_at: 'Geändert',
|
||||
};
|
||||
|
||||
/** 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: 'last_interaction', label: 'Letzte Interaktion' },
|
||||
{ key: null, label: 'Ansprechpartner' },
|
||||
{ key: null, label: 'Zustaendig' },
|
||||
{ key: null, label: 'Zuständig' },
|
||||
];
|
||||
|
||||
interface OpportunityListProps {
|
||||
@@ -238,7 +238,7 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
|
||||
onDelete(opportunity);
|
||||
}}
|
||||
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">
|
||||
<path
|
||||
|
||||
@@ -354,7 +354,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass} htmlFor="opp-owner">
|
||||
Zustaendig
|
||||
Zuständig
|
||||
</label>
|
||||
<select
|
||||
id="opp-owner"
|
||||
@@ -467,10 +467,10 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
<select
|
||||
value={contactToAdd}
|
||||
onChange={(event) => setContactToAdd(event.target.value)}
|
||||
aria-label="Kontakt als Ansprechpartner auswaehlen"
|
||||
aria-label="Kontakt als Ansprechpartner auswählen"
|
||||
className={`${inputClass} sm:flex-1`}
|
||||
>
|
||||
<option value="">Kontakt auswaehlen...</option>
|
||||
<option value="">Kontakt auswählen...</option>
|
||||
{addableLeads.map((lead) => (
|
||||
<option key={lead.id} value={lead.id}>
|
||||
{leadFullName(lead)}
|
||||
@@ -484,7 +484,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Hinzufuegen
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,10 +540,10 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
<div className="mt-4">
|
||||
<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">
|
||||
Naechster Schritt
|
||||
Nächster Schritt
|
||||
</label>
|
||||
<DictateButton
|
||||
label="Naechster Schritt"
|
||||
label="Nächster Schritt"
|
||||
onBeforeNavigate={onClose}
|
||||
compact
|
||||
onText={(text) =>
|
||||
@@ -602,7 +602,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
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"
|
||||
>
|
||||
Hinzufuegen
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
||||
event.stopPropagation();
|
||||
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"
|
||||
>
|
||||
{OPPORTUNITY_STAGES.map((option) => (
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({ value, onChange, fallb
|
||||
})}
|
||||
|
||||
<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"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -49,7 +49,7 @@ const AccountTab: React.FC = () => {
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
addToast('Die neuen Passwoerter stimmen nicht ueberein.', 'error');
|
||||
addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
@@ -60,12 +60,12 @@ const AccountTab: React.FC = () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
addToast('Passwort geaendert.', 'success');
|
||||
addToast('Passwort geändert.', 'success');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Aenderung fehlgeschlagen.', 'error');
|
||||
addToast(err instanceof Error ? err.message : 'Änderung fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ const AccountTab: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<label className={labelClass}>Aktuelles Passwort</label>
|
||||
@@ -152,7 +152,7 @@ const AccountTab: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Neues Passwort bestaetigen</label>
|
||||
<label className={labelClass}>Neues Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
@@ -167,7 +167,7 @@ const AccountTab: React.FC = () => {
|
||||
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"
|
||||
>
|
||||
{isSaving ? 'Speichere...' : 'Passwort aendern'}
|
||||
{isSaving ? 'Speichere...' : 'Passwort ändern'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -145,10 +145,10 @@ export const UserManagement: React.FC = () => {
|
||||
if (!userToDelete) return;
|
||||
try {
|
||||
await usersApi.remove(userToDelete.id);
|
||||
addToast(`Benutzer "${userToDelete.name}" wurde geloescht.`, 'success');
|
||||
addToast(`Benutzer "${userToDelete.name}" wurde gelöscht.`, 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
|
||||
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setUserToDelete(null);
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export const UserManagement: React.FC = () => {
|
||||
<button
|
||||
onClick={() => setUserToDelete(user)}
|
||||
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">
|
||||
<path
|
||||
@@ -243,7 +243,7 @@ export const UserManagement: React.FC = () => {
|
||||
{isModalOpen && (
|
||||
<Modal
|
||||
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)}
|
||||
size="md"
|
||||
footer={
|
||||
@@ -324,7 +324,7 @@ export const UserManagement: React.FC = () => {
|
||||
required={!editingUser}
|
||||
value={form.password}
|
||||
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}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
@@ -352,7 +352,7 @@ export const UserManagement: React.FC = () => {
|
||||
|
||||
{userToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Benutzer loeschen?"
|
||||
title="Benutzer löschen?"
|
||||
message={`"${userToDelete.name}" verliert den Zugang. Angelegte Kontakte bleiben erhalten, verlieren aber die Zuordnung.`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setUserToDelete(null)}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const AvatarPicker: React.FC<AvatarPickerProps> = ({
|
||||
const result = await compressImage(file);
|
||||
onChange(result.dataUrl);
|
||||
addToast(
|
||||
`Foto uebernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`,
|
||||
`Foto übernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`,
|
||||
'success'
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -62,7 +62,7 @@ export const AvatarPicker: React.FC<AvatarPickerProps> = ({
|
||||
{isBusy ? (
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
) : (
|
||||
'Aendern'
|
||||
'Ändern'
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -75,7 +75,7 @@ export const DictateButton: React.FC<DictateButtonProps> = ({
|
||||
isRecording
|
||||
? 'Aufnahme beenden und erkennen'
|
||||
: isBusy
|
||||
? 'Erkennung laeuft...'
|
||||
? 'Erkennung läuft...'
|
||||
: `${label} diktieren`
|
||||
}
|
||||
aria-label={isRecording ? 'Aufnahme beenden' : `${label} diktieren`}
|
||||
@@ -160,7 +160,7 @@ export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }
|
||||
? 'Spracherkennung wird geladen...'
|
||||
: status === 'error'
|
||||
? '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 className="text-xs text-ink-500">
|
||||
{isLoading
|
||||
@@ -169,7 +169,7 @@ export const DictationHint: React.FC<DictationHintProps> = ({ onBeforeNavigate }
|
||||
: progressLabel || 'Bitte kurz warten.'
|
||||
: isCached
|
||||
? '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>
|
||||
{isLoading && (
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||
|
||||
@@ -49,7 +49,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
<button
|
||||
onClick={onClose}
|
||||
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">
|
||||
<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> = ({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Loeschen',
|
||||
confirmLabel = 'Löschen',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => (
|
||||
|
||||
@@ -73,13 +73,13 @@ export const WhisperSettings: React.FC = () => {
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h2 className="text-base text-ink-900">Diktat mit Whisper</h2>
|
||||
<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
|
||||
Dienstleister.
|
||||
</p>
|
||||
<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
|
||||
jeder Anmeldung bereit. Auf einem anderen Geraet oder in einem anderen Browser faellt der
|
||||
Einmal herunterladen genügt: das Modell bleibt in diesem Browser und steht danach bei
|
||||
jeder Anmeldung bereit. Auf einem anderen Gerät oder in einem anderen Browser fällt der
|
||||
Download erneut an.
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
{isLoading
|
||||
? 'Laedt...'
|
||||
? 'Lädt...'
|
||||
: isCached
|
||||
? 'Diktat aktivieren'
|
||||
: `Sprachmodell laden${sizeLabel ? ` (${sizeLabel})` : ''}`}
|
||||
@@ -131,7 +131,7 @@ export const WhisperSettings: React.FC = () => {
|
||||
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"
|
||||
>
|
||||
Sprachmodell loeschen
|
||||
Sprachmodell löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,22 +155,22 @@ export const WhisperSettings: React.FC = () => {
|
||||
{/* Nur melden, was den Benutzer betrifft - sonst Stille. */}
|
||||
{!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">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Dieses Geraet hat keine nutzbare Grafikbeschleunigung. Das Diktat funktioniert, die
|
||||
Erkennung dauert aber deutlich laenger.
|
||||
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-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
|
||||
geladen werden. Ursache ist meist ein privates Fenster oder eine Speicherbeschraenkung.
|
||||
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>
|
||||
@@ -179,7 +179,7 @@ export const WhisperSettings: React.FC = () => {
|
||||
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
|
||||
<h3 className="text-base text-ink-900">Probe sprechen</h3>
|
||||
<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>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
@@ -223,9 +223,9 @@ export const WhisperSettings: React.FC = () => {
|
||||
|
||||
{askClear && (
|
||||
<ConfirmDialog
|
||||
title="Sprachmodell loeschen?"
|
||||
message="Das Modell wird aus diesem Browser entfernt. Fuer ein weiteres Diktat muss es erneut geladen werden."
|
||||
confirmLabel="Loeschen"
|
||||
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);
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
|
||||
try {
|
||||
response = await fetch(`/api${path}`, { ...options, headers });
|
||||
} 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) {
|
||||
|
||||
@@ -115,7 +115,7 @@ export function useRecorder(
|
||||
try {
|
||||
const { text } = await transcribe(blob);
|
||||
if (text) onText(text);
|
||||
else onError('Es war nichts Verstaendliches zu hoeren.');
|
||||
else onError('Es war nichts Verständliches zu hören.');
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.');
|
||||
} finally {
|
||||
|
||||
@@ -56,7 +56,7 @@ function dataUrlBytes(dataUrl: string): number {
|
||||
*/
|
||||
export async function compressImage(file: File): Promise<CompressedImage> {
|
||||
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);
|
||||
|
||||
+2
-2
@@ -188,7 +188,7 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
const adapter = await gpu.requestAdapter();
|
||||
if (cancelled) return;
|
||||
if (!adapter) {
|
||||
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfuegbar' }));
|
||||
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfügbar' }));
|
||||
return;
|
||||
}
|
||||
const info = adapter.info ?? {};
|
||||
@@ -298,7 +298,7 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
failLoad(event.message || 'Der Whisper-Worker ist abgestuerzt.');
|
||||
failLoad(event.message || 'Der Whisper-Worker ist abgestürzt.');
|
||||
};
|
||||
|
||||
workerRef.current = worker;
|
||||
|
||||
@@ -104,7 +104,7 @@ function reportFatal(reason: unknown) {
|
||||
post({
|
||||
type: 'error',
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user