diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json
index da3ba1e0..692943db 100644
--- a/artifacts/tx-os/src/locales/ar.json
+++ b/artifacts/tx-os/src/locales/ar.json
@@ -1076,6 +1076,7 @@
"moveDown": "تحريك للأسفل",
"dragHandle": "اسحب لإعادة الترتيب",
"addPersonHere": "إضافة شخص هنا",
+ "addPersonHereAria": "إضافة شخص في نهاية هذا القسم",
"removeAll": "حذف جميع الحضور",
"removeAllConfirm": "حذف جميع الحضور ({{count}})؟\n\nسيتم تطبيق التغيير عند حفظ الاجتماع."
},
diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json
index 0fd0b3eb..d0c48439 100644
--- a/artifacts/tx-os/src/locales/en.json
+++ b/artifacts/tx-os/src/locales/en.json
@@ -977,6 +977,7 @@
"moveDown": "Move down",
"dragHandle": "Drag to reorder",
"addPersonHere": "Add person here",
+ "addPersonHereAria": "Add a person at the end of this section",
"removeAll": "Remove all attendees",
"removeAllConfirm": "Remove all {{count}} attendees?\n\nThe change applies when you save the meeting."
},
diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx
index 43b976ea..870011f2 100644
--- a/artifacts/tx-os/src/pages/executive-meetings.tsx
+++ b/artifacts/tx-os/src/pages/executive-meetings.tsx
@@ -3601,15 +3601,22 @@ function AttendeeFlow({
)}
,
);
- // Inline "+ person here" chip — lets the user grow this section
- // without scrolling to the trailing "+". Hidden while any ghost
- // is open (showAdd already encodes that). The chip occupies a
- // full-width line below the subheading, mirroring its layout,
- // so it visually anchors to the section it adds into.
- if (showAdd) {
+ // Subheading-only section edge case: if the very next item in
+ // the list is ALSO a subheading (or this subheading is the
+ // last item with no person after it), the section it just
+ // started has zero persons. Render a chip right after the
+ // subheading so the user can still grow that empty section
+ // without opening the manage dialog. The chip's insert index
+ // is the slot immediately after this subheading's global
+ // attendees index.
+ const nextItem = items[listIdx + 1];
+ const sectionIsEmpty =
+ !nextItem ||
+ (nextItem.a.kind ?? "person") === "subheading";
+ if (showAdd && sectionIsEmpty) {
nodes.push(
@@ -3676,6 +3683,39 @@ function AttendeeFlow({
)}
,
);
+ // Section-tail "+ شخص هنا" chip — appears AFTER the last person
+ // of each section (a section ends right before the next
+ // subheading, or at end-of-list). The chip's insertAtIndex is
+ // the slot immediately after this person's global attendees
+ // index — so the new row lands at the end of THIS section, not
+ // at the start of the next one. We only render this when the
+ // cell already has at least one subheading: in a no-subheading
+ // cell the trailing global "+" already covers the same slot.
+ const nextItemForPerson = items[listIdx + 1];
+ const isLastInSection =
+ !nextItemForPerson ||
+ (nextItemForPerson.a.kind ?? "person") === "subheading";
+ if (showAdd && hasAnySubheading && isLastInSection) {
+ nodes.push(
+
+
+
,
+ );
+ }
return nodes;
})}
{/* Inline ghost pinned to the trailing slot (insert-at-end case). */}
@@ -4292,6 +4332,35 @@ function MeetingFormDialog({
],
});
}
+ // Insert a fresh person row at a specific slot (used by the per-section
+ // "+ شخص هنا" buttons rendered after each subheading inside the manage
+ // dialog). Falls back to appending when the index is out of range.
+ function insertAttendeeAt(insertAtIndex: number) {
+ const safeIdx = Math.max(
+ 0,
+ Math.min(insertAtIndex, state.attendees.length),
+ );
+ const newRow: Attendee = {
+ name: "",
+ title: null,
+ // Inherit the previous row's attendance type so the inserted row
+ // visually belongs to the same section the user clicked into.
+ attendanceType:
+ safeIdx > 0
+ ? state.attendees[safeIdx - 1].attendanceType
+ : "internal",
+ sortOrder: safeIdx,
+ kind: "person",
+ _sid: genAttendeeSid(),
+ };
+ const arr = state.attendees.slice();
+ arr.splice(safeIdx, 0, newRow);
+ // Renumber sortOrder so the array stays a contiguous 0..N-1 sequence.
+ arr.forEach((row, idx) => {
+ row.sortOrder = idx;
+ });
+ onChange({ ...state, attendees: arr });
+ }
function addSubheading() {
onChange({
...state,
@@ -4554,25 +4623,57 @@ function MeetingFormDialog({
items={sortableIds}
strategy={verticalListSortingStrategy}
>
- {state.attendees.map((a, i) => (
- moveAttendee(i, -1)}
- onMoveDown={() => moveAttendee(i, 1)}
- onChangeName={(v) => setAttendee(i, { name: v })}
- onChangeTitle={(v) =>
- setAttendee(i, { title: v || null })
- }
- onChangeAttendanceType={(v) =>
- setAttendee(i, { attendanceType: v })
- }
- onRemove={() => removeAttendee(i)}
- />
- ))}
+ {state.attendees.flatMap((a, i) => {
+ const nodes: ReactNode[] = [
+ moveAttendee(i, -1)}
+ onMoveDown={() => moveAttendee(i, 1)}
+ onChangeName={(v) => setAttendee(i, { name: v })}
+ onChangeTitle={(v) =>
+ setAttendee(i, { title: v || null })
+ }
+ onChangeAttendanceType={(v) =>
+ setAttendee(i, { attendanceType: v })
+ }
+ onRemove={() => removeAttendee(i)}
+ />,
+ ];
+ // Per-section "+ شخص هنا" button: appears AFTER every
+ // subheading row so the user can grow the section it
+ // starts without scrolling to the bottom of the list
+ // and then dragging the new row up. Insertion target
+ // is the slot right after the subheading (i + 1).
+ if ((a.kind ?? "person") === "subheading") {
+ nodes.push(
+
+
+
,
+ );
+ }
+ return nodes;
+ })}
{state.attendees.length === 0 && (
diff --git a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
index 97d8186d..bd4532a0 100644
--- a/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
+++ b/artifacts/tx-os/tests/executive-meetings-attendee-insert-reorder.spec.mjs
@@ -5,11 +5,13 @@ import pg from "pg";
// attendees" inside executive-meetings.
//
// What we verify:
-// 1. Per-section "+ شخص هنا" chip on the schedule grid: clicking the
-// inline chip rendered after a subheading opens a ghost row at the
-// slot right after that subheading (not at the trailing slot), and
-// committing the typed name persists the new attendee BETWEEN the
-// subheading and the next existing person.
+// 1. Per-section "+ شخص هنا" chip on the schedule grid: the chip is
+// rendered AFTER THE LAST PERSON of each section (a section ends
+// right before the next subheading or at end-of-list). Clicking it
+// opens a ghost row at the slot right after that last person — i.e.
+// at the TAIL of the section the chip belongs to — and committing
+// the typed name persists the new attendee at that exact slot,
+// pushing the next subheading down by one.
// 2. Drag-and-drop reordering inside the manage dialog: picking up a
// row by its grip handle and moving it down with the keyboard
// reorders the attendee array, and saving persists the new order.
@@ -162,7 +164,7 @@ const langOffsets2 = { en: 3, ar: 4 };
const langOffsets3 = { en: 5, ar: 6 };
for (const lang of ["en", "ar"]) {
- test(`Schedule [${lang}]: "+ person here" chip after a subheading inserts at that slot`, async ({
+ test(`Schedule [${lang}]: "+ person here" chip after the last person of a section inserts at the section tail`, async ({
page,
}) => {
const date = uniqueFutureDate(langOffsets[lang]);
@@ -174,9 +176,12 @@ for (const lang of ["en", "ar"]) {
startTime: "09:00:00",
endTime: "10:00:00",
});
- // Internal section with subheading wedged between persons. After the
- // user clicks "+ person here" right after "Sec2Header" we want the
- // new person to land at sort_order 3, pushing "PersonGamma" to 4.
+ // Layout: [Alpha, Beta, Sec2Header, Gamma].
+ // Section 1 (implicit) = [Alpha, Beta] ← chip after Beta (idx 1)
+ // Section 2 = [Gamma] ← chip after Gamma (idx 3)
+ // The user clicks the chip at idx 1 — the chip belongs to the
+ // SECTION ABOVE the subheading. Insertion target is idx 2, so the
+ // new row lands BEFORE the subheading.
await insertAttendee({
meetingId,
name: "PersonAlpha",
@@ -219,18 +224,20 @@ for (const lang of ["en", "ar"]) {
await expect(row).toBeVisible({ timeout: 15_000 });
await ensureEditMode(page);
- // The "+ person here" chip renders right below each subheading
- // row, indexed by the subheading's array position (2 here).
- const chip = row.getByTestId("em-add-person-here-2");
+ // The "+ person here" chip is rendered AFTER each section's last
+ // person row, indexed by that person's global attendees idx.
+ // Section 1 ends at PersonBeta (idx 1) → chip testid -1.
+ // The chip on the subheading row itself should NOT exist (the
+ // section above it is non-empty).
+ const chip = row.getByTestId("em-add-person-here-1");
await expect(chip).toBeVisible({ timeout: 10_000 });
+ await expect(row.getByTestId("em-add-person-here-2")).toHaveCount(0);
await chip.click();
// The pending ghost
appears, with a Tiptap editor that has
// started in edit mode. Type the new name and blur to commit.
const pending = row.getByTestId("em-add-attendee-pending-internal");
await expect(pending).toBeVisible({ timeout: 5_000 });
- // The EditableCell mounts a contenteditable div — find it inside
- // the pending wrapper and type into it.
const editor = pending.locator('[contenteditable="true"]').first();
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
@@ -250,33 +257,27 @@ for (const lang of ["en", "ar"]) {
).toHaveCount(1);
// Verify the persisted order via DB — the new person must land
- // BETWEEN the subheading and the previously-trailing person.
+ // BEFORE the subheading (at the tail of section 1), not after.
const persisted = await getAttendeesOrdered(meetingId);
- // The Tiptap editor stores names as rich HTML (e.g. "
X
"),
- // while DB-seeded rows are plain strings. Strip tags before
- // comparing so the assertion stays focused on order, not on the
- // wrapper element the editor happens to emit.
const stripTags = (s) => s.replace(/<[^>]+>/g, "").trim();
const orderedNames = persisted.map((r) => stripTags(r.name));
expect(orderedNames).toEqual([
"PersonAlpha",
"PersonBeta",
- "Sec2Header-Q7",
"InsertedDelta",
+ "Sec2Header-Q7",
"PersonGamma",
]);
- // sort_order must be a contiguous 0..N-1 sequence after the
- // splice + renumber path in commitAddAttendee.
expect(persisted.map((r) => r.sort_order)).toEqual([0, 1, 2, 3, 4]);
- // Numbering: section 1 = [Alpha, Beta] → 1-, 2-; section 2 =
- // [InsertedDelta, Gamma] → 1-, 2- (cell has a subheading so all
- // persons are numbered).
+ // Numbering: section 1 = [Alpha, Beta, InsertedDelta] → 1-, 2-, 3-;
+ // section 2 = [Gamma] → 1- (cell has a subheading so all persons
+ // are numbered).
const indexTexts = await row
.locator('[data-testid^="em-attendee-index-"]')
.allInnerTexts();
const trimmed = indexTexts.map((s) => s.trim().replace(/\s+/g, ""));
- expect(trimmed).toEqual(["1-", "2-", "1-", "2-"]);
+ expect(trimmed).toEqual(["1-", "2-", "3-", "1-"]);
} finally {
if (originalAdminPreferredLanguage) {
await setAdminPreferredLanguage(originalAdminPreferredLanguage).catch(