Choose whose notes reach your bell
The bell is a shared pile: every active HR person sees every open note, regardless of who wrote it. That was agreed and it stays the default — this narrows it, it never widens it. You can now untick colleagues whose notes you do not want to see. What gets stored is the *exceptions*, not the selection. The difference shows the day someone new joins HR: had the selection been stored, she would be invisible to everyone until each person ticked her, and nobody would notice her follow-ups piling up. This way she is visible from day one and hiding her is a deliberate act. Same reasoning that made notes a shared inbox in the first place — the silent gap is worse than a row too many. Own notes always come through: `note_mutes` rejects a self-reference, and the predicate says so again rather than depending on a check constraint staying put. Notes with no author come through too — hiding one because nobody knows who wrote it is exactly the loss this list exists to prevent. The rule lives in lib/notes.ts as one SQL expression because two places need it: the bell in the header and the "Anstehend" card on the dashboard. Two copies drift, and then the card counts something the bell does not show. No SQL function and no audit row, unlike anything that touches employee data — this is a personal display preference, and an audit trail recording every tick would make finding real changes harder. Same pattern as saved reports and hire drafts, and the owner policy on note_mutes means a row for someone else cannot be written even with invented values. The checkbox flips immediately and flips back if saving fails; the list gets clicked through several at a time and a round trip per tick feels like hesitation. Verified: 19 tests, five mutation-checked (or→and, dropping the own-notes clause, inverting `not exists`, inverting the default, and losing the email fallback each turn them red). Typecheck, lint, schema drift, 477 tests and the build are clean. Not seen in a browser: login goes through the company account and the database is unreachable — the migration is reviewed but has not been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,17 @@ import { Topbar } from "./Topbar";
|
||||
// Owns the one piece of state the shell needs (is the mobile drawer open),
|
||||
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
|
||||
// auth check and data loading on the server.
|
||||
export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) {
|
||||
export function AppShell({
|
||||
userLabel,
|
||||
openNotes,
|
||||
kollegen,
|
||||
children,
|
||||
}: {
|
||||
userLabel: string;
|
||||
openNotes: OpenNote[];
|
||||
kollegen: { id: string; name: string; sichtbar: boolean }[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const closeNav = useCallback(() => setNavOpen(false), []);
|
||||
|
||||
@@ -27,7 +37,7 @@ export function AppShell({ userLabel, openNotes, children }: { userLabel: string
|
||||
</div>
|
||||
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
|
||||
<div className="print:hidden">
|
||||
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
|
||||
<Topbar userLabel={userLabel} openNotes={openNotes} kollegen={kollegen} onOpenNav={() => setNavOpen(true)} />
|
||||
</div>
|
||||
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
|
||||
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Bell } from "lucide-react";
|
||||
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 }: { notes: OpenNote[] }) {
|
||||
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 ausgeblendet = 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)) {
|
||||
@@ -54,7 +77,53 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
||||
|
||||
{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="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
|
||||
<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 eingeschränkt: sonst ist die Zahl bei jedem
|
||||
Aufschlagen zu lesen, ohne je etwas zu bedeuten. */}
|
||||
{ausgeblendet > 0 && <span className="text-warning-text">({ausgeblendet} aus)</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 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>
|
||||
) : (
|
||||
|
||||
@@ -25,10 +25,11 @@ function titleFor(pathname: string): string {
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
openNotes: OpenNote[];
|
||||
kollegen: { id: string; name: string; sichtbar: boolean }[];
|
||||
onOpenNav: () => void;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
||||
export function Topbar({ userLabel, openNotes, kollegen, onOpenNav }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -47,7 +48,7 @@ export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
||||
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
|
||||
<NotesBell notes={openNotes} />
|
||||
<NotesBell notes={openNotes} kollegen={kollegen} />
|
||||
<NewHireButton />
|
||||
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
|
||||
{/* The name is the first thing worth dropping on a narrow screen —
|
||||
|
||||
Reference in New Issue
Block a user