diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 9cfc8e7f..09b5e252 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -231,6 +231,12 @@ const meetingBaseFields = { .transform((v) => (v ? 1 : 0)) .optional(), notes: z.string().trim().max(5000).nullable().optional(), + // #635: external-meeting flag. Coerce truthy/falsy → boolean so older + // clients can keep sending 0/1 without surprises. + isExternal: z + .union([z.boolean(), z.number()]) + .transform((v) => Boolean(v)) + .optional(), } as const; const meetingCreateSchema = z @@ -282,6 +288,7 @@ const meetingPatchSchema = z status: meetingBaseFields.status, isHighlighted: meetingBaseFields.isHighlighted, notes: meetingBaseFields.notes, + isExternal: meetingBaseFields.isExternal, attendees: z.array(attendeeSchema).optional(), merge: meetingMergeSchema.optional(), // Row-highlight colour (#288). null = clear back to default (no tint). @@ -669,6 +676,12 @@ router.post( status: data.status ?? "scheduled", isHighlighted: data.isHighlighted ?? 0, notes: stripTagsToPlainTextOrNull(data.notes), + // #635: persist the external-meeting flag; default false. + // When true, force rowColor='red' so the schedule always + // renders the row in the agreed external-meeting tint even + // if the client forgot to pass it. + isExternal: Boolean(data.isExternal ?? false), + rowColor: data.isExternal ? "red" : (data as { rowColor?: string }).rowColor ?? null, createdBy: userId, updatedBy: userId, }; @@ -843,6 +856,22 @@ router.patch( if (data.rowColor !== undefined) { updateValues.rowColor = data.rowColor; } + // #635: persist isExternal. When switching to external, also + // force rowColor='red' (overrides any rowColor in the same + // PATCH) so the red tint always tracks the flag. + if (data.isExternal !== undefined) { + updateValues.isExternal = Boolean(data.isExternal); + if (data.isExternal) updateValues.rowColor = "red"; + } + // #635: belt-and-suspenders — if the row is (or is becoming) + // external and a non-red rowColor would otherwise be persisted + // by this PATCH, force red. Prevents PDF/export paths that read + // the raw rowColor from showing a non-red tint for externals. + const willBeExternal = + data.isExternal !== undefined ? Boolean(data.isExternal) : existing.isExternal; + if (willBeExternal && updateValues.rowColor !== undefined && updateValues.rowColor !== "red") { + updateValues.rowColor = "red"; + } if (Object.keys(updateValues).length > 1) { await tx @@ -1126,6 +1155,7 @@ async function computeCascadeShift( startTime: executiveMeetingsTable.startTime, endTime: executiveMeetingsTable.endTime, status: executiveMeetingsTable.status, + isExternal: executiveMeetingsTable.isExternal, }) .from(executiveMeetingsTable) .where(eq(executiveMeetingsTable.meetingDate, date)); @@ -1138,6 +1168,10 @@ async function computeCascadeShift( const sorted = candidates .filter((c) => c.id !== primaryId) .filter((c) => c.status !== "cancelled" && c.status !== "completed") + // #635: external meetings are excluded from auto-postpone cascades + // by design — they're scheduled with the other party and can't be + // unilaterally shifted. + .filter((c) => !c.isExternal) .map((c) => ({ ...c, _startMin: parseTimeToMinutes(c.startTime), @@ -1573,6 +1607,18 @@ router.post( if (!locked) { return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; } + // #635: External meetings can never be auto-postponed — they're + // scheduled with an outside party and shifting them unilaterally + // would put the schedule out of sync. The UI also disables the + // Postpone button, so this is a belt-and-suspenders 409. + if (locked.isExternal) { + return { + ok: false, + status: 409, + error: "External meetings cannot be auto-postponed", + code: "external_meeting_no_auto_postpone", + }; + } // #283: Optimistic-locking guard. The client always sends the // `updatedAt` it last observed; if the row has been modified // since, refuse to mutate and return the current state + the diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx index 7c324c07..79c8b806 100644 --- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -80,6 +80,10 @@ type Meeting = { // when another user mutated this meeting after it was loaded. Server // sets it on every update; client echoes it back as expectedUpdatedAt. updatedAt: string; + // #635: external meeting flag — the alert "Postpone" button is + // disabled when true (the server also rejects with + // `external_meeting_no_auto_postpone`). + isExternal?: boolean; }; type DayResponse = { date: string; meetings: Meeting[] }; type AlertState = { @@ -698,7 +702,14 @@ export function UpcomingMeetingAlert() { size="sm" variant="outline" onClick={() => setPostponeOpen(true)} - disabled={savingAction !== null} + // #635: external meetings can't be auto-postponed (the + // server also rejects with external_meeting_no_auto_postpone). + disabled={savingAction !== null || meeting.isExternal === true} + title={ + meeting.isExternal + ? t("executiveMeetings.alert.externalCannotPostpone") + : undefined + } data-testid="alert-postpone" > {t("executiveMeetings.alert.postpone")} @@ -1145,6 +1156,20 @@ function PostponeDialog({ } }; const handleErr = (err: unknown) => { + // #635: map the external-meeting 409 to the localized reason so + // schedule quick-action callers don't show the raw English error + // from the server. + const code = + err && typeof err === "object" && "code" in err + ? (err as { code?: unknown }).code + : undefined; + if (code === "external_meeting_no_auto_postpone") { + toast({ + title: t("executiveMeetings.alert.externalCannotPostpone"), + variant: "destructive", + }); + return; + } toast({ title: t("executiveMeetings.alert.saveFailed"), description: err instanceof Error ? err.message : String(err), diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 360dd5dd..14057ab7 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1246,6 +1246,7 @@ "atTime": "في {{time}}", "done": "تم", "postpone": "تأجيل", + "externalCannotPostpone": "لا يمكن تأجيل اجتماع خارجي تلقائياً.", "dismiss": "تجاهل التنبيه", "dragHandle": "اسحب للتحريك", "dismissToast": "تم تجاهل التنبيه", @@ -1474,7 +1475,11 @@ "platform": "المنصة", "status": "الحالة", "isHighlighted": "تمييز (لون أحمر)", - "notes": "ملاحظات" + "notes": "ملاحظات", + "isExternal": "اجتماع خارجي", + "isExternalHint": "يصبح صف الاجتماع باللون الأحمر تلقائياً، ولا يدخل في التأجيل التلقائي.", + "isExternalOn": "نعم", + "isExternalOff": "لا" }, "attendees": { "label": "قائمة الحضور", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index c6f3c55f..d80430a0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1174,6 +1174,7 @@ "atTime": "at {{time}}", "done": "Done", "postpone": "Postpone", + "externalCannotPostpone": "External meetings can't be auto-postponed.", "dismiss": "Dismiss alert", "dragHandle": "Drag to move", "dismissToast": "Alert dismissed", @@ -1347,7 +1348,11 @@ "platform": "Platform", "status": "Status", "isHighlighted": "Highlight (red)", - "notes": "Notes" + "notes": "Notes", + "isExternal": "External meeting", + "isExternalHint": "Row turns red automatically and is excluded from auto-postpone.", + "isExternalOn": "Yes", + "isExternalOff": "No" }, "attendees": { "label": "Attendees", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index dd3ae1bd..271c8574 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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>(() => { const out: Record = {}; 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)} /> + {/* #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. */} - update("dailyNumber", e.target.value)} - /> +
+ update("isExternal", Boolean(v))} + data-testid="em-form-isExternal" + /> + + {state.isExternal + ? t("executiveMeetings.manage.field.isExternalOn") + : t("executiveMeetings.manage.field.isExternalOff")} + +
usersTable.id, { onDelete: "set null", }),