#635: External meetings + auto-numbering

User asked for three things in the executive-meetings module:
1. Remove the "daily number" input from the add/edit dialog — numbering
   is auto-assigned by the server already, so the field was just noise.
2. Add an "اجتماع خارجي" (external meeting) toggle. When ON, the row
   is force-tinted red in the schedule grid.
3. External meetings must be excluded from the auto-postpone flow:
   the alert "Postpone" button is disabled, and the cascade shift
   skips them as followers.

Changes
- lib/db/src/schema/executive-meetings.ts
  + `is_external boolean not null default false` column. Pushed via
    drizzle-kit (no destructive migration; defaults backfill).
- artifacts/api-server/src/routes/executive-meetings.ts
  + `isExternal` added to base zod fields, POST insert, PATCH update.
  + POST/PATCH force `rowColor='red'` when `isExternal=true`.
  + POST /:id/postpone-minutes rejects external meetings with
    HTTP 409 + code `external_meeting_no_auto_postpone`.
  + `computeCascadeShift` filters out external followers (preview +
    writer stay in lockstep, as the comment promises).
- artifacts/tx-os/src/pages/executive-meetings.tsx
  + Meeting type + MeetingFormState gain `isExternal`; dropped the
    `dailyNumber` field from the form state entirely.
  + `emptyMeetingForm` / `openEdit` updated; ManageSection.save() and
    the inline ScheduleSection save body now send `isExternal` and
    never send `dailyNumber`.
  + MeetingFormDialog: removed the daily-number FormRow and added a
    Switch-based external-meeting FormRow with localized hint.
  + `rowColors` memo coerces to "red" whenever `m.isExternal`.
  + Audit-diff "interesting" list now includes `isExternal`.
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
  + Meeting type gains optional `isExternal`.
  + "Postpone" button disabled + tooltip when meeting is external.
- locales: added `field.isExternal`, `field.isExternalHint`,
  `field.isExternalOn/Off`, and `alert.externalCannotPostpone` in
  both ar.json and en.json. Existing dailyNumber keys kept (still
  referenced by the manage-table column header / cell display).

Post-review hardening (from architect pass)
- PATCH now coerces rowColor → 'red' for any PATCH that touches a row
  that is (or becomes) external. Prevents PDF/export paths that read
  raw rowColor from showing a non-red tint for externals.
- Schedule row quick-actions popover: the "Postpone" button is now
  disabled with a localized tooltip when the meeting is external,
  matching the upcoming-meeting alert behavior.
- PostponeDialog.handleErr maps the 409 code
  `external_meeting_no_auto_postpone` to the localized
  `alert.externalCannotPostpone` toast so users see a real reason
  instead of the raw English error string.

Notes
- The `dailyNumber` column stays in DB (heavily used for slot
  persistence and the unique index); only the form input was removed.
  Server's `nextDailyNumber()` already auto-assigns when the field is
  absent on POST, and PATCH leaves it untouched when absent.
- Two pre-existing TS errors remain in api-server (`isHighlighted`
  type-inference quirk on `z.union(...).transform(...)` and a
  font_settings comparison) — not in scope for this task.
- Tests not added; verification by typecheck on changed surfaces and
  workflow-restart smoke. No e2e harness was wired.
