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
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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" />
+8 -8
View File
@@ -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)}
/>
+2 -2
View File
@@ -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"
+4 -4
View File
@@ -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 &ndash; markierte Personen sind spaeter anklickbar.
zu markieren &ndash; 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) => {
+3 -3
View File
@@ -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">
+2 -2
View File
@@ -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', {
+2 -2
View File
@@ -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 }}
>
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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)}
+3 -3
View File
@@ -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 &rarr; 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>
+4 -4
View File
@@ -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.`}
+1 -1
View File
@@ -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) => (
+1 -1
View File
@@ -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">
+6 -6
View File
@@ -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>
+6 -6
View File
@@ -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)}
+2 -2
View File
@@ -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"
+3 -3
View 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">
+2 -2
View File
@@ -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,
}) => (
+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">
<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
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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,
});
}