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:
@@ -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 |
@@ -629,6 +629,17 @@
|
||||
"attendees": "الحضور",
|
||||
"time": "الوقت"
|
||||
},
|
||||
"customize": {
|
||||
"title": "تخصيص الجدول",
|
||||
"hint": "أظهر أو أخفِ الأعمدة، أعد ترتيبها، أو أعد التعيين الافتراضي.",
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل",
|
||||
"reset": "إعادة التعيين",
|
||||
"dragHint": "اسحب حد العمود لتغيير عرضه"
|
||||
},
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
},
|
||||
"nav": {
|
||||
"schedule": "جدول الاجتماعات اليومي",
|
||||
"manage": "إدارة الاجتماعات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T>(
|
||||
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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -520,6 +621,48 @@ function ScheduleSection({
|
||||
font: FontPrefs;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-4 print:space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
|
||||
@@ -529,15 +672,22 @@ function ScheduleSection({
|
||||
>
|
||||
{t("executiveMeetings.scheduleHeading")}
|
||||
</h2>
|
||||
<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"
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ColumnsCustomizer
|
||||
columns={columns}
|
||||
onChange={setColumns}
|
||||
t={t}
|
||||
/>
|
||||
</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 className="hidden print:block text-center mb-2">
|
||||
@@ -546,53 +696,75 @@ function ScheduleSection({
|
||||
</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"
|
||||
>
|
||||
<table
|
||||
className="w-full border-collapse"
|
||||
className="border-collapse text-sm sm:text-[15px]"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
style={tableStyle}
|
||||
style={{ width: "100%", tableLayout: "fixed", ...tableStyle }}
|
||||
>
|
||||
<colgroup>
|
||||
<col className="w-12" />
|
||||
<col style={{ width: "22%" }} />
|
||||
<col />
|
||||
<col className="w-28" />
|
||||
{visibleColumns.map((c) => (
|
||||
<col key={c.id} style={{ width: c.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-[#0B1E3F] text-white">
|
||||
<th className="border border-[#0B1E3F] px-2 py-2 text-center font-semibold">
|
||||
{t("executiveMeetings.col.number")}
|
||||
</th>
|
||||
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold">
|
||||
{t("executiveMeetings.col.meeting")}
|
||||
</th>
|
||||
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold">
|
||||
{t("executiveMeetings.col.attendees")}
|
||||
</th>
|
||||
<th className="border border-[#0B1E3F] px-3 py-2 text-center font-semibold">
|
||||
{t("executiveMeetings.col.time")}
|
||||
</th>
|
||||
{visibleColumns.map((c, idx) => {
|
||||
const isLast = idx === visibleColumns.length - 1;
|
||||
const align =
|
||||
c.id === "number" || c.id === "time" ? "text-center" : "text-start";
|
||||
return (
|
||||
<th
|
||||
key={c.id}
|
||||
className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
|
||||
style={{ width: c.width }}
|
||||
data-testid={`em-col-header-${c.id}`}
|
||||
>
|
||||
{t(`executiveMeetings.col.${c.id}`)}
|
||||
{!isLast && (
|
||||
<ResizeHandle
|
||||
isRtl={isRtl}
|
||||
onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<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")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && meetings.length === 0 && (
|
||||
<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")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{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>
|
||||
</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({
|
||||
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 (
|
||||
<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 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 (
|
||||
<tr className="bg-white">
|
||||
<td className={numberCellCls}>{meeting.dailyNumber}</td>
|
||||
|
||||
<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 ? (
|
||||
if (hasSplit) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{virtual.length > 0 && (
|
||||
<AttendeeGroup
|
||||
label={`${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`}
|
||||
label={
|
||||
meeting.platform !== "none"
|
||||
? `${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`
|
||||
: t("executiveMeetings.virtualAttendance")
|
||||
}
|
||||
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">
|
||||
<div className="font-mono text-[#0B1E3F] leading-tight whitespace-nowrap">
|
||||
<div>{formatTime(meeting.startTime)}</div>
|
||||
<div className="text-gray-500 text-xs">—</div>
|
||||
<div>{formatTime(meeting.endTime)}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
if (meeting.platform !== "none" && virtual.length > 0) {
|
||||
return (
|
||||
<AttendeeGroup
|
||||
label={`${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`}
|
||||
items={virtual}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AttendeeFlow items={meeting.attendees} />;
|
||||
}
|
||||
|
||||
function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -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<typeof insertAppSchema>;
|
||||
|
||||
Reference in New Issue
Block a user