diff --git a/artifacts/api-server/tests/app-permissions-unique.test.mjs b/artifacts/api-server/tests/app-permissions-unique.test.mjs new file mode 100644 index 00000000..d4afac5d --- /dev/null +++ b/artifacts/api-server/tests/app-permissions-unique.test.mjs @@ -0,0 +1,83 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let testAppId; +let testPermissionId; + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const slug = `apuq_app_${stamp}`; + const permName = `apuq.perm.${stamp}`; + + const route = `/${slug}`; + const appRes = await pool.query( + `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order) + VALUES ($1, $2, $3, $4, true, 999) + RETURNING id`, + [slug, slug, slug, route], + ); + testAppId = appRes.rows[0].id; + + const permRes = await pool.query( + `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`, + [permName, permName], + ); + testPermissionId = permRes.rows[0].id; +}); + +after(async () => { + await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [testAppId]); + await pool.query(`DELETE FROM apps WHERE id = $1`, [testAppId]); + await pool.query(`DELETE FROM permissions WHERE id = $1`, [testPermissionId]); + await pool.end(); +}); + +test("app_permissions composite primary key prevents duplicate (app_id, permission_id)", async () => { + // First insert succeeds + await pool.query( + `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`, + [testAppId, testPermissionId], + ); + + // Plain duplicate insert should fail with a unique-violation error (SQLSTATE 23505) + await assert.rejects( + pool.query( + `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`, + [testAppId, testPermissionId], + ), + (err) => err.code === "23505", + ); + + // Only one row should exist for the pair + const { rows: countRows } = await pool.query( + `SELECT COUNT(*)::int AS c FROM app_permissions WHERE app_id = $1 AND permission_id = $2`, + [testAppId, testPermissionId], + ); + assert.equal(countRows[0].c, 1); +}); + +test("ON CONFLICT DO NOTHING handles duplicate inserts gracefully (no error, no extra row)", async () => { + // Re-running the same insert with ON CONFLICT DO NOTHING should not throw + // and should not add a second row. This mirrors what `seed.ts` and any + // future admin permission management endpoint does via Drizzle's + // `.onConflictDoNothing()`. + const res = await pool.query( + `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [testAppId, testPermissionId], + ); + assert.equal(res.rowCount, 0, "expected no row to be inserted on conflict"); + + const { rows: countRows } = await pool.query( + `SELECT COUNT(*)::int AS c FROM app_permissions WHERE app_id = $1 AND permission_id = $2`, + [testAppId, testPermissionId], + ); + assert.equal(countRows[0].c, 1, "exactly one row should remain after conflict-safe insert"); +}); diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 326f2774..c3ff5c90 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 9d134404..2f1ae848 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -629,6 +629,17 @@ "attendees": "الحضور", "time": "الوقت" }, + "customize": { + "title": "تخصيص الجدول", + "hint": "أظهر أو أخفِ الأعمدة، أعد ترتيبها، أو أعد التعيين الافتراضي.", + "moveUp": "تحريك للأعلى", + "moveDown": "تحريك للأسفل", + "reset": "إعادة التعيين", + "dragHint": "اسحب حد العمود لتغيير عرضه" + }, + "rowColor": { + "label": "لون الصف" + }, "nav": { "schedule": "جدول الاجتماعات اليومي", "manage": "إدارة الاجتماعات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 14bdbae2..2e4cc604 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -626,6 +626,17 @@ "attendees": "Attendees", "time": "Time" }, + "customize": { + "title": "Customize table", + "hint": "Show or hide columns, reorder them, or reset to defaults.", + "moveUp": "Move up", + "moveDown": "Move down", + "reset": "Reset", + "dragHint": "Drag a column edge to resize it" + }, + "rowColor": { + "label": "Row color" + }, "nav": { "schedule": "Daily Schedule", "manage": "Manage Meetings", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 03f27016..4952150c 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -1,4 +1,12 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -26,6 +34,13 @@ import { ChevronUp, ChevronDown, Copy, + Eye, + EyeOff, + ArrowUp, + ArrowDown, + RotateCcw, + Palette, + Sliders, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -46,6 +61,12 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; import { useToast } from "@/hooks/use-toast"; type Attendee = { @@ -245,6 +266,37 @@ const DEFAULT_FONT: FontPrefs = { alignment: "start", }; +type ColumnId = "number" | "meeting" | "attendees" | "time"; + +type ColumnSetting = { + id: ColumnId; + visible: boolean; + width: number; +}; + +const DEFAULT_COLUMNS: ColumnSetting[] = [ + { id: "number", visible: true, width: 56 }, + { id: "meeting", visible: true, width: 320 }, + { id: "attendees", visible: true, width: 600 }, + { id: "time", visible: true, width: 120 }, +]; + +const MIN_COL_WIDTH = 56; +const MAX_COL_WIDTH = 1200; + +const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [ + { key: "default", bg: "", label: "default" }, + { key: "red", bg: "#fee2e2", label: "red" }, + { key: "amber", bg: "#fef3c7", label: "amber" }, + { key: "green", bg: "#dcfce7", label: "green" }, + { key: "blue", bg: "#dbeafe", label: "blue" }, + { key: "violet", bg: "#ede9fe", label: "violet" }, + { key: "gray", bg: "#f3f4f6", label: "gray" }, +]; + +const COLS_STORAGE_KEY = "em-schedule-cols-v1"; +const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1"; + function formatTime(t: string | null): string { if (!t) return ""; return t.slice(0, 5); @@ -305,6 +357,55 @@ async function apiJson( return data; } +function readJsonFromStorage(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeJsonToStorage(key: string, value: unknown): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* ignore */ + } +} + +function normalizeColumns(value: unknown): ColumnSetting[] { + if (!Array.isArray(value)) return DEFAULT_COLUMNS; + const seen = new Set(); + const out: ColumnSetting[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Partial; + if (typeof e.id !== "string") continue; + if (!DEFAULT_COLUMNS.some((d) => d.id === e.id)) continue; + if (seen.has(e.id as ColumnId)) continue; + seen.add(e.id as ColumnId); + const fallback = DEFAULT_COLUMNS.find((d) => d.id === e.id)!; + const width = + typeof e.width === "number" && Number.isFinite(e.width) + ? Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, e.width)) + : fallback.width; + out.push({ + id: e.id as ColumnId, + visible: typeof e.visible === "boolean" ? e.visible : true, + width, + }); + } + // Append any defaults that were missing (e.g. new columns added later). + for (const def of DEFAULT_COLUMNS) { + if (!seen.has(def.id)) out.push({ ...def }); + } + return out; +} + export default function ExecutiveMeetingsPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -520,6 +621,48 @@ function ScheduleSection({ font: FontPrefs; }) { const tableStyle = buildFontStyle(font); + const [columns, setColumns] = useState(() => + normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)), + ); + const [rowColors, setRowColors] = useState>(() => + readJsonFromStorage>(ROW_COLORS_STORAGE_KEY, {}), + ); + + useEffect(() => { + writeJsonToStorage(COLS_STORAGE_KEY, columns); + }, [columns]); + + useEffect(() => { + writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors); + }, [rowColors]); + + const visibleColumns = columns.filter((c) => c.visible); + + const setColumnWidth = useCallback((id: ColumnId, width: number) => { + setColumns((prev) => + prev.map((c) => + c.id === id + ? { + ...c, + width: Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, width)), + } + : c, + ), + ); + }, []); + + const setRowColor = useCallback((meetingId: number, colorKey: string) => { + setRowColors((prev) => { + const next = { ...prev }; + if (colorKey === "default") { + delete next[meetingId]; + } else { + next[meetingId] = colorKey; + } + return next; + }); + }, []); + return (
@@ -529,15 +672,22 @@ function ScheduleSection({ > {t("executiveMeetings.scheduleHeading")} -
@@ -546,53 +696,75 @@ function ScheduleSection({
- - - - + {visibleColumns.map((c) => ( + + ))} - - - - + {visibleColumns.map((c, idx) => { + const isLast = idx === visibleColumns.length - 1; + const align = + c.id === "number" || c.id === "time" ? "text-center" : "text-start"; + return ( + + ); + })} {isLoading && ( - )} {!isLoading && meetings.length === 0 && ( - )} {meetings.map((m) => ( - + setRowColor(m.id, key)} + /> ))}
- {t("executiveMeetings.col.number")} - - {t("executiveMeetings.col.meeting")} - - {t("executiveMeetings.col.attendees")} - - {t("executiveMeetings.col.time")} - + {t(`executiveMeetings.col.${c.id}`)} + {!isLast && ( + setColumnWidth(c.id, c.width + delta)} + /> + )} +
+ {t("common.loading")}
+ {t("executiveMeetings.noMeetings")}
@@ -601,25 +773,330 @@ function ScheduleSection({ ); } +function ResizeHandle({ + isRtl, + onResize, +}: { + isRtl: boolean; + onResize: (delta: number) => void; +}) { + const startXRef = useRef(null); + + const onPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLDivElement).setPointerCapture(e.pointerId); + startXRef.current = e.clientX; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (startXRef.current === null) return; + const raw = e.clientX - startXRef.current; + // In RTL the trailing edge is on the LEFT, so dragging left makes the + // column wider. Negate the delta in that case. + const delta = isRtl ? -raw : raw; + if (Math.abs(delta) >= 1) { + startXRef.current = e.clientX; + onResize(delta); + } + }; + + const onPointerUp = (e: React.PointerEvent) => { + startXRef.current = null; + try { + (e.target as HTMLDivElement).releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + }; + + // Place handle on the trailing (logical end) edge so it works the same in + // both LTR and RTL. + const sideClass = isRtl ? "left-0" : "right-0"; + + return ( +
+ ); +} + +function ColumnsCustomizer({ + columns, + onChange, + t, +}: { + columns: ColumnSetting[]; + onChange: (next: ColumnSetting[]) => void; + t: (k: string) => string; +}) { + const move = (index: number, dir: -1 | 1) => { + const target = index + dir; + if (target < 0 || target >= columns.length) return; + const next = columns.slice(); + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }; + + const toggle = (id: ColumnId, value: boolean) => { + // Always keep at least one column visible. + const visibleCount = columns.filter((c) => c.visible).length; + if (!value && visibleCount <= 1) return; + onChange(columns.map((c) => (c.id === id ? { ...c, visible: value } : c))); + }; + + const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c }))); + + return ( + + + + + +
+ {t("executiveMeetings.customize.title")} +
+

+ {t("executiveMeetings.customize.hint")} +

+
    + {columns.map((c, idx) => ( +
  • + toggle(c.id, !!v)} + data-testid={`em-customize-toggle-${c.id}`} + /> + + {!c.visible ? ( + + ) : ( + + )} + + +
  • + ))} +
+
+ + + {t("executiveMeetings.customize.dragHint")} + +
+
+
+ ); +} + +function RowColorPicker({ + current, + onPick, + t, +}: { + current: string; + onPick: (key: string) => void; + t: (k: string) => string; +}) { + return ( + + + + + +
+ {t("executiveMeetings.rowColor.label")} +
+
+ {ROW_COLOR_OPTIONS.map((opt) => { + const selected = current === opt.key; + const isDefault = opt.key === "default"; + return ( +
+
+
+ ); +} + function MeetingRow({ meeting, isRtl, t, + visibleColumns, + rowColorKey, + onPickRowColor, }: { meeting: Meeting; isRtl: boolean; t: (k: string) => string; + visibleColumns: ColumnSetting[]; + rowColorKey: string; + onPickRowColor: (key: string) => void; }) { const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr; const isCancelled = meeting.status === "cancelled"; const highlight = meeting.isHighlighted === 1; + const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey); + const rowBg = rowColor?.bg || ""; + const numberCellCls = "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " + (highlight || isCancelled ? "bg-red-600 text-white" : "bg-white text-[#0B1E3F]"); + const renderCell = (col: ColumnSetting): ReactNode => { + switch (col.id) { + case "number": + return ( + + {meeting.dailyNumber} + + + ); + case "meeting": + return ( + +
{title}
+ {meeting.location && ( +
+ {meeting.location} +
+ )} + + ); + case "attendees": + return ( + + + + ); + case "time": + return ( + +
+
{formatTime(meeting.startTime)}
+
+
{formatTime(meeting.endTime)}
+
+ + ); + } + }; + + return ( + + {visibleColumns.map((c) => renderCell(c))} + + ); +} + +function AttendeesCell({ + meeting, + t, +}: { + meeting: Meeting; + t: (k: string) => string; +}) { const virtual = meeting.attendees.filter((a) => a.attendanceType === "virtual"); const internal = meeting.attendees.filter((a) => a.attendanceType === "internal"); const external = meeting.attendees.filter((a) => a.attendanceType === "external"); @@ -633,62 +1110,45 @@ function MeetingRow({ other: t("executiveMeetings.virtual"), }; - return ( - - {meeting.dailyNumber} - - -
{title}
- {meeting.location && ( -
{meeting.location}
- )} - - - - {hasSplit ? ( -
- {virtual.length > 0 && ( - - )} - {internal.length > 0 && ( - - )} - {external.length > 0 && ( - - )} -
- ) : meeting.platform !== "none" && virtual.length > 0 ? ( + if (hasSplit) { + return ( +
+ {virtual.length > 0 && ( - ) : ( - )} - + {internal.length > 0 && ( + + )} + {external.length > 0 && ( + + )} +
+ ); + } - -
-
{formatTime(meeting.startTime)}
-
-
{formatTime(meeting.endTime)}
-
- - - ); + if (meeting.platform !== "none" && virtual.length > 0) { + return ( + + ); + } + + return ; } function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) { diff --git a/lib/db/src/schema/apps.ts b/lib/db/src/schema/apps.ts index a0c9060b..4f852f90 100644 --- a/lib/db/src/schema/apps.ts +++ b/lib/db/src/schema/apps.ts @@ -1,4 +1,4 @@ -import { pgTable, text, serial, timestamp, integer, varchar, boolean } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, timestamp, integer, varchar, boolean, primaryKey } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; @@ -22,7 +22,7 @@ export const appsTable = pgTable("apps", { export const appPermissionsTable = pgTable("app_permissions", { appId: integer("app_id").notNull().references(() => appsTable.id, { onDelete: "cascade" }), permissionId: integer("permission_id").notNull(), -}); +}, (t) => [primaryKey({ columns: [t.appId, t.permissionId] })]); export const insertAppSchema = createInsertSchema(appsTable).omit({ id: true, createdAt: true, updatedAt: true }); export type InsertApp = z.infer;