Zwei Dinge, die beim Ansehen aufgefallen sind.
**Das Rechteck auf dem Panel.** Das Logo bringt sein rosa Feld selbst mit, und
auf der rosa Flaeche stand es als Rechteck in leicht anderem Ton darauf. Der
Ton war dabei derselbe — verschoben hat ihn die Lichtblende, die ueber der
Panelflaeche liegt und unter dem Logo endete.
Das Manual kennt fuer den Schriftzug zwei Faelle, und sie brauchen
verschiedene Dateien: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa
Feld, in einem grossflaechigen rosa Umfeld dagegen wird er nach §3.1 nur mit
Freiraum integriert — die Flaeche ist ja schon da. Es gibt deshalb jetzt
manner-logo-auf-rosa.svg ohne eigenes Feld, und die rosa Flaechen benutzen es.
Damit bleibt nichts mehr uebrig, wo die Blende enden koennte.
Die Blende selbst ist weg. Sie war Weiss auf Rosa und hellte die Markenfarbe
so weit auf, dass sie kaum noch die Markenfarbe war; geblieben ist ein feines
Raster und eine leichte Tiefe zur unteren Ecke, beides aus ink.
**Schwarz auf der Marke.** "Uebersicht", "Berichte" und der Rest der Kopfzeile
standen in ink. Das traegt zwar (7.47:1), sieht aber aus wie Text, der aus
Versehen auf der Marke gelandet ist. Alles Geschriebene auf Rosa steht jetzt
in brand-700: 6.60:1, und es ist dieselbe Paarung, aus der der Schriftzug
selbst besteht — blaue Lettern auf rosa Feld.
Dabei noch eine Schwaeche gefunden: das Abzeichen an der Glocke kam als
danger-solid auf dem rosa Band nur auf 2.78:1 und blieb unter den 3:1, die
WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Mit danger-text
sind es 3.26:1, und die Ziffer darin steht mit 7.12:1 besser als vorher.
**Und ausgemistet.** Die Logodatei wog 74 kB, weil die Vorlage aus Seite 3 des
Manuals gezogen wurde und die ganze Seite mitgenommen hat: 219 Elemente, die
den Fliesstext jener Seite zeichnen ("Der Manner Schriftzug ... darf nicht
veraendert werden"), unsichtbar rosa auf Rosa oder ausserhalb des Beschnitts.
Das eigentliche Logo sind vier Pfade. Beide Fassungen wiegen jetzt 18,7 kB.
Lint, Typen, Schemaabgleich, 552 Tests und der Build sind sauber. Im Browser
nicht gesehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
8.3 KiB
TypeScript
178 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { Bell, Users } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { completeEmployeeNote } from "@/actions/employees";
|
|
import { setNotizSichtbarkeit } from "@/actions/notes";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
|
|
import { fmtDate } from "@/lib/format";
|
|
import type { OpenNote } from "@/lib/notes";
|
|
|
|
export type Kollege = { id: string; name: string; sichtbar: boolean };
|
|
|
|
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
|
|
// listener) — without its draft-text reset, which has no equivalent here.
|
|
export function NotesBell({ notes, kollegen }: { notes: OpenNote[]; kollegen: Kollege[] }) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [completingId, setCompletingId] = useState<string | null>(null);
|
|
const [zeigeEinstellung, setZeigeEinstellung] = useState(false);
|
|
// Der Haken springt sofort um und wird zurückgedreht, wenn das Speichern
|
|
// scheitert. Ohne das fühlt sich jeder Klick wie eine halbe Sekunde
|
|
// Bedenkzeit an — die Liste wird beim Durchgehen mehrfach angetippt.
|
|
const [eigen, setEigen] = useState<Record<string, boolean>>({});
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const sichtbarkeit = (k: Kollege) => eigen[k.id] ?? k.sichtbar;
|
|
const dazugewaehlt = kollegen.filter((k) => sichtbarkeit(k)).length;
|
|
|
|
async function schalte(k: Kollege) {
|
|
const neu = !sichtbarkeit(k);
|
|
setEigen((v) => ({ ...v, [k.id]: neu }));
|
|
const result = await setNotizSichtbarkeit({ kollegeId: k.id, sichtbar: neu });
|
|
if (result.success) {
|
|
router.refresh();
|
|
} else {
|
|
setEigen((v) => ({ ...v, [k.id]: !neu }));
|
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
function onClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", onClickOutside);
|
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
|
}, []);
|
|
|
|
async function handleComplete(note: OpenNote) {
|
|
setCompletingId(note.id);
|
|
const result = await completeEmployeeNote({ note_id: note.id, employee_id: note.employee_id });
|
|
setCompletingId(null);
|
|
if (result.success) {
|
|
showToast("Notiz erledigt.");
|
|
router.refresh();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
{/* brand-700 statt ink-muted: die Glocke sitzt im rosa Markenband der
|
|
Kopfzeile, und ink-muted kommt darauf nur auf 2.33:1. */}
|
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-brand-700 hover:bg-accent-100">
|
|
<Bell className="h-4 w-4" />
|
|
{notes.length > 0 && (
|
|
// danger-text statt danger-solid: als Flaeche auf dem rosa Band kam
|
|
// das hellere Rot nur auf 2.78:1 und blieb damit unter den 3:1, die
|
|
// WCAG 1.4.11 fuer ein bedeutungstragendes Element verlangt. Der
|
|
// dunklere Ton derselben Familie erreicht 3.26:1, und die Ziffer
|
|
// darin steht mit 7.12:1 besser als vorher.
|
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-text px-1 text-[10px] font-bold text-white">
|
|
{notes.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
|
|
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
|
<span className="text-xs font-bold uppercase tracking-wide text-ink-muted">
|
|
Meine Notizen ({notes.length})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setZeigeEinstellung((v) => !v)}
|
|
aria-expanded={zeigeEinstellung}
|
|
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
|
zeigeEinstellung ? "text-brand-700" : "text-ink-muted"
|
|
}`}
|
|
>
|
|
<Users className="h-3.5 w-3.5" />
|
|
Kolleg:innen
|
|
{/* Nur wenn jemand dazugewählt ist: eine „0" bei jedem
|
|
Aufschlagen zu lesen, sagt nichts. */}
|
|
{dazugewaehlt > 0 && <span className="text-brand-700">+{dazugewaehlt}</span>}
|
|
</button>
|
|
</div>
|
|
|
|
{zeigeEinstellung && (
|
|
<div className="border-b border-border bg-surface px-4 py-3">
|
|
{/* Dass die Auswahl auch die Entwürfe auf der Übersicht
|
|
steuert, steht hier und nicht nur dort: sie wird hier
|
|
getroffen, und eine Wirkung, die zwei Klicks entfernt
|
|
sichtbar wird, sucht sonst niemand. */}
|
|
<p className="mb-2 text-xs text-ink-muted">
|
|
Wessen Notizen hier und wessen Entwürfe auf der Übersicht zusätzlich erscheinen. Die
|
|
eigenen sind immer dabei.
|
|
</p>
|
|
{kollegen.length === 0 ? (
|
|
<p className="py-2 text-sm text-ink-muted">Keine weiteren HR-Kolleg:innen freigeschaltet.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-1">
|
|
{kollegen.map((k) => (
|
|
<li key={k.id}>
|
|
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-white">
|
|
<input
|
|
type="checkbox"
|
|
checked={sichtbarkeit(k)}
|
|
onChange={() => schalte(k)}
|
|
className="h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
/>
|
|
<span className={sichtbarkeit(k) ? "text-ink" : "text-ink-muted line-through"}>{k.name}</span>
|
|
</label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{notes.length === 0 ? (
|
|
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
|
) : (
|
|
<ul className="divide-y divide-border">
|
|
{notes.map((n) => (
|
|
<li key={n.id} className="px-4 py-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
|
|
<Link href={`/employees/${n.employee_id}`} className="text-sm font-semibold text-ink hover:text-brand-700 hover:underline">
|
|
{n.employeeName}
|
|
</Link>
|
|
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
|
|
{/* Von wem die Notiz stammt. Sichtbar sind die eigenen und
|
|
die hinzugewählten — ohne diese Zeile wäre nicht zu
|
|
unterscheiden, welche davon man gerade liest. „Ich"
|
|
statt des eigenen Namens: den kennt man. */}
|
|
<p className="mt-1 text-xs text-ink-muted">
|
|
{n.vonMir ? "von mir" : `von ${n.author_name}`}
|
|
</p>
|
|
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleComplete(n)}
|
|
disabled={completingId === n.id}
|
|
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
|
|
>
|
|
✓ Erledigt
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|