Git commit prior to merge
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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<HTMLDivElement | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
@@ -243,13 +246,42 @@ export function EditableCell({
|
||||
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
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}
|
||||
>
|
||||
<FormattingToolbar editor={editor} onSave={saveEdit} onCancel={cancelEdit} />
|
||||
<FormattingToolbar
|
||||
editor={editor}
|
||||
anchorRef={wrapperRef}
|
||||
toolbarRef={toolbarRef}
|
||||
onSave={saveEdit}
|
||||
onCancel={cancelEdit}
|
||||
/>
|
||||
<div
|
||||
className="border border-blue-400 rounded bg-white px-1.5 py-1 shadow-sm focus-within:border-blue-600"
|
||||
style={fontStyle}
|
||||
@@ -325,10 +363,14 @@ export function EditableCell({
|
||||
|
||||
function FormattingToolbar({
|
||||
editor,
|
||||
anchorRef,
|
||||
toolbarRef,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
editor: Editor;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
toolbarRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
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<HTMLDivElement | null>(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 = (
|
||||
<div
|
||||
className="absolute -top-9 inline-start-0 z-20 flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
|
||||
ref={setToolbarNode}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pos?.top ?? -9999,
|
||||
left: pos?.left ?? -9999,
|
||||
zIndex: 1000,
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
data-testid="em-edit-toolbar"
|
||||
>
|
||||
@@ -492,5 +589,8 @@ function FormattingToolbar({
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === "undefined") return node;
|
||||
return createPortal(node, document.body);
|
||||
}
|
||||
|
||||
|
||||
@@ -767,6 +767,13 @@
|
||||
},
|
||||
"dragRow": "اسحب لإعادة الترتيب",
|
||||
"editMeetingTitle": "تعديل عنوان الاجتماع",
|
||||
"schedule": {
|
||||
"addRow": "إضافة سطر",
|
||||
"newMeetingDefaultAr": "اجتماع جديد",
|
||||
"newMeetingDefaultEn": "New meeting",
|
||||
"timeStart": "وقت البداية",
|
||||
"timeEnd": "وقت النهاية"
|
||||
},
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// <input type="time"> 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({
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{canMutate && !isLoading && (
|
||||
<tr
|
||||
className="print:hidden"
|
||||
data-testid="em-add-row-tr"
|
||||
>
|
||||
<td
|
||||
colSpan={visibleColumns.length}
|
||||
className="border border-gray-300 p-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createRow}
|
||||
disabled={creatingRow}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-[#0B1E3F] hover:bg-blue-50/60 focus:bg-blue-50/60 focus:outline-none transition-colors disabled:opacity-60"
|
||||
data-testid="em-add-row-button"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>
|
||||
{creatingRow
|
||||
? t("common.loading")
|
||||
: t("executiveMeetings.schedule.addRow")}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</DndContext>
|
||||
</table>
|
||||
@@ -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<void>;
|
||||
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
|
||||
onSaveTimes: (
|
||||
startTime: string | null,
|
||||
endTime: string | null,
|
||||
) => Promise<void>;
|
||||
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)}
|
||||
>
|
||||
<div className="font-mono text-[#0B1E3F] leading-tight whitespace-nowrap">
|
||||
<div>{formatTime(meeting.startTime)}</div>
|
||||
<div className="text-muted-foreground text-xs">—</div>
|
||||
<div>{formatTime(meeting.endTime)}</div>
|
||||
</div>
|
||||
<TimeRangeCell
|
||||
meeting={meeting}
|
||||
t={t}
|
||||
canMutate={canMutate}
|
||||
onSaveTimes={onSaveTimes}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
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<HTMLDivElement | null>(null);
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startInputRef = useRef<HTMLInputElement | null>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
role={canMutate ? "button" : undefined}
|
||||
tabIndex={canMutate ? 0 : -1}
|
||||
aria-label={t("executiveMeetings.col.time")}
|
||||
className={
|
||||
"font-mono text-[#0B1E3F] whitespace-nowrap leading-tight " +
|
||||
(canMutate
|
||||
? "cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors px-1"
|
||||
: "")
|
||||
}
|
||||
onClick={enterEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
enterEdit();
|
||||
}
|
||||
}}
|
||||
data-testid={`em-time-${meeting.id}`}
|
||||
>
|
||||
{display}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
onFocus={onFocusIn}
|
||||
onBlur={onFocusOut}
|
||||
className="flex items-center justify-center gap-1 font-mono whitespace-nowrap"
|
||||
data-testid={`em-time-${meeting.id}`}
|
||||
data-editing="true"
|
||||
>
|
||||
<input
|
||||
ref={startInputRef}
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => 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}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}`}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user