feat: personal notes on bill detail pages

- bill_notes table (migration 0014): user_id, bill_id, content, pinned,
  created_at, updated_at; unique constraint (user_id, bill_id)
- BillNote SQLAlchemy model with back-refs on User and Bill
- GET/PUT/DELETE /api/notes/{bill_id} — auth-required, one note per (user, bill)
- NotesPanel component: collapsible, auto-resize textarea, pin toggle,
  save + delete; shows last-saved date and pin indicator in collapsed header
- Pinned notes render above BriefPanel; unpinned render below DraftLetterPanel
- Guests see nothing (token guard in component + query disabled)

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 22:14:52 -05:00
parent 128c8e9257
commit 62a217cb22
13 changed files with 393 additions and 30 deletions

View File

@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { StickyNote, Pin, PinOff, Trash2, ChevronDown, ChevronUp, Save } from "lucide-react";
import { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
interface NotesPanelProps {
billId: string;
}
export function NotesPanel({ billId }: NotesPanelProps) {
const token = useAuthStore((s) => s.token);
const qc = useQueryClient();
const queryKey = ["note", billId];
const { data: note, isLoading } = useQuery({
queryKey,
queryFn: () => notesAPI.get(billId),
enabled: !!token,
retry: false, // 404 = no note; don't retry
throwOnError: false,
});
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Sync form from loaded note
useEffect(() => {
if (note) {
setContent(note.content);
setPinned(note.pinned);
}
}, [note]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [content, open]);
const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned),
onSuccess: (updated) => {
qc.setQueryData(queryKey, updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const remove = useMutation({
mutationFn: () => notesAPI.delete(billId),
onSuccess: () => {
qc.removeQueries({ queryKey });
setContent("");
setPinned(false);
setOpen(false);
},
});
// Don't render for guests
if (!token) return null;
if (isLoading) return null;
const hasNote = !!note;
const isDirty = hasNote
? content !== note.content || pinned !== note.pinned
: content.trim().length > 0;
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header / toggle */}
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-accent transition-colors"
>
<span className="flex items-center gap-2 font-medium">
<StickyNote className="w-4 h-4 text-muted-foreground" />
My Note
{hasNote && (
<span className="flex items-center gap-1 text-xs text-muted-foreground font-normal">
{note.pinned && <Pin className="w-3 h-3" />}
{new Date(note.updated_at).toLocaleDateString()}
</span>
)}
</span>
{open ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 pb-4 space-y-3 border-t border-border pt-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin to top of page"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<button
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div>
)}
</div>
);
}