Git commit prior to merge

This commit is contained in:
riyadhafraa
2026-04-29 10:57:44 +00:00
parent 768d6eab4c
commit 0d58a856d6
5 changed files with 376 additions and 11 deletions
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);
}
+7
View File
@@ -767,6 +767,13 @@
},
"dragRow": "اسحب لإعادة الترتيب",
"editMeetingTitle": "تعديل عنوان الاجتماع",
"schedule": {
"addRow": "إضافة سطر",
"newMeetingDefaultAr": "اجتماع جديد",
"newMeetingDefaultEn": "New meeting",
"timeStart": "وقت البداية",
"timeEnd": "وقت النهاية"
},
"rowColor": {
"label": "لون الصف"
},
+7
View File
@@ -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}`}
/>
) : (