#591 meetings: reuse the real notes Composer (not a custom textarea)

The yellow side tab on /executive-meetings previously opened a bespoke
textarea panel I added in #590. The user pointed out (screenshot) that
it should be the exact same composer card they use on /notes — yellow
background, color picker, label menu, checklist toggle, "حفظ" Save,
paper-plane Send.

Changes:
- Extracted Composer + its inline helpers (ChecklistEditor, KindToggle,
  ColorPicker, LabelMenu, newItemId) out of pages/notes.tsx into a new
  shared module at components/notes/composer.tsx.
- Extracted SendNoteDialog out of pages/notes.tsx into a new shared
  module at components/notes/send-note-dialog.tsx.
- pages/notes.tsx now imports both modules; behavior of the notes page
  is unchanged.
- pages/executive-meetings.tsx: deleted InlineQuickNotePanel. The
  schedule view now renders <Composer folderSelection={{kind:"all"}}>
  inside the yellow side-tab toggle, plus <SendNoteDialog> for the
  paper-plane Send flow. A new quickNoteTrigger counter is bumped each
  time the tab opens so the shared Composer auto-expands and focuses
  its textarea. A small X in the corner dismisses the panel; Save just
  collapses the composer (matches /notes UX), Send saves then opens
  the recipient picker.
- Notes created from the meetings panel land as unfiled notes in the
  user's notes (folder "all"), visible immediately in /notes.

Notes:
- No locale-key cleanup needed: the old quickNote.placeholder/send
  keys lived only as t() fallbacks, not in en.json/ar.json.
- pnpm --filter @workspace/tx-os exec tsc --noEmit: clean.
- HMR errors visible in the browser console were from the intermediate
  syntax-error state during extraction; cleared after the final edit.
