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>
171 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|