Yesterday's version had it the other way — everyone visible, untick to
hide. The decision from the business side is the opposite: you see your
own notes, and you tick the colleagues you also want. So note_mutes
becomes note_subscriptions and the predicate flips from `not exists` to
`exists`.
The existing rows are not carried over. The meaning inverts rather than
the sign: converting faithfully ("everyone except the muted") would write
almost the whole roster into the new table and reproduce exactly the state
the change is meant to end. Anyone opening the setting tomorrow would
think it had not taken effect. The table is a day old; what is lost is a
few ticks from trying it out.
What this costs is worth saying plainly: the silent case that could not
happen under exceptions can happen now. Do not tick a colleague and you
will not see her follow-ups — not while she is on holiday either. That is
the flip side of the decision, and it is written down in the migration
rather than discovered later.
Each note now says who wrote it. Own notes read "von mir" rather than
repeating your own name, which would sit on every second line and tell
nobody anything. The flag is computed on the server: the user id is
already there, and threading it through four components for one word is a
poor trade. The counter on the button follows the same turn — "+2" for
what you added, nothing when you added nothing.
Verified: 21 tests, five mutation-checked (restoring `not exists`,
dropping the own-notes clause, inverting the default, hiding the author,
and printing your own name instead of "von mir" each turn them red). 489
tests, typecheck, lint, schema drift and build clean. The migration is
reviewed but not run — no reachable database here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
7.5 KiB
TypeScript
166 lines
7.5 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">
|
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-ink-muted hover:bg-surface">
|
|
<Bell className="h-4 w-4" />
|
|
{notes.length > 0 && (
|
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-solid 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">
|
|
<p className="mb-2 text-xs text-ink-muted">
|
|
Wessen Notizen hier 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>
|
|
);
|
|
}
|