diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index d4e02b12..c6bb5d01 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/components/editable-cell.tsx b/artifacts/tx-os/src/components/editable-cell.tsx index fd06bce2..08062020 100644 --- a/artifacts/tx-os/src/components/editable-cell.tsx +++ b/artifacts/tx-os/src/components/editable-cell.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type CSSProperties, } from "react"; +import { createPortal } from "react-dom"; import { useEditor, EditorContent, type Editor, Extension } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Underline } from "@tiptap/extension-underline"; @@ -144,6 +146,7 @@ export function EditableCell({ }) { const [editing, setEditing] = useState(false); const wrapperRef = useRef(null); + const toolbarRef = useRef(null); const blurTimerRef = useRef | null>(null); const editor = useEditor( @@ -243,13 +246,42 @@ export function EditableCell({ const onFocusOut = (e: React.FocusEvent) => { if (!editing) return; const next = e.relatedTarget as Node | null; - if (next && wrapperRef.current?.contains(next)) return; + if ( + next && + (wrapperRef.current?.contains(next) || + toolbarRef.current?.contains(next)) + ) + return; if (blurTimerRef.current) clearTimeout(blurTimerRef.current); blurTimerRef.current = setTimeout(() => { saveEdit(); }, 100); }; + // When the toolbar is portalled outside wrapperRef, native pointerdown on + // the toolbar would still trigger our onFocusOut path on some browsers. + // We watch document-level pointerdown and ignore taps inside the toolbar. + useEffect(() => { + if (!editing) return; + const onPointerDownDoc = (e: PointerEvent) => { + const target = e.target as Node | null; + if (!target) return; + if ( + wrapperRef.current?.contains(target) || + toolbarRef.current?.contains(target) + ) { + if (blurTimerRef.current) { + clearTimeout(blurTimerRef.current); + blurTimerRef.current = null; + } + } + }; + document.addEventListener("pointerdown", onPointerDownDoc, true); + return () => { + document.removeEventListener("pointerdown", onPointerDownDoc, true); + }; + }, [editing]); + const onFocusIn = () => { if (blurTimerRef.current) { clearTimeout(blurTimerRef.current); @@ -312,7 +344,13 @@ export function EditableCell({ data-editing="true" style={fontStyle} > - +
; + toolbarRef: React.MutableRefObject; onSave: () => void; onCancel: () => void; }) { @@ -352,9 +394,64 @@ function FormattingToolbar({ ? "bg-[#0B1E3F] text-white" : "bg-white text-[#0B1E3F] hover:bg-gray-100 border border-gray-200"); - return ( + // Position the toolbar via a portal so it escapes table cells with + // overflow:hidden. We try to place it above the editor, but flip below + // when there isn't enough room (e.g. first row of the table). We also + // clamp horizontally to keep it inside the viewport. + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const [toolbarEl, setToolbarEl] = useState(null); + const setToolbarNode = useCallback( + (node: HTMLDivElement | null) => { + toolbarRef.current = node; + setToolbarEl(node); + }, + [toolbarRef], + ); + + useLayoutEffect(() => { + const update = () => { + const anchor = anchorRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + const tbHeight = toolbarEl?.offsetHeight ?? 36; + const tbWidth = toolbarEl?.offsetWidth ?? 320; + const placeAbove = rect.top > tbHeight + 8; + const top = placeAbove ? rect.top - tbHeight - 4 : rect.bottom + 4; + const vw = window.innerWidth; + let left = rect.left; + if (left + tbWidth > vw - 8) left = vw - tbWidth - 8; + if (left < 8) left = 8; + setPos({ top, left }); + }; + update(); + const raf = requestAnimationFrame(update); + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(update); + if (anchorRef.current) ro.observe(anchorRef.current); + if (toolbarEl) ro.observe(toolbarEl); + } + window.addEventListener("scroll", update, true); + window.addEventListener("resize", update); + return () => { + cancelAnimationFrame(raf); + ro?.disconnect(); + window.removeEventListener("scroll", update, true); + window.removeEventListener("resize", update); + }; + }, [anchorRef, toolbarEl]); + + const node = (
e.preventDefault()} data-testid="em-edit-toolbar" > @@ -492,5 +589,8 @@ function FormattingToolbar({
); + + if (typeof document === "undefined") return node; + return createPortal(node, document.body); } diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index c84a4437..6828357a 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -767,6 +767,13 @@ }, "dragRow": "اسحب لإعادة الترتيب", "editMeetingTitle": "تعديل عنوان الاجتماع", + "schedule": { + "addRow": "إضافة سطر", + "newMeetingDefaultAr": "اجتماع جديد", + "newMeetingDefaultEn": "New meeting", + "timeStart": "وقت البداية", + "timeEnd": "وقت النهاية" + }, "rowColor": { "label": "لون الصف" }, diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index a6fe326d..1ef0b637 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -719,6 +719,13 @@ "rowColor": { "label": "Row color" }, + "schedule": { + "addRow": "Add row", + "newMeetingDefaultAr": "اجتماع جديد", + "newMeetingDefaultEn": "New meeting", + "timeStart": "Start time", + "timeEnd": "End time" + }, "nav": { "schedule": "Daily Schedule", "manage": "Manage Meetings", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 1225789a..d00db9ef 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -296,7 +296,9 @@ const DEFAULT_COLUMNS: ColumnSetting[] = [ { id: "number", visible: true, width: 56 }, { id: "meeting", visible: true, width: 320 }, { id: "attendees", visible: true, width: 600 }, - { id: "time", visible: true, width: 120 }, + // Wide enough to fit "HH:MM – HH:MM" on one line, plus the two + // controls when the cell enters edit mode. + { id: "time", visible: true, width: 200 }, ]; const MIN_COL_WIDTH = 56; @@ -828,6 +830,57 @@ function ScheduleSection({ [refreshDay, toast, t], ); + const saveTimes = useCallback( + async ( + meeting: Meeting, + startTime: string | null, + endTime: string | null, + ) => { + try { + await apiJson(`/api/executive-meetings/${meeting.id}`, { + method: "PATCH", + body: { startTime, endTime }, + }); + refreshDay(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast({ + title: t("common.error"), + description: msg, + variant: "destructive", + }); + throw err; + } + }, + [refreshDay, toast, t], + ); + + const [creatingRow, setCreatingRow] = useState(false); + const createRow = useCallback(async () => { + if (creatingRow) return; + setCreatingRow(true); + try { + await apiJson(`/api/executive-meetings`, { + method: "POST", + body: { + titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"), + titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"), + meetingDate: date, + }, + }); + refreshDay(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast({ + title: t("common.error"), + description: msg, + variant: "destructive", + }); + } finally { + setCreatingRow(false); + } + }, [creatingRow, date, refreshDay, toast, t]); + const reorderRows = useCallback( async (fromId: number, toId: number) => { if (fromId === toId) return; @@ -1075,6 +1128,7 @@ function ScheduleSection({ onSaveAttendeeName={(idx, html) => saveAttendeeName(m, idx, html) } + onSaveTimes={(start, end) => saveTimes(m, start, end)} isCurrent={ highlightPrefs.enabled && currentMeetingId === m.id } @@ -1083,6 +1137,32 @@ function ScheduleSection({ /> ))} + {canMutate && !isLoading && ( + + + + + + )} @@ -1428,6 +1508,7 @@ function MeetingRow({ canMutate, onSaveTitle, onSaveAttendeeName, + onSaveTimes, isCurrent, highlightColor, reordering, @@ -1441,6 +1522,10 @@ function MeetingRow({ canMutate: boolean; onSaveTitle: (html: string) => Promise; onSaveAttendeeName: (idx: number, html: string) => Promise; + onSaveTimes: ( + startTime: string | null, + endTime: string | null, + ) => Promise; isCurrent: boolean; highlightColor: string; reordering: boolean; @@ -1556,11 +1641,12 @@ function MeetingRow({ className="border border-gray-300 px-3 py-3 align-middle text-center" style={cellStyle(col)} > -
-
{formatTime(meeting.startTime)}
-
-
{formatTime(meeting.endTime)}
-
+ ); } @@ -1591,6 +1677,169 @@ function MeetingRow({ ); } +function TimeRangeCell({ + meeting, + t, + canMutate, + onSaveTimes, +}: { + meeting: Meeting; + t: (k: string) => string; + canMutate: boolean; + onSaveTimes: ( + startTime: string | null, + endTime: string | null, + ) => Promise; +}) { + const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : ""; + const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : ""; + + const [editing, setEditing] = useState(false); + const [start, setStart] = useState(startSaved); + const [end, setEnd] = useState(endSaved); + const wrapperRef = useRef(null); + const blurTimerRef = useRef | null>(null); + const startInputRef = useRef(null); + + // If the saved times change while we're not editing (e.g. another tab + // updated them, or the user picked a different date), sync local state. + useEffect(() => { + if (!editing) { + setStart(startSaved); + setEnd(endSaved); + } + }, [editing, startSaved, endSaved]); + + useEffect(() => { + if (editing) { + startInputRef.current?.focus(); + } + }, [editing]); + + const enterEdit = useCallback(() => { + if (!canMutate) return; + setEditing(true); + }, [canMutate]); + + const cancel = useCallback(() => { + setStart(startSaved); + setEnd(endSaved); + setEditing(false); + }, [startSaved, endSaved]); + + const save = useCallback(async () => { + const newStart = start || null; + const newEnd = end || null; + setEditing(false); + if (newStart === (startSaved || null) && newEnd === (endSaved || null)) { + return; + } + if (newStart && newEnd && newStart > newEnd) { + // Server validates this too; revert locally and let user retry. + setStart(startSaved); + setEnd(endSaved); + return; + } + try { + await onSaveTimes(newStart, newEnd); + } catch { + setStart(startSaved); + setEnd(endSaved); + } + }, [start, end, startSaved, endSaved, onSaveTimes]); + + const onFocusOut = (e: React.FocusEvent) => { + if (!editing) return; + const next = e.relatedTarget as Node | null; + if (next && wrapperRef.current?.contains(next)) return; + if (blurTimerRef.current) clearTimeout(blurTimerRef.current); + blurTimerRef.current = setTimeout(() => { + void save(); + }, 100); + }; + + const onFocusIn = () => { + if (blurTimerRef.current) { + clearTimeout(blurTimerRef.current); + blurTimerRef.current = null; + } + }; + + if (!editing) { + const hasAny = !!(meeting.startTime || meeting.endTime); + const display = hasAny + ? `${formatTime(meeting.startTime)} – ${formatTime(meeting.endTime)}` + : canMutate + ? "—" + : "—"; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + enterEdit(); + } + }} + data-testid={`em-time-${meeting.id}`} + > + {display} +
+ ); + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } else if (e.key === "Enter") { + e.preventDefault(); + void save(); + } + }; + + return ( +
+ setStart(e.target.value)} + onKeyDown={onKeyDown} + aria-label={t("executiveMeetings.schedule.timeStart")} + className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" + data-testid={`em-time-start-${meeting.id}`} + /> + + setEnd(e.target.value)} + onKeyDown={onKeyDown} + aria-label={t("executiveMeetings.schedule.timeEnd")} + className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white" + data-testid={`em-time-end-${meeting.id}`} + /> +
+ ); +} + function AttendeesCell({ meeting, t, @@ -1717,7 +1966,9 @@ function AttendeeFlow({ value={a.name} onSave={(html) => onSaveAttendeeName!(i, html)} ariaLabel="attendee name" - className="inline-block align-baseline" + dir="auto" + placeholder="…" + className="inline-block align-baseline min-w-[3rem] px-0.5" testId={`em-edit-attendee-${i}`} /> ) : (