Prevent duplicate app permission entries from being saved

Original task (#91): The `app_permissions` join table had no unique
constraint, allowing the same `(app_id, permission_id)` pair to be
inserted repeatedly. The Admin Panel restriction had grown to 24
duplicate rows. This change prevents that recurring at the DB level
and verifies that the existing conflict-safe insert path keeps working.

Schema change:
- `lib/db/src/schema/apps.ts`: added a composite primary key on
  `(app_id, permission_id)` for `appPermissionsTable`, matching the
  pattern used by other join tables in this repo (rolePermissions,
  userRoles, groupApps, etc.). This is enforced at the database level.

DB migration:
- Cleaned up the 23 duplicate rows still present in the DB before
  applying the constraint (kept the earliest row per pair using
  `ctid`), then ran `pnpm --filter @workspace/db run push` to sync the
  schema. Verified the new primary key
  `app_permissions_app_id_permission_id_pk` rejects duplicate inserts.

Graceful handling of duplicates:
- The seed script (`scripts/src/seed.ts`) already uses
  `.onConflictDoNothing()` when inserting into `app_permissions`. With
  the new primary key, that call is now properly idempotent (no longer
  silently allowing duplicates).
- There is currently no HTTP route or admin UI that POSTs into
  `app_permissions` (the task description listed `routes/apps.ts` as a
  relevant file, but no such handler exists today). The constraint
  itself is what prevents future regressions, and any future endpoint
  should follow the seed's `.onConflictDoNothing()` pattern.

Tests:
- Added `artifacts/api-server/tests/app-permissions-unique.test.mjs`
  with two cases that prove the new behavior:
  1. A plain duplicate INSERT fails with SQLSTATE 23505 (unique
     violation) and only one row remains.
  2. `INSERT ... ON CONFLICT DO NOTHING` (the pattern Drizzle's
     `.onConflictDoNothing()` emits) handles duplicates gracefully:
     no error thrown, `rowCount` is 0, and exactly one row remains.
- All previously-passing api-server tests for apps/groups still pass
  after the schema change.

Replit-Task-Id: 0589a4dc-5898-4c66-8feb-3cd48289fe89
This commit is contained in:
riyadhafraa
2026-04-28 09:03:59 +00:00
parent 589642e11d
commit bdc19d5012
7 changed files with 649 additions and 84 deletions
@@ -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");
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+11
View File
@@ -629,6 +629,17 @@
"attendees": "الحضور", "attendees": "الحضور",
"time": "الوقت" "time": "الوقت"
}, },
"customize": {
"title": "تخصيص الجدول",
"hint": "أظهر أو أخفِ الأعمدة، أعد ترتيبها، أو أعد التعيين الافتراضي.",
"moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل",
"reset": "إعادة التعيين",
"dragHint": "اسحب حد العمود لتغيير عرضه"
},
"rowColor": {
"label": "لون الصف"
},
"nav": { "nav": {
"schedule": "جدول الاجتماعات اليومي", "schedule": "جدول الاجتماعات اليومي",
"manage": "إدارة الاجتماعات", "manage": "إدارة الاجتماعات",
+11
View File
@@ -626,6 +626,17 @@
"attendees": "Attendees", "attendees": "Attendees",
"time": "Time" "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": { "nav": {
"schedule": "Daily Schedule", "schedule": "Daily Schedule",
"manage": "Manage Meetings", "manage": "Manage Meetings",
+542 -82
View File
@@ -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 { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -26,6 +34,13 @@ import {
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
Copy, Copy,
Eye,
EyeOff,
ArrowUp,
ArrowDown,
RotateCcw,
Palette,
Sliders,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -46,6 +61,12 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; 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"; import { useToast } from "@/hooks/use-toast";
type Attendee = { type Attendee = {
@@ -245,6 +266,37 @@ const DEFAULT_FONT: FontPrefs = {
alignment: "start", 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 { function formatTime(t: string | null): string {
if (!t) return ""; if (!t) return "";
return t.slice(0, 5); return t.slice(0, 5);
@@ -305,6 +357,55 @@ async function apiJson<T>(
return data; return data;
} }
function readJsonFromStorage<T>(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<ColumnId>();
const out: ColumnSetting[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const e = entry as Partial<ColumnSetting>;
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() { export default function ExecutiveMeetingsPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@@ -520,6 +621,48 @@ function ScheduleSection({
font: FontPrefs; font: FontPrefs;
}) { }) {
const tableStyle = buildFontStyle(font); const tableStyle = buildFontStyle(font);
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
);
const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
readJsonFromStorage<Record<number, string>>(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 ( return (
<div className="space-y-4 print:space-y-2"> <div className="space-y-4 print:space-y-2">
<div className="flex items-center justify-between gap-3 flex-wrap print:hidden"> <div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
@@ -529,15 +672,22 @@ function ScheduleSection({
> >
{t("executiveMeetings.scheduleHeading")} {t("executiveMeetings.scheduleHeading")}
</h2> </h2>
<label className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span> <ColumnsCustomizer
<input columns={columns}
type="date" onChange={setColumns}
value={date} t={t}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/> />
</label> <label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
</div>
</div> </div>
<div className="hidden print:block text-center mb-2"> <div className="hidden print:block text-center mb-2">
@@ -546,53 +696,75 @@ function ScheduleSection({
</div> </div>
<div <div
className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-hidden shadow-sm print:shadow-none print:border-black" className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
id="executive-schedule-printable" id="executive-schedule-printable"
> >
<table <table
className="w-full border-collapse" className="border-collapse text-sm sm:text-[15px]"
dir={isRtl ? "rtl" : "ltr"} dir={isRtl ? "rtl" : "ltr"}
style={tableStyle} style={{ width: "100%", tableLayout: "fixed", ...tableStyle }}
> >
<colgroup> <colgroup>
<col className="w-12" /> {visibleColumns.map((c) => (
<col style={{ width: "22%" }} /> <col key={c.id} style={{ width: c.width }} />
<col /> ))}
<col className="w-28" />
</colgroup> </colgroup>
<thead> <thead>
<tr className="bg-[#0B1E3F] text-white"> <tr className="bg-[#0B1E3F] text-white">
<th className="border border-[#0B1E3F] px-2 py-2 text-center font-semibold"> {visibleColumns.map((c, idx) => {
{t("executiveMeetings.col.number")} const isLast = idx === visibleColumns.length - 1;
</th> const align =
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold"> c.id === "number" || c.id === "time" ? "text-center" : "text-start";
{t("executiveMeetings.col.meeting")} return (
</th> <th
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold"> key={c.id}
{t("executiveMeetings.col.attendees")} className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
</th> style={{ width: c.width }}
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold"> data-testid={`em-col-header-${c.id}`}
{t("executiveMeetings.col.time")} >
</th> {t(`executiveMeetings.col.${c.id}`)}
{!isLast && (
<ResizeHandle
isRtl={isRtl}
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
/>
)}
</th>
);
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={4} className="py-10 text-center text-muted-foreground"> <td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("common.loading")} {t("common.loading")}
</td> </td>
</tr> </tr>
)} )}
{!isLoading && meetings.length === 0 && ( {!isLoading && meetings.length === 0 && (
<tr> <tr>
<td colSpan={4} className="py-10 text-center text-muted-foreground"> <td
colSpan={visibleColumns.length}
className="py-10 text-center text-muted-foreground"
>
{t("executiveMeetings.noMeetings")} {t("executiveMeetings.noMeetings")}
</td> </td>
</tr> </tr>
)} )}
{meetings.map((m) => ( {meetings.map((m) => (
<MeetingRow key={m.id} meeting={m} isRtl={isRtl} t={t} /> <MeetingRow
key={m.id}
meeting={m}
isRtl={isRtl}
t={t}
visibleColumns={visibleColumns}
rowColorKey={rowColors[m.id] ?? "default"}
onPickRowColor={(key) => setRowColor(m.id, key)}
/>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -601,25 +773,330 @@ function ScheduleSection({
); );
} }
function ResizeHandle({
isRtl,
onResize,
}: {
isRtl: boolean;
onResize: (delta: number) => void;
}) {
const startXRef = useRef<number | null>(null);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLDivElement).setPointerCapture(e.pointerId);
startXRef.current = e.clientX;
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
role="separator"
aria-orientation="vertical"
className={`absolute top-0 ${sideClass} h-full w-1.5 cursor-col-resize hover:bg-white/40 active:bg-white/60`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
data-testid="em-customize-columns-trigger"
>
<Sliders className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.customize.title")}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="end">
<div className="text-sm font-semibold text-[#0B1E3F] mb-2">
{t("executiveMeetings.customize.title")}
</div>
<p className="text-[11px] text-muted-foreground mb-3 leading-relaxed">
{t("executiveMeetings.customize.hint")}
</p>
<ul className="space-y-1.5">
{columns.map((c, idx) => (
<li
key={c.id}
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
data-testid={`em-customize-row-${c.id}`}
>
<Checkbox
id={`em-col-${c.id}`}
checked={c.visible}
onCheckedChange={(v) => toggle(c.id, !!v)}
data-testid={`em-customize-toggle-${c.id}`}
/>
<label
htmlFor={`em-col-${c.id}`}
className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
>
{t(`executiveMeetings.col.${c.id}`)}
</label>
{!c.visible ? (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
)}
<button
type="button"
className="p-1 rounded hover:bg-white disabled:opacity-30"
onClick={() => move(idx, -1)}
disabled={idx === 0}
aria-label={t("executiveMeetings.customize.moveUp")}
data-testid={`em-customize-up-${c.id}`}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="p-1 rounded hover:bg-white disabled:opacity-30"
onClick={() => move(idx, 1)}
disabled={idx === columns.length - 1}
aria-label={t("executiveMeetings.customize.moveDown")}
data-testid={`em-customize-down-${c.id}`}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
<div className="mt-3 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs"
onClick={reset}
data-testid="em-customize-reset"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("executiveMeetings.customize.reset")}
</Button>
<span className="text-[11px] text-muted-foreground">
{t("executiveMeetings.customize.dragHint")}
</span>
</div>
</PopoverContent>
</Popover>
);
}
function RowColorPicker({
current,
onPick,
t,
}: {
current: string;
onPick: (key: string) => void;
t: (k: string) => string;
}) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="absolute top-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/70 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
aria-label={t("executiveMeetings.rowColor.label")}
data-testid="em-row-color-trigger"
>
<Palette className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-2" align="end">
<div className="text-[11px] text-muted-foreground mb-2">
{t("executiveMeetings.rowColor.label")}
</div>
<div className="flex items-center gap-1.5">
{ROW_COLOR_OPTIONS.map((opt) => {
const selected = current === opt.key;
const isDefault = opt.key === "default";
return (
<button
key={opt.key}
type="button"
onClick={() => onPick(opt.key)}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{
background: isDefault
? "repeating-linear-gradient(45deg,#fff,#fff 4px,#e5e7eb 4px,#e5e7eb 8px)"
: opt.bg,
}}
aria-label={opt.label}
data-testid={`em-row-color-${opt.key}`}
/>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
function MeetingRow({ function MeetingRow({
meeting, meeting,
isRtl, isRtl,
t, t,
visibleColumns,
rowColorKey,
onPickRowColor,
}: { }: {
meeting: Meeting; meeting: Meeting;
isRtl: boolean; isRtl: boolean;
t: (k: string) => string; t: (k: string) => string;
visibleColumns: ColumnSetting[];
rowColorKey: string;
onPickRowColor: (key: string) => void;
}) { }) {
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr; const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
const isCancelled = meeting.status === "cancelled"; const isCancelled = meeting.status === "cancelled";
const highlight = meeting.isHighlighted === 1; const highlight = meeting.isHighlighted === 1;
const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
const rowBg = rowColor?.bg || "";
const numberCellCls = const numberCellCls =
"border border-gray-300 px-2 py-2 text-center align-middle font-semibold " + "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
(highlight || isCancelled (highlight || isCancelled
? "bg-red-600 text-white" ? "bg-red-600 text-white"
: "bg-white text-[#0B1E3F]"); : "bg-white text-[#0B1E3F]");
const renderCell = (col: ColumnSetting): ReactNode => {
switch (col.id) {
case "number":
return (
<td
key="number"
className={`relative group ${numberCellCls}`}
style={{ width: col.width, overflow: "hidden" }}
>
{meeting.dailyNumber}
<RowColorPicker current={rowColorKey} onPick={onPickRowColor} t={t} />
</td>
);
case "meeting":
return (
<td
key="meeting"
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F]"
style={{ width: col.width, overflow: "hidden" }}
>
<div className="font-medium leading-snug break-words">{title}</div>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
{meeting.location}
</div>
)}
</td>
);
case "attendees":
return (
<td
key="attendees"
className="border border-gray-300 px-3 py-3 align-middle"
style={{ width: col.width, overflow: "hidden" }}
>
<AttendeesCell meeting={meeting} t={t} />
</td>
);
case "time":
return (
<td
key="time"
className="border border-gray-300 px-3 py-3 align-middle text-center"
style={{ width: col.width, overflow: "hidden" }}
>
<div className="font-mono text-[#0B1E3F] leading-tight whitespace-nowrap">
<div>{formatTime(meeting.startTime)}</div>
<div className="text-muted-foreground text-xs"></div>
<div>{formatTime(meeting.endTime)}</div>
</div>
</td>
);
}
};
return (
<tr
className="group"
style={rowBg ? { backgroundColor: rowBg } : { backgroundColor: "white" }}
data-testid={`em-row-${meeting.id}`}
>
{visibleColumns.map((c) => renderCell(c))}
</tr>
);
}
function AttendeesCell({
meeting,
t,
}: {
meeting: Meeting;
t: (k: string) => string;
}) {
const virtual = meeting.attendees.filter((a) => a.attendanceType === "virtual"); const virtual = meeting.attendees.filter((a) => a.attendanceType === "virtual");
const internal = meeting.attendees.filter((a) => a.attendanceType === "internal"); const internal = meeting.attendees.filter((a) => a.attendanceType === "internal");
const external = meeting.attendees.filter((a) => a.attendanceType === "external"); const external = meeting.attendees.filter((a) => a.attendanceType === "external");
@@ -633,62 +1110,45 @@ function MeetingRow({
other: t("executiveMeetings.virtual"), other: t("executiveMeetings.virtual"),
}; };
return ( if (hasSplit) {
<tr className="bg-white"> return (
<td className={numberCellCls}>{meeting.dailyNumber}</td> <div className="space-y-2">
{virtual.length > 0 && (
<td className="border border-gray-300 px-3 py-2 align-middle text-center text-[#0B1E3F]">
<div className="font-medium leading-snug">{title}</div>
{meeting.location && (
<div className="text-[11px] text-gray-500 mt-0.5">{meeting.location}</div>
)}
</td>
<td className="border border-gray-300 px-3 py-2 align-middle">
{hasSplit ? (
<div className="space-y-1.5">
{virtual.length > 0 && (
<AttendeeGroup
label={
meeting.platform !== "none"
? `${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`
: t("executiveMeetings.virtualAttendance")
}
items={virtual}
/>
)}
{internal.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
/>
)}
{external.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
/>
)}
</div>
) : meeting.platform !== "none" && virtual.length > 0 ? (
<AttendeeGroup <AttendeeGroup
label={`${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`} label={
meeting.platform !== "none"
? `${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`
: t("executiveMeetings.virtualAttendance")
}
items={virtual} items={virtual}
/> />
) : (
<AttendeeFlow items={meeting.attendees} />
)} )}
</td> {internal.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
/>
)}
{external.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
/>
)}
</div>
);
}
<td className="border border-gray-300 px-3 py-2 align-middle text-center"> if (meeting.platform !== "none" && virtual.length > 0) {
<div className="font-mono text-[#0B1E3F] leading-tight whitespace-nowrap"> return (
<div>{formatTime(meeting.startTime)}</div> <AttendeeGroup
<div className="text-gray-500 text-xs"></div> label={`${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`}
<div>{formatTime(meeting.endTime)}</div> items={virtual}
</div> />
</td> );
</tr> }
);
return <AttendeeFlow items={meeting.attendees} />;
} }
function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) { function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+2 -2
View File
@@ -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 { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -22,7 +22,7 @@ export const appsTable = pgTable("apps", {
export const appPermissionsTable = pgTable("app_permissions", { export const appPermissionsTable = pgTable("app_permissions", {
appId: integer("app_id").notNull().references(() => appsTable.id, { onDelete: "cascade" }), appId: integer("app_id").notNull().references(() => appsTable.id, { onDelete: "cascade" }),
permissionId: integer("permission_id").notNull(), 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 const insertAppSchema = createInsertSchema(appsTable).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertApp = z.infer<typeof insertAppSchema>; export type InsertApp = z.infer<typeof insertAppSchema>;