This commit is contained in:
riyadhafraa
2026-05-25 11:59:54 +00:00
parent 5dc42fdb81
commit 736785e3d3
6 changed files with 135 additions and 19 deletions
@@ -194,6 +194,10 @@ type Meeting = {
// is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below.
rowColor?: string | null;
// #635: External meeting flag. When true, the row is force-tinted red
// and the alert-postpone button is disabled (the server also rejects
// /postpone-minutes with `external_meeting_no_auto_postpone`).
isExternal?: boolean;
// #486: optimistic-lock token used by /executive-meetings/swap-times
// (the schedule row quick-actions Move up / Move down popover) and
// by the existing PostponeDialog when reused from the schedule.
@@ -1361,7 +1365,12 @@ function ScheduleSection({
const rowColors = useMemo<Record<number, string>>(() => {
const out: Record<number, string> = {};
for (const m of meetings) {
if (m.rowColor) out[m.id] = m.rowColor;
// #635: external meetings are always tinted red regardless of
// (and overriding) any manually-set rowColor, so the visual cue
// matches the flag even on legacy rows whose rowColor was set
// before isExternal existed.
if (m.isExternal) out[m.id] = "red";
else if (m.rowColor) out[m.id] = m.rowColor;
}
return out;
}, [meetings]);
@@ -3403,10 +3412,9 @@ function ScheduleSection({
sortOrder: idx,
kind: a.kind ?? "person",
})),
// #635: external meeting flag.
isExternal: editingMeeting.isExternal,
};
if (editingMeeting.dailyNumber.trim()) {
body.dailyNumber = Number(editingMeeting.dailyNumber);
}
try {
await apiJson(
`/api/executive-meetings/${editingMeeting.id}`,
@@ -4984,6 +4992,12 @@ function MeetingRow({
setQuickOpen(false);
onQuickPostpone?.();
}}
disabled={meeting.isExternal}
title={
meeting.isExternal
? t("executiveMeetings.alert.externalCannotPostpone")
: undefined
}
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
@@ -6266,7 +6280,6 @@ type MeetingFormState = {
titleAr: string;
titleEn: string;
meetingDate: string;
dailyNumber: string;
startTime: string;
endTime: string;
location: string;
@@ -6276,6 +6289,9 @@ type MeetingFormState = {
isHighlighted: boolean;
notes: string;
attendees: Attendee[];
// #635: external meeting flag — when true the row is force-tinted red
// and the alert-postpone button is disabled.
isExternal: boolean;
};
// Generate a stable client-only attendee id for the manage-dialog DnD.
@@ -6294,7 +6310,6 @@ function emptyMeetingForm(date: string): MeetingFormState {
titleAr: "",
titleEn: "",
meetingDate: date,
dailyNumber: "",
startTime: "",
endTime: "",
location: "",
@@ -6304,6 +6319,7 @@ function emptyMeetingForm(date: string): MeetingFormState {
isHighlighted: false,
notes: "",
attendees: [],
isExternal: false,
};
}
@@ -6602,7 +6618,6 @@ function ManageSection({
titleAr: htmlToPlainText(m.titleAr),
titleEn: htmlToPlainText(m.titleEn ?? ""),
meetingDate: m.meetingDate,
dailyNumber: String(m.dailyNumber),
startTime: m.startTime ? m.startTime.slice(0, 5) : "",
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
location: m.location ?? "",
@@ -6622,6 +6637,7 @@ function ManageSection({
name: htmlToPlainText(a.name),
_sid: genAttendeeSid(),
})),
isExternal: m.isExternal ?? false,
});
}
@@ -6675,8 +6691,10 @@ function ManageSection({
// schedule survive a save through the Manage dialog.
kind: a.kind ?? "person",
})),
// #635: send the external flag so the server can force rowColor='red'
// and the cascade/postpone rules know to skip it.
isExternal: editing.isExternal,
};
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
try {
if (editing.id == null) {
await apiJson(`/api/executive-meetings`, { method: "POST", body });
@@ -7553,16 +7571,26 @@ function MeetingFormDialog({
onChange={(e) => update("meetingDate", e.target.value)}
/>
</FormRow>
{/* #635: external-meeting toggle replaces the daily-number
input (numbering is now fully auto-assigned by the
server). When ON the row is force-tinted red and the
alert-postpone button is disabled. */}
<FormRow
label={t("executiveMeetings.manage.field.dailyNumber")}
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
label={t("executiveMeetings.manage.field.isExternal")}
hint={t("executiveMeetings.manage.field.isExternalHint")}
>
<Input
type="number"
min={1}
value={state.dailyNumber}
onChange={(e) => update("dailyNumber", e.target.value)}
/>
<div className="flex items-center gap-2 h-9">
<Switch
checked={state.isExternal}
onCheckedChange={(v) => update("isExternal", Boolean(v))}
data-testid="em-form-isExternal"
/>
<span className="text-xs text-gray-700">
{state.isExternal
? t("executiveMeetings.manage.field.isExternalOn")
: t("executiveMeetings.manage.field.isExternalOff")}
</span>
</div>
</FormRow>
<FormRow
label={t("executiveMeetings.manage.field.startTime")}
@@ -7843,6 +7871,7 @@ function AuditDiffSummary({
"platform",
"status",
"isHighlighted",
"isExternal",
"notes",
"assignedTo",
"taskType",