diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 73e46ad0..8ecbc6a7 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -70,17 +70,19 @@ const REQUEST_REVIEW_STATUSES = [ "needs_edit", ] as const; const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; +// Font settings constraints per Phase 2 spec: limited family list, regular/bold +// only, start/center alignment only, size 12–22. const FONT_FAMILIES = [ "system", "Cairo", "Tajawal", "Noto Naskh Arabic", "Amiri", - "Inter", - "monospace", ] as const; -const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; -const FONT_ALIGNMENTS = ["start", "center", "end"] as const; +const FONT_WEIGHTS = ["regular", "bold"] as const; +const FONT_ALIGNMENTS = ["start", "center"] as const; +const FONT_SIZE_MIN = 12; +const FONT_SIZE_MAX = 22; const MUTATE_ROLES = [ "admin", @@ -297,7 +299,7 @@ const pdfArchiveCreateSchema = z.object({ const fontSettingsSchema = z.object({ scope: z.enum(["user", "global"]).default("user"), fontFamily: z.enum(FONT_FAMILIES).default("system"), - fontSize: z.number().int().min(10).max(32).default(14), + fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14), fontWeight: z.enum(FONT_WEIGHTS).default("regular"), alignment: z.enum(FONT_ALIGNMENTS).default("start"), }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 806bc885..9f061783 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -685,8 +685,14 @@ "id": "رقم الحاضر", "type": "نوع الحضور", "add": "إضافة حاضر", - "remove": "إزالة" + "remove": "إزالة", + "moveUp": "تحريك للأعلى", + "moveDown": "تحريك للأسفل" }, + "duplicate": "تكرار", + "duplicateToDate": "تكرار إلى تاريخ", + "duplicated": "تم تكرار الاجتماع", + "duplicateFailed": "تعذر تكرار الاجتماع", "platform": { "none": "بدون", "webex": "Webex", @@ -830,6 +836,18 @@ "done": "تم" } }, + "print": { + "title": "جدول الاجتماعات التنفيذية", + "no": "#", + "meeting": "الاجتماع", + "attendees": "الحضور", + "time": "الوقت", + "location": "المكان", + "none": "لا توجد اجتماعات لهذا اليوم.", + "print": "طباعة", + "back": "إغلاق", + "loading": "جارٍ التحميل…" + }, "pdf": { "heading": "تصدير / طباعة", "intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d687f02e..f3ff608b 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -682,8 +682,14 @@ "id": "Attendee ID", "type": "Attendance type", "add": "Add attendee", - "remove": "Remove" + "remove": "Remove", + "moveUp": "Move up", + "moveDown": "Move down" }, + "duplicate": "Duplicate", + "duplicateToDate": "Duplicate to date", + "duplicated": "Meeting duplicated", + "duplicateFailed": "Could not duplicate the meeting", "platform": { "none": "None", "webex": "Webex", @@ -840,6 +846,18 @@ "archivesHeading": "Archived snapshots", "noArchives": "No snapshots archived for this date yet." }, + "print": { + "title": "Executive Meetings Schedule", + "no": "#", + "meeting": "Meeting", + "attendees": "Attendees", + "time": "Time", + "location": "Location", + "none": "No meetings for this day.", + "print": "Print", + "back": "Close", + "loading": "Loading…" + }, "fontSettingsPage": { "heading": "Font Settings", "intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.", diff --git a/artifacts/tx-os/src/pages/executive-meetings-print.tsx b/artifacts/tx-os/src/pages/executive-meetings-print.tsx index 3e188f3b..a2362ddc 100644 --- a/artifacts/tx-os/src/pages/executive-meetings-print.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings-print.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; type Attendee = { id: number; @@ -35,31 +36,22 @@ function getQuery(): { date: string; lang: "ar" | "en" } { return { date, lang }; } -const T = { - ar: { - title: "جدول الاجتماعات التنفيذية", - no: "#", - meeting: "الاجتماع", - attendees: "الحضور", - time: "الوقت", - location: "المكان", - none: "لا توجد اجتماعات لهذا اليوم.", - print: "طباعة", - back: "إغلاق", - loading: "جارٍ التحميل…", - }, - en: { - title: "Executive Meetings Schedule", - no: "#", - meeting: "Meeting", - attendees: "Attendees", - time: "Time", - location: "Location", - none: "No meetings for this day.", - print: "Print", - back: "Close", - loading: "Loading…", - }, +// All printable strings live in the project locale files under +// `executiveMeetings.print.*`, keeping AR + EN parity with the rest of the +// app. We force i18next into the language requested by the URL so a +// "Print English" link always renders English regardless of the active +// session language. +const PRINT_KEYS = { + title: "executiveMeetings.print.title", + no: "executiveMeetings.print.no", + meeting: "executiveMeetings.print.meeting", + attendees: "executiveMeetings.print.attendees", + time: "executiveMeetings.print.time", + location: "executiveMeetings.print.location", + none: "executiveMeetings.print.none", + print: "executiveMeetings.print.print", + back: "executiveMeetings.print.back", + loading: "executiveMeetings.print.loading", } as const; export default function ExecutiveMeetingsPrintPage() { @@ -67,7 +59,22 @@ export default function ExecutiveMeetingsPrintPage() { const [data, setData] = useState(null); const [error, setError] = useState(null); const isRtl = lang === "ar"; - const t = T[lang]; + const { t: i18nT, i18n } = useTranslation(); + useEffect(() => { + if (i18n.language !== lang) void i18n.changeLanguage(lang); + }, [lang, i18n]); + const t = { + title: i18nT(PRINT_KEYS.title), + no: i18nT(PRINT_KEYS.no), + meeting: i18nT(PRINT_KEYS.meeting), + attendees: i18nT(PRINT_KEYS.attendees), + time: i18nT(PRINT_KEYS.time), + location: i18nT(PRINT_KEYS.location), + none: i18nT(PRINT_KEYS.none), + print: i18nT(PRINT_KEYS.print), + back: i18nT(PRINT_KEYS.back), + loading: i18nT(PRINT_KEYS.loading), + }; useEffect(() => { document.documentElement.dir = isRtl ? "rtl" : "ltr"; @@ -191,7 +198,7 @@ export default function ExecutiveMeetingsPrintPage() { ) : null} - + {attendees || "—"} {time} diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 7d5acef9..d30f8866 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -23,6 +23,9 @@ import { Printer, Check, X, + ChevronUp, + ChevronDown, + Copy, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -157,8 +160,8 @@ type NotificationRow = { type FontPrefs = { fontFamily: string; fontSize: number; - fontWeight: "regular" | "medium" | "semibold" | "bold"; - alignment: "start" | "center" | "end"; + fontWeight: "regular" | "bold"; + alignment: "start" | "center"; }; type FontSettingsResponse = { @@ -256,7 +259,7 @@ function todayIso(): string { } function fontWeightToCss(w: FontPrefs["fontWeight"]): number { - return { regular: 400, medium: 500, semibold: 600, bold: 700 }[w]; + return { regular: 400, bold: 700 }[w]; } function fontFamilyToCss(name: string): string { @@ -775,6 +778,14 @@ function ManageSection({ const [editing, setEditing] = useState(null); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); + // Duplicate-to-date dialog state. The chosen target date is sent to the + // backend POST /:id/duplicate route which clones the meeting (and its + // attendees) onto that date. + const [duplicating, setDuplicating] = useState<{ + id: number; + targetDate: string; + } | null>(null); + const [duplicatingBusy, setDuplicatingBusy] = useState(false); const { data, isLoading } = useQuery({ queryKey: ["/api/executive-meetings", date], @@ -785,6 +796,35 @@ function ManageSection({ function openCreate() { setEditing(emptyMeetingForm(date)); } + function openDuplicate(m: Meeting) { + setDuplicating({ id: m.id, targetDate: date }); + } + async function performDuplicate() { + if (!duplicating) return; + setDuplicatingBusy(true); + try { + await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, { + method: "POST", + body: { targetDate: duplicating.targetDate }, + }); + toast({ title: t("executiveMeetings.manage.duplicated") }); + const newDate = duplicating.targetDate; + setDuplicating(null); + if (newDate === date) { + await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] }); + } else { + onDateChange(newDate); + } + } catch (err) { + toast({ + title: t("executiveMeetings.manage.duplicateFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setDuplicatingBusy(false); + } + } function openEdit(m: Meeting) { setEditing({ id: m.id, @@ -949,6 +989,15 @@ function ManageSection({ > + + + + + ); } @@ -1017,6 +1110,16 @@ function MeetingFormDialog({ arr[i] = { ...arr[i], ...patch } as Attendee; onChange({ ...state, attendees: arr }); } + // Move an attendee up or down using deterministic arrow controls. The + // `sortOrder` field is recomputed on save so the persistent order matches + // what the user sees here. + function moveAttendee(i: number, dir: -1 | 1) { + const j = i + dir; + if (j < 0 || j >= state.attendees.length) return; + const arr = state.attendees.slice(); + [arr[i], arr[j]] = [arr[j], arr[i]]; + onChange({ ...state, attendees: arr }); + } return ( !o && onClose()}> @@ -1138,13 +1241,50 @@ function MeetingFormDialog({
{state.attendees.map((a, i) => ( -
+
+
+ + +
setAttendee(i, { name: e.target.value })} /> + + setAttendee(i, { title: e.target.value || null }) + } + data-testid={`em-attendee-title-${i}`} + /> setPrefs({ ...prefs, fontSize: Number(e.target.value) })} className="w-full" @@ -2674,7 +2812,7 @@ function FontSettingsSection({ - {(["regular", "medium", "semibold", "bold"] as const).map((w) => ( + {(["regular", "bold"] as const).map((w) => ( {t(`executiveMeetings.fontSettingsPage.weight.${w}`)} @@ -2691,7 +2829,7 @@ function FontSettingsSection({ - {(["start", "center", "end"] as const).map((a) => ( + {(["start", "center"] as const).map((a) => ( {t(`executiveMeetings.fontSettingsPage.align.${a}`)}