This commit is contained in:
Riyadh
2026-05-18 11:15:22 +00:00
parent 3a247608bb
commit d97f22764a
4 changed files with 789 additions and 789 deletions
@@ -0,0 +1,537 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ListTodo, Palette, Plus, Send, Tag, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import {
type ChecklistItem,
type NoteKind,
type NoteLabel,
NOTE_COLORS,
colorAccent,
colorBg,
useCreateLabel,
useCreateNote,
} from "@/lib/notes-api";
import { type FolderSelection } from "@/components/notes/folders-rail";
export function newItemId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
}
export function ChecklistEditor({
items,
onChange,
testIdPrefix,
}: {
items: ChecklistItem[];
onChange: (next: ChecklistItem[]) => void;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
const focusItemIdRef = useRef<string | null>(null);
useEffect(() => {
const id = focusItemIdRef.current;
if (!id) return;
const el = inputRefs.current.get(id);
if (el) {
el.focus();
focusItemIdRef.current = null;
}
}, [items]);
const setItem = (id: string, patch: Partial<ChecklistItem>) => {
onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it)));
};
const removeItem = (id: string) => {
onChange(items.filter((it) => it.id !== id));
};
const appendItem = () => {
const item: ChecklistItem = { id: newItemId(), text: "", done: false };
focusItemIdRef.current = item.id;
onChange([...items, item]);
};
return (
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
{items.length === 0 && (
<div className="text-xs text-muted-foreground italic px-1">
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
</div>
)}
{items.map((it) => (
<div
key={it.id}
className="flex items-center gap-2"
data-testid={`${testIdPrefix}-item-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
onChange={(e) => setItem(it.id, { done: e.target.checked })}
className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500"
aria-label={t("notes.checklist.itemPlaceholder", "List item")}
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<Input
ref={(el) => {
if (el) inputRefs.current.set(it.id, el);
else inputRefs.current.delete(it.id);
}}
value={it.text}
onChange={(e) => setItem(it.id, { text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
appendItem();
} else if (
e.key === "Backspace" &&
it.text.length === 0 &&
items.length > 1
) {
e.preventDefault();
removeItem(it.id);
}
}}
placeholder={t("notes.checklist.itemPlaceholder", "List item")}
className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${
it.done ? "line-through text-muted-foreground" : ""
}`}
data-testid={`${testIdPrefix}-text-${it.id}`}
/>
<button
type="button"
onClick={() => removeItem(it.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive"
aria-label={t("notes.checklist.removeItem", "Remove item")}
data-testid={`${testIdPrefix}-remove-${it.id}`}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={appendItem}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
data-testid={`${testIdPrefix}-add`}
>
<Plus size={12} />
{t("notes.checklist.addItem", "Add item")}
</button>
</div>
);
}
// Re-export colorAccent so callers (e.g. ChecklistView in notes.tsx)
// can access it via this module without bypassing it through notes-api.
export { colorAccent };
export function KindToggle({
kind,
onChange,
testId,
}: {
kind: NoteKind;
onChange: (k: NoteKind) => void;
testId?: string;
}) {
const { t } = useTranslation();
const next: NoteKind = kind === "text" ? "checklist" : "text";
return (
<button
type="button"
onClick={() => onChange(next)}
data-testid={testId}
aria-pressed={kind === "checklist"}
aria-label={t("notes.checklist.toggle", "To-do list")}
title={t("notes.checklist.toggle", "To-do list")}
className={`p-1 rounded-md hover:bg-black/5 ${
kind === "checklist"
? "text-amber-600 bg-amber-100/60"
: "text-muted-foreground"
}`}
>
<ListTodo size={15} />
</button>
);
}
export function ColorPicker({
value,
onChange,
}: {
value: string;
onChange: (c: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Color"
>
<Palette size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2">
<div className="grid grid-cols-5 gap-1.5">
{NOTE_COLORS.map((c) => (
<button
key={c.id}
onClick={() => onChange(c.id)}
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
}`}
aria-label={c.id}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function LabelMenu({
labels,
selected,
onChange,
}: {
labels: NoteLabel[];
selected: number[];
onChange: (ids: number[]) => void;
}) {
const { t } = useTranslation();
const create = useCreateLabel();
const [name, setName] = useState("");
const toggle = (id: number) => {
if (selected.includes(id)) onChange(selected.filter((x) => x !== id));
else onChange([...selected, id]);
};
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Labels"
>
<Tag size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2">
<div className="space-y-1 max-h-48 overflow-y-auto">
{labels.length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-1">
{t("notes.noLabelsYet", "No labels yet")}
</div>
)}
{labels.map((l) => (
<button
key={l.id}
onClick={() => toggle(l.id)}
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
>
<span className="flex items-center gap-2">
<Tag size={12} /> {l.name}
</span>
{selected.includes(l.id) && <Check size={14} />}
</button>
))}
</div>
<div className="mt-2 flex gap-1 border-t pt-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("notes.newLabel", "New label")}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) {
create.mutate(name.trim(), { onSuccess: () => setName("") });
}
}}
/>
<Button
size="sm"
variant="ghost"
disabled={!name.trim()}
onClick={() =>
create.mutate(name.trim(), { onSuccess: () => setName("") })
}
>
<Plus size={14} />
</Button>
</div>
</PopoverContent>
</Popover>
);
}
export function Composer({
labels,
folderSelection,
onSend,
hideSend = false,
openTrigger = 0,
}: {
labels: NoteLabel[];
folderSelection: FolderSelection;
onSend: (id: number) => void;
// shared-folder editors create notes that get stamped to
// the folder owner; the owner-only `/notes/:id/send` route would 403
// for them, so the composer hides its Send button in that context.
hideSend?: boolean;
// Numeric trigger bumped by the page when an external action (e.g.
// the meetings quick-note tab) wants the composer expanded and
// focused. Each new value opens + focuses; the count itself is
// irrelevant — only the change matters.
openTrigger?: number;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [color, setColor] = useState("yellow");
const [labelIds, setLabelIds] = useState<number[]>([]);
const [kind, setKind] = useState<NoteKind>("text");
const [items, setItems] = useState<ChecklistItem[]>([]);
const create = useCreateNote();
// Resolve the folder the new note should land in based on the active
// selection. "all" → null (unfiled / global), "unfiled" → null,
// "folder" → that folder id. Without this the composer always created
// notes in Unfiled, so a note made from inside a folder vanished from
// the current view.
const targetFolderId =
folderSelection.kind === "folder" ? folderSelection.id : null;
const ref = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLTextAreaElement | null>(null);
const contentFocused = useRef(false);
const { keyboardInset } = useVisualViewport();
// Same keyboard-aware scroll-into-view as the thread reply box: only
// fires while a software keyboard is actually open and the composer
// textarea is focused. Desktop focus is unaffected.
useEffect(() => {
if (keyboardInset <= 80) return;
if (!contentFocused.current) return;
contentRef.current?.scrollIntoView({ block: "center" });
}, [keyboardInset]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (ref.current?.contains(target)) return;
// Ignore clicks inside Radix popover/portal content (the color and
// label menus render in a portal outside the composer's DOM tree).
// Without this guard, picking a swatch would trigger save() on the
// mousedown — before the swatch's onClick updated `color` — so the
// new color was never persisted.
const el = target instanceof Element ? target : null;
if (
el?.closest(
"[data-radix-popper-content-wrapper],[data-radix-portal],[role='dialog']",
)
) {
return;
}
save();
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, content, color, labelIds, kind, items]);
const reset = () => {
setContent("");
setColor("yellow");
setLabelIds([]);
setKind("text");
setItems([]);
setOpen(false);
};
// External open trigger. React both to subsequent bumps AND to the
// initial mount when the trigger is already > 0 — this covers the
// case where the page resets folder selection and the Composer
// mounts fresh with a non-zero trigger.
const lastOpenTrigger = useRef(0);
useEffect(() => {
if (openTrigger === 0) return;
if (openTrigger === lastOpenTrigger.current) return;
lastOpenTrigger.current = openTrigger;
setOpen(true);
setKind("text");
requestAnimationFrame(() => {
const el = contentRef.current;
if (!el) return;
el.focus();
el.scrollIntoView({ block: "center", behavior: "smooth" });
});
}, [openTrigger]);
// Build the create-note payload from current composer state and a
// boolean indicating whether the composer is empty (no body /
// checklist items). Shared by save() and sendNew() so the
// "save before send" path uses the exact same data the bare Save
// button would have submitted.
const buildPayload = () => {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
const isEmpty =
kind === "checklist" ? cleanItems.length === 0 : !content.trim();
return {
isEmpty,
payload: {
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
folderId: targetFolderId,
},
};
};
const save = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty) {
reset();
return;
}
create.mutate(payload, { onSuccess: reset });
};
// Send button inside the composer. The plan agreed with the user is
// "save first, then open the send dialog for the new note". On
// success we reset the composer (mirroring save()) and hand the
// freshly created note's id up to the page so it can mount
// <SendNoteDialog>. On failure we leave the composer state intact
// so the user can retry — same as the existing save error behavior.
const sendNew = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty || create.isPending) return;
create.mutate(payload, {
onSuccess: (created) => {
reset();
onSend(created.id);
},
});
};
const sendDisabled =
create.isPending ||
(kind === "checklist"
? items.every((it) => !it.text.trim())
: !content.trim());
return (
<div
ref={ref}
data-testid="notes-composer"
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
color,
)}`}
>
{open ? (
<>
{kind === "checklist" ? (
<div className="px-1">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-composer-checklist"
/>
</div>
) : (
<Textarea
autoFocus
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={(e) => {
contentFocused.current = true;
if (keyboardInset > 80) {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onBlur={() => {
contentFocused.current = false;
}}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[80px] resize-none"
data-testid="notes-composer-content"
/>
)}
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-composer-kind-toggle"
/>
<LabelMenu
labels={labels}
selected={labelIds}
onChange={setLabelIds}
/>
<div className="ms-auto flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
{t("notes.save", "Save")}
</Button>
{/* Send action inside the composer. Saves first
(auto-save on send was explicitly OK'd by the user),
then opens <SendNoteDialog> for the new note. Disabled
while empty or while the create request is in flight
to prevent duplicate notes from rapid clicks. */}
{!hideSend && (
<Button
size="sm"
variant="ghost"
onClick={sendNew}
disabled={sendDisabled}
aria-label={t("notes.send", "Send")}
data-testid="notes-composer-send"
className="px-2"
>
<Send size={16} />
</Button>
)}
</div>
</div>
</>
) : (
<button
onClick={() => setOpen(true)}
className="w-full flex items-center gap-2 text-start text-muted-foreground py-1 px-1"
data-testid="notes-composer-open"
>
<Plus
size={18}
aria-hidden="true"
className="shrink-0 text-muted-foreground"
/>
<span className="truncate">
{t("notes.takeNote", "Take a note...")}
</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,195 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Search, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import {
useSendNote,
useUserDirectory,
userDisplayName,
} from "@/lib/notes-api";
export function SendNoteDialog({
noteId,
onClose,
}: {
noteId: number;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { user } = useAuth();
const { data: directory = [], isLoading } = useUserDirectory();
const send = useSendNote();
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<number>>(new Set());
const candidates = useMemo(() => {
const me = user?.id;
return directory.filter((u) => u.id !== me);
}, [directory, user]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return candidates;
return candidates.filter((u) =>
[u.username, u.displayNameAr, u.displayNameEn]
.filter(Boolean)
.some((s) => (s as string).toLowerCase().includes(q)),
);
}, [candidates, search]);
const toggle = (id: number) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const [confirmOpen, setConfirmOpen] = useState(false);
const performSend = () => {
send.mutate(
{ id: noteId, recipientUserIds: Array.from(picked) },
{
onSuccess: () => {
toast({ title: t("notes.sendSuccess", "Note sent") });
setConfirmOpen(false);
onClose();
},
onError: () => {
setConfirmOpen(false);
toast({
title: t("notes.sendFailed", "Could not send note"),
variant: "destructive",
});
},
},
);
};
const requestSubmit = () => {
if (picked.size === 0 || send.isPending) return;
if (picked.size === 1) {
performSend();
return;
}
setConfirmOpen(true);
};
const confirmSubmit = () => {
performSend();
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="send-note-dialog">
<DialogHeader className="pr-8">
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
</DialogHeader>
<div className="relative">
<Search
size={14}
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
className="ps-9"
data-testid="send-note-search"
/>
</div>
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
{isLoading && (
<div className="p-3 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="p-3 text-sm text-muted-foreground text-center">
</div>
)}
{filtered.map((u) => {
const checked = picked.has(u.id);
return (
<button
key={u.id}
onClick={() => toggle(u.id)}
data-testid={`send-recipient-option-${u.id}`}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
checked ? "bg-primary/5" : ""
}`}
>
<span className="truncate">{userDisplayName(u, lang)}</span>
{checked && <Check size={14} className="text-primary" />}
</button>
);
})}
</div>
{picked.size > 0 && (
<div className="text-xs text-muted-foreground">
{t("notes.recipientsCount", { count: picked.size })}
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={requestSubmit}
disabled={picked.size === 0 || send.isPending}
data-testid="send-note-submit"
>
<Send size={14} className="me-1" />
{t("notes.send", "Send")}
</Button>
</div>
</DialogContent>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent data-testid="send-note-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.confirmSendTitle", "Send this note?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("notes.confirmSendBody", { count: picked.size })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmSubmit}
disabled={send.isPending}
data-testid="send-note-confirm-submit"
>
{t("notes.confirmSendCta", "Send")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
+49 -111
View File
@@ -113,6 +113,9 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
import { Composer } from "@/components/notes/composer";
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
import { useNoteLabels } from "@/lib/notes-api";
// #486: reuse the same dialog the upcoming-alert pops, so postponing
// from the schedule row quick-actions menu shares one implementation.
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
@@ -834,113 +837,6 @@ function MeetingsQuickNoteTab({
);
}
function InlineQuickNotePanel({
lang,
onClose,
}: {
lang: "ar" | "en";
onClose: () => void;
}) {
const { t } = useTranslation();
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const submit = async () => {
const trimmed = content.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/notes", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: trimmed, color: "yellow" }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `HTTP ${res.status}`);
}
setContent("");
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const placeholder = t(
"executiveMeetings.quickNote.placeholder",
lang === "ar" ? "اكتب ملاحظة سريعة…" : "Write a quick note…",
);
const sendLabel = t("executiveMeetings.quickNote.send", lang === "ar" ? "إرسال" : "Send");
const cancelLabel = t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel");
return (
<div
data-testid="em-quick-note-panel"
className="mb-3 sm:mb-4 rounded-lg border border-amber-300 bg-amber-50 shadow-sm print:hidden"
>
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-amber-200">
<div className="text-sm font-semibold text-[#0B1E3F]">
{t("executiveMeetings.quickNote.label", lang === "ar" ? "اكتب ملاحظة…" : "Take a note…")}
</div>
<button
type="button"
onClick={onClose}
aria-label={cancelLabel}
data-testid="em-quick-note-close"
className="rounded-md p-1 text-[#0B1E3F] hover:bg-amber-200/70"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-3 space-y-2">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
placeholder={placeholder}
rows={3}
className="w-full resize-y rounded-md border border-amber-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300"
data-testid="em-quick-note-textarea"
/>
{error && (
<div className="text-xs text-red-600" data-testid="em-quick-note-error">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose} type="button">
{cancelLabel}
</Button>
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={busy || content.trim().length === 0}
data-testid="em-quick-note-send"
className="bg-[#0B1E3F] text-white hover:bg-[#0B1E3F]/90"
>
{sendLabel}
</Button>
</div>
</div>
</div>
);
}
function ExecutiveMeetingsPageInner() {
const { t, i18n } = useTranslation();
@@ -955,6 +851,15 @@ function ExecutiveMeetingsPageInner() {
// transient local state (not sessionStorage) — it's a quick capture,
// not a view preference. Auto-closes when leaving the schedule tab.
const [quickNoteOpen, setQuickNoteOpen] = useState<boolean>(false);
// Bumped each time the yellow side tab opens the panel, so the
// shared <Composer> expands and focuses its textarea on open (it
// reacts to changes in this numeric trigger).
const [quickNoteTrigger, setQuickNoteTrigger] = useState<number>(0);
// When the user hits the paper-plane Send inside the composer we
// save the note first, then mount <SendNoteDialog> for that id —
// same flow as on /notes.
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
const { data: quickNoteLabels = [] } = useNoteLabels();
useEffect(() => {
if (section !== "schedule") setQuickNoteOpen(false);
}, [section]);
@@ -1117,7 +1022,10 @@ function ExecutiveMeetingsPageInner() {
{section === "schedule" && !isFullscreen && !bulkToolbarActive && !quickNoteOpen && (
<MeetingsQuickNoteTab
lang={lang}
onClick={() => setQuickNoteOpen(true)}
onClick={() => {
setQuickNoteOpen(true);
setQuickNoteTrigger((n) => n + 1);
}}
/>
)}
{isFullscreen && (
@@ -1229,9 +1137,39 @@ function ExecutiveMeetingsPageInner() {
}
>
{section === "schedule" && quickNoteOpen && (
<InlineQuickNotePanel
lang={lang}
onClose={() => setQuickNoteOpen(false)}
// Reuse the real notes <Composer> instead of a custom textarea
// so the meetings quick-note matches /notes exactly (yellow
// card, color picker, label menu, checklist toggle, Save +
// paper-plane Send). folderSelection "all" → unfiled, so the
// saved note shows up at the top of the Notes app.
<div
data-testid="em-quick-note-panel"
className="relative mb-3 sm:mb-4 print:hidden"
>
<button
type="button"
onClick={() => setQuickNoteOpen(false)}
aria-label={t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel")}
data-testid="em-quick-note-close"
className="absolute z-10 top-1 end-1 rounded-full p-1 text-[#0B1E3F]/70 hover:bg-black/5 hover:text-[#0B1E3F]"
>
<X className="w-4 h-4" />
</button>
<Composer
labels={quickNoteLabels}
folderSelection={{ kind: "all" }}
onSend={(id) => {
setSendForNoteId(id);
setQuickNoteOpen(false);
}}
openTrigger={quickNoteTrigger}
/>
</div>
)}
{sendForNoteId !== null && (
<SendNoteDialog
noteId={sendForNoteId}
onClose={() => setSendForNoteId(null)}
/>
)}
{section === "schedule" && (
+8 -678
View File
@@ -117,6 +117,14 @@ import {
FoldersRail,
type FolderSelection,
} from "@/components/notes/folders-rail";
import {
ChecklistEditor,
ColorPicker,
Composer,
KindToggle,
LabelMenu,
} from "@/components/notes/composer";
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
import {
DndContext,
MouseSensor,
@@ -2508,172 +2516,6 @@ function StatusDot({ status }: { status: string }) {
);
}
function SendNoteDialog({
noteId,
onClose,
}: {
noteId: number;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { user } = useAuth();
const { data: directory = [], isLoading } = useUserDirectory();
const send = useSendNote();
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<number>>(new Set());
const candidates = useMemo(() => {
const me = user?.id;
return directory.filter((u) => u.id !== me);
}, [directory, user]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return candidates;
return candidates.filter((u) =>
[u.username, u.displayNameAr, u.displayNameEn]
.filter(Boolean)
.some((s) => (s as string).toLowerCase().includes(q)),
);
}, [candidates, search]);
const toggle = (id: number) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const [confirmOpen, setConfirmOpen] = useState(false);
const performSend = () => {
send.mutate(
{ id: noteId, recipientUserIds: Array.from(picked) },
{
onSuccess: () => {
toast({ title: t("notes.sendSuccess", "Note sent") });
setConfirmOpen(false);
onClose();
},
onError: () => {
setConfirmOpen(false);
toast({
title: t("notes.sendFailed", "Could not send note"),
variant: "destructive",
});
},
},
);
};
const requestSubmit = () => {
if (picked.size === 0 || send.isPending) return;
if (picked.size === 1) {
performSend();
return;
}
setConfirmOpen(true);
};
const confirmSubmit = () => {
performSend();
};
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md" data-testid="send-note-dialog">
<DialogHeader className="pr-8">
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
</DialogHeader>
<div className="relative">
<Search
size={14}
className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
className="ps-9"
data-testid="send-note-search"
/>
</div>
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
{isLoading && (
<div className="p-3 text-sm text-muted-foreground">
{t("common.loading", "Loading...")}
</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="p-3 text-sm text-muted-foreground text-center">
</div>
)}
{filtered.map((u) => {
const checked = picked.has(u.id);
return (
<button
key={u.id}
onClick={() => toggle(u.id)}
data-testid={`send-recipient-option-${u.id}`}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-slate-100 ${
checked ? "bg-primary/5" : ""
}`}
>
<span className="truncate">{userDisplayName(u, lang)}</span>
{checked && <Check size={14} className="text-primary" />}
</button>
);
})}
</div>
{picked.size > 0 && (
<div className="text-xs text-muted-foreground">
{t("notes.recipientsCount", { count: picked.size })}
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={requestSubmit}
disabled={picked.size === 0 || send.isPending}
data-testid="send-note-submit"
>
<Send size={14} className="me-1" />
{t("notes.send", "Send")}
</Button>
</div>
</DialogContent>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent data-testid="send-note-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.confirmSendTitle", "Send this note?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("notes.confirmSendBody", { count: picked.size })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmSubmit}
disabled={send.isPending}
data-testid="send-note-confirm-submit"
>
{t("notes.confirmSendCta", "Send")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
function ArchivedView({
loadingMine,
mine,
@@ -3169,120 +3011,6 @@ function GroupedReplies({
);
}
function newItemId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `it_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
}
function ChecklistEditor({
items,
onChange,
testIdPrefix,
}: {
items: ChecklistItem[];
onChange: (next: ChecklistItem[]) => void;
testIdPrefix: string;
}) {
const { t } = useTranslation();
const inputRefs = useRef<Map<string, HTMLInputElement | null>>(new Map());
const focusItemIdRef = useRef<string | null>(null);
// After append (Enter or Add), shift focus to the freshly inserted row.
useEffect(() => {
const id = focusItemIdRef.current;
if (!id) return;
const el = inputRefs.current.get(id);
if (el) {
el.focus();
focusItemIdRef.current = null;
}
}, [items]);
const setItem = (id: string, patch: Partial<ChecklistItem>) => {
onChange(items.map((it) => (it.id === id ? { ...it, ...patch } : it)));
};
const removeItem = (id: string) => {
onChange(items.filter((it) => it.id !== id));
};
const appendItem = () => {
const item: ChecklistItem = { id: newItemId(), text: "", done: false };
focusItemIdRef.current = item.id;
onChange([...items, item]);
};
return (
<div className="space-y-1.5" data-testid={`${testIdPrefix}-list`}>
{items.length === 0 && (
<div className="text-xs text-muted-foreground italic px-1">
{t("notes.checklist.emptyHint", "No items yet — add one below.")}
</div>
)}
{items.map((it) => (
<div
key={it.id}
className="flex items-center gap-2"
data-testid={`${testIdPrefix}-item-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
onChange={(e) => setItem(it.id, { done: e.target.checked })}
className="h-4 w-4 rounded border-black/30 cursor-pointer accent-amber-500"
aria-label={t("notes.checklist.itemPlaceholder", "List item")}
data-testid={`${testIdPrefix}-check-${it.id}`}
/>
<Input
ref={(el) => {
if (el) inputRefs.current.set(it.id, el);
else inputRefs.current.delete(it.id);
}}
value={it.text}
onChange={(e) => setItem(it.id, { text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
appendItem();
} else if (
e.key === "Backspace" &&
it.text.length === 0 &&
items.length > 1
) {
e.preventDefault();
removeItem(it.id);
}
}}
placeholder={t("notes.checklist.itemPlaceholder", "List item")}
className={`h-8 border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 ${
it.done ? "line-through text-muted-foreground" : ""
}`}
data-testid={`${testIdPrefix}-text-${it.id}`}
/>
<button
type="button"
onClick={() => removeItem(it.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive"
aria-label={t("notes.checklist.removeItem", "Remove item")}
data-testid={`${testIdPrefix}-remove-${it.id}`}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={appendItem}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground px-1 py-1"
data-testid={`${testIdPrefix}-add`}
>
<Plus size={12} />
{t("notes.checklist.addItem", "Add item")}
</button>
</div>
);
}
function ChecklistView({
items,
onToggle,
@@ -3338,404 +3066,6 @@ function ChecklistView({
);
}
function KindToggle({
kind,
onChange,
testId,
}: {
kind: NoteKind;
onChange: (k: NoteKind) => void;
testId?: string;
}) {
const { t } = useTranslation();
const next: NoteKind = kind === "text" ? "checklist" : "text";
return (
<button
type="button"
onClick={() => onChange(next)}
data-testid={testId}
aria-pressed={kind === "checklist"}
aria-label={t("notes.checklist.toggle", "To-do list")}
title={t("notes.checklist.toggle", "To-do list")}
className={`p-1 rounded-md hover:bg-black/5 ${
kind === "checklist"
? "text-amber-600 bg-amber-100/60"
: "text-muted-foreground"
}`}
>
<ListTodo size={15} />
</button>
);
}
function ColorPicker({
value,
onChange,
}: {
value: string;
onChange: (c: string) => void;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Color"
>
<Palette size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2">
<div className="grid grid-cols-5 gap-1.5">
{NOTE_COLORS.map((c) => (
<button
key={c.id}
onClick={() => onChange(c.id)}
className={`w-7 h-7 rounded-full border border-black/10 ${c.bg} ${
value === c.id ? "ring-2 ring-offset-1 " + c.ring : ""
}`}
aria-label={c.id}
/>
))}
</div>
</PopoverContent>
</Popover>
);
}
function LabelMenu({
labels,
selected,
onChange,
}: {
labels: NoteLabel[];
selected: number[];
onChange: (ids: number[]) => void;
}) {
const { t } = useTranslation();
const create = useCreateLabel();
const [name, setName] = useState("");
const toggle = (id: number) => {
if (selected.includes(id)) onChange(selected.filter((x) => x !== id));
else onChange([...selected, id]);
};
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
aria-label="Labels"
>
<Tag size={15} />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2">
<div className="space-y-1 max-h-48 overflow-y-auto">
{labels.length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-1">
{t("notes.noLabelsYet", "No labels yet")}
</div>
)}
{labels.map((l) => (
<button
key={l.id}
onClick={() => toggle(l.id)}
className="w-full flex items-center justify-between px-2 py-1 text-sm rounded hover:bg-slate-100"
>
<span className="flex items-center gap-2">
<Tag size={12} /> {l.name}
</span>
{selected.includes(l.id) && <Check size={14} />}
</button>
))}
</div>
<div className="mt-2 flex gap-1 border-t pt-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("notes.newLabel", "New label")}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim()) {
create.mutate(name.trim(), { onSuccess: () => setName("") });
}
}}
/>
<Button
size="sm"
variant="ghost"
disabled={!name.trim()}
onClick={() =>
create.mutate(name.trim(), { onSuccess: () => setName("") })
}
>
<Plus size={14} />
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function Composer({
labels,
folderSelection,
onSend,
hideSend = false,
openTrigger = 0,
}: {
labels: NoteLabel[];
folderSelection: FolderSelection;
onSend: (id: number) => void;
// shared-folder editors create notes that get stamped to
// the folder owner; the owner-only `/notes/:id/send` route would 403
// for them, so the composer hides its Send button in that context.
hideSend?: boolean;
// Numeric trigger bumped by the page when an external action (e.g.
// the meetings quick-note tab arriving via /notes?new=1) wants the
// composer expanded and focused. Each new value opens + focuses; the
// count itself is irrelevant — only the change matters.
openTrigger?: number;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [color, setColor] = useState("yellow");
const [labelIds, setLabelIds] = useState<number[]>([]);
const [kind, setKind] = useState<NoteKind>("text");
const [items, setItems] = useState<ChecklistItem[]>([]);
const create = useCreateNote();
// Resolve the folder the new note should land in based on the active
// selection. "all" → null (unfiled / global), "unfiled" → null,
// "folder" → that folder id. Without this the composer always created
// notes in Unfiled, so a note made from inside a folder vanished from
// the current view.
const targetFolderId =
folderSelection.kind === "folder" ? folderSelection.id : null;
const ref = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLTextAreaElement | null>(null);
const contentFocused = useRef(false);
const { keyboardInset } = useVisualViewport();
// Same keyboard-aware scroll-into-view as the thread reply box: only
// fires while a software keyboard is actually open and the composer
// textarea is focused. Desktop focus is unaffected.
useEffect(() => {
if (keyboardInset <= 80) return;
if (!contentFocused.current) return;
contentRef.current?.scrollIntoView({ block: "center" });
}, [keyboardInset]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (ref.current?.contains(target)) return;
// Ignore clicks inside Radix popover/portal content (the color and
// label menus render in a portal outside the composer's DOM tree).
// Without this guard, picking a swatch would trigger save() on the
// mousedown — before the swatch's onClick updated `color` — so the
// new color was never persisted.
const el = target instanceof Element ? target : null;
if (
el?.closest(
"[data-radix-popper-content-wrapper],[data-radix-portal],[role='dialog']",
)
) {
return;
}
save();
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, content, color, labelIds, kind, items]);
const reset = () => {
setContent("");
setColor("yellow");
setLabelIds([]);
setKind("text");
setItems([]);
setOpen(false);
};
// External open trigger (e.g. /notes?new=1 from the meetings quick
// note tab). React both to subsequent bumps AND to the initial mount
// when the trigger is already > 0 — this covers the case where the
// page resets folder selection and the Composer mounts fresh with a
// non-zero trigger (e.g. user was in a shared folder and clicked the
// quick-note tab).
const lastOpenTrigger = useRef(0);
useEffect(() => {
if (openTrigger === 0) return;
if (openTrigger === lastOpenTrigger.current) return;
lastOpenTrigger.current = openTrigger;
setOpen(true);
setKind("text");
requestAnimationFrame(() => {
const el = contentRef.current;
if (!el) return;
el.focus();
el.scrollIntoView({ block: "center", behavior: "smooth" });
});
}, [openTrigger]);
// Build the create-note payload from current composer state and a
// boolean indicating whether the composer is empty (no body / checklist
// items). Shared by save() and sendNew() so the "save before send"
// path uses the exact same data the bare Save button would have
// submitted.
const buildPayload = () => {
const cleanItems =
kind === "checklist"
? items
.map((it) => ({ ...it, text: it.text.trim() }))
.filter((it) => it.text.length > 0)
: [];
const isEmpty =
kind === "checklist" ? cleanItems.length === 0 : !content.trim();
return {
isEmpty,
payload: {
content: kind === "checklist" ? "" : content.trim(),
color,
labelIds,
kind,
items: kind === "checklist" ? cleanItems : null,
folderId: targetFolderId,
},
};
};
const save = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty) {
reset();
return;
}
create.mutate(payload, { onSuccess: reset });
};
// Send button inside the composer. The plan agreed with
// the user is "save first, then open the send dialog for the new
// note". On success we reset the composer (mirroring save()) and
// hand the freshly created note's id up to the page so it can mount
// <SendNoteDialog>. On failure we leave the composer state intact
// so the user can retry — same as the existing save error behavior.
const sendNew = () => {
const { isEmpty, payload } = buildPayload();
if (isEmpty || create.isPending) return;
create.mutate(payload, {
onSuccess: (created) => {
reset();
onSend(created.id);
},
});
};
const sendDisabled =
create.isPending ||
(kind === "checklist"
? items.every((it) => !it.text.trim())
: !content.trim());
return (
<div
ref={ref}
data-testid="notes-composer"
className={`max-w-xl mx-auto rounded-xl border border-black/10 shadow-sm p-3 transition ${colorBg(
color,
)}`}
>
{open ? (
<>
{kind === "checklist" ? (
<div className="px-1">
<ChecklistEditor
items={items}
onChange={setItems}
testIdPrefix="notes-composer-checklist"
/>
</div>
) : (
<Textarea
autoFocus
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={(e) => {
contentFocused.current = true;
if (keyboardInset > 80) {
e.currentTarget.scrollIntoView({ block: "center" });
}
}}
onBlur={() => {
contentFocused.current = false;
}}
placeholder={t("notes.contentPlaceholder", "Take a note...")}
className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[80px] resize-none"
data-testid="notes-composer-content"
/>
)}
<div className="flex items-center gap-1 mt-2">
<ColorPicker value={color} onChange={setColor} />
<KindToggle
kind={kind}
onChange={setKind}
testId="notes-composer-kind-toggle"
/>
<LabelMenu
labels={labels}
selected={labelIds}
onChange={setLabelIds}
/>
<div className="ms-auto flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={save} data-testid="notes-composer-save">
{t("notes.save", "Save")}
</Button>
{/* Send action inside the composer. Saves first
(auto-save on send was explicitly OK'd by the user),
then opens <SendNoteDialog> for the new note. Disabled
while empty or while the create request is in flight to
prevent duplicate notes from rapid clicks. */}
{!hideSend && (
<Button
size="sm"
variant="ghost"
onClick={sendNew}
disabled={sendDisabled}
aria-label={t("notes.send", "Send")}
data-testid="notes-composer-send"
className="px-2"
>
<Send size={16} />
</Button>
)}
</div>
</div>
</>
) : (
<button
onClick={() => setOpen(true)}
className="w-full flex items-center gap-2 text-start text-muted-foreground py-1 px-1"
data-testid="notes-composer-open"
>
<Plus
size={18}
aria-hidden="true"
className="shrink-0 text-muted-foreground"
/>
<span className="truncate">
{t("notes.takeNote", "Take a note...")}
</span>
</button>
)}
</div>
);
}
function EditNoteDialog({
note,
labels,