Files
alpenwerk-hr/components/shell/NotesBell.tsx
Maximilian Stubhan eeaf210e78
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Let the same choice open notes and drafts
The picker in the bell now governs both lists, so note_subscriptions is
renamed to colleague_subscriptions -- a name that only mentions notes would
mislead the next reader.

Reading and writing a draft now reach differently far. hire_drafts_owner
(for all) is split into four policies: select lets in your own drafts and
those of the people you added, while insert/update/delete stay with the
owner. A draft is unfinished work with no lock and no history; two people
writing into the same row would overwrite each other silently.

That split forces a change in the actions: a policy does not reject a write,
it lets it hit no rows. saveHireDraft and deleteHireDraft now read the row
count instead of reporting success over a row that never changed.

The card shows a foreign draft with its author and without Fortsetzen or
Loeschen -- offering a button that reliably ends in a database error is a
promise without cover.

check-schema-types.mjs learns `alter table ... rename to`; without it the
drift check reports one rename as two errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:05:40 +02:00

171 lines
7.8 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">
{/* 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>
);
}