#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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user