diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx
index 4b72274..8eac8c4 100644
--- a/src/components/generic/AdminStickyAnnouncementBar.jsx
+++ b/src/components/generic/AdminStickyAnnouncementBar.jsx
@@ -1,116 +1,163 @@
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
-import {
- AlertDialog, AlertDialogAction, AlertDialogCancel,
- AlertDialogContent, AlertDialogDescription,
- AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
-import { NotificationIcon, getTypeAccent, resolveStickyStyle } from "@/components/generic/notificationDisplay";
+import { getTierColor, getContrastText } from "@/utils/tierColors";
+import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
+
+const ROTATE_INTERVAL_MS = 6000;
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
// explicit link_url from the "On Open" section — the type-based fallback
// resolver in notificationDisplay.jsx points at client-only routes
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
+function resolveClickAction(stickyAnnouncement) {
+ const linkUrl = stickyAnnouncement.data?.linkUrl || null;
+ if (!linkUrl) return null;
+ return {
+ label: stickyAnnouncement.data?.linkLabel || "Open Link",
+ go: (navigate) => (linkUrl.startsWith("/")
+ ? navigate(linkUrl)
+ : window.open(linkUrl, "_blank", "noopener,noreferrer")),
+ };
+}
+
export default function AdminStickyAnnouncementBar() {
const navigate = useNavigate();
- const { stickyAnnouncement, markSeen } = useAdminNotifications();
+ const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications();
+ const [activeIndex, setActiveIndex] = useState(0);
const [detailsOpen, setDetailsOpen] = useState(false);
+ const count = stickyAnnouncements.length;
+ // Derived rather than clamped via effect — safe the instant the array
+ // shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
+ const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
+
+ useEffect(() => {
+ if (count <= 1) return;
+ const id = setInterval(() => {
+ setActiveIndex((i) => (i + 1) % count);
+ }, ROTATE_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, [count]);
+
+ const current = stickyAnnouncements[safeIndex];
+
const onDismiss = useCallback(async () => {
- if (!stickyAnnouncement) return;
- await markSeen(stickyAnnouncement.notification_id);
- }, [stickyAnnouncement, markSeen]);
+ if (!current) return;
+ await markSeen(current.notification_id);
+ }, [current, markSeen]);
- // Opening the dialog must NOT mark it seen — markSeen clears
- // stickyAnnouncement, which would unmount this component (dialog included)
- // before it ever shows. Only the X button dismisses/marks seen.
+ // Opening the dialog must NOT mark it seen — markSeen removes the row from
+ // stickyAnnouncements, which would unmount this component (dialog included)
+ // before it ever shows.
const onClickBanner = useCallback(() => {
- if (!stickyAnnouncement) return;
+ if (!current) return;
setDetailsOpen(true);
- }, [stickyAnnouncement]);
+ }, [current]);
- if (!stickyAnnouncement) return null;
+ // Closing the details dialog (X, Escape, overlay click — any reason)
+ // dismisses whichever announcement was being viewed at the time. This is
+ // the only dismiss path when multiple are active (no per-item X on the bar
+ // itself — see the count > 1 branch below).
+ const onDialogOpenChange = useCallback((open) => {
+ setDetailsOpen(open);
+ if (!open) void onDismiss();
+ }, [onDismiss]);
- const accentClass = getTypeAccent(stickyAnnouncement.type);
- const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
- const linkUrl = stickyAnnouncement.data?.linkUrl || null;
+ if (!current) return null;
- const openLink = () => {
- if (!linkUrl) return;
- if (linkUrl.startsWith("/")) navigate(linkUrl);
- else window.open(linkUrl, "_blank", "noopener,noreferrer");
- };
+ const swatch = getTierColor(current.color || "indigo").swatch;
+ const textColor = getContrastText(swatch, current.color || "indigo");
+ const clickAction = resolveClickAction(current);
return (
<>
-
+
-
-
-
-
+
+
+ {current.title || "Announcement"}
+
-
-
- {stickyAnnouncement.title || "Announcement"}
-
-
- {stickyAnnouncement.message || ""}
-
-
+ {clickAction && (
+
+ )}
-
+ {count > 1 && (
+
+ {stickyAnnouncements.map((a, i) => (
+
+ )}
+
+ {/* Dismiss-X only makes sense for a single active announcement —
+ with multiple, the dialog's own close button (shadcn Dialog)
+ is the way to close/step away, no per-item dismiss from the bar. */}
+ {count <= 1 && (
+
- );
-}
\ No newline at end of file
diff --git a/src/components/generic/BroadcastTargetPicker.jsx b/src/components/generic/BroadcastTargetPicker.jsx
index b5086b1..f6bc9f9 100644
--- a/src/components/generic/BroadcastTargetPicker.jsx
+++ b/src/components/generic/BroadcastTargetPicker.jsx
@@ -32,7 +32,7 @@ const TARGET_CONFIGS = {
},
};
-export function BroadcastTargetPicker({ targetType, value, onChange }) {
+export function BroadcastTargetPicker({ targetType, value, onChange, onLabelResolved }) {
const config = TARGET_CONFIGS[targetType];
const [items, setItems] = useState([]);
@@ -57,9 +57,17 @@ export function BroadcastTargetPicker({ targetType, value, onChange }) {
return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q));
}, [items, query, config]);
- if (!config) return null;
+ const selected = config ? items.find((item) => String(item[config.idKey]) === String(value)) : undefined;
- const selected = items.find((item) => String(item[config.idKey]) === String(value));
+ // Lets the parent (Review step summaries, etc.) show the resolved name
+ // instead of just the raw id — fires whenever the matched item changes,
+ // including on initial load once the fetched list resolves `value`.
+ useEffect(() => {
+ onLabelResolved?.(selected ? selected[config.labelKey] : null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selected]);
+
+ if (!config) return null;
return (
{ setOpen(v); if (!v) setQuery(""); }}>
diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx
index 17f4948..571ae27 100644
--- a/src/components/generic/Sheet/FilterSheet.jsx
+++ b/src/components/generic/Sheet/FilterSheet.jsx
@@ -15,14 +15,22 @@ const FIELD_DISPLAY_MAP = {
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ────────────────────────────────────────────────────────
+// Audit fields (createdBy/updatedBy/deletedBy) come back from the field-values
+// API as { value: user_id, label: full_name } — filtering has to select on the
+// id, but the sheet should still display the name. Every other field type
+// still hands this plain primitives, which pass through unchanged.
+const itemValue = (item) => (item && typeof item === "object" && "value" in item) ? item.value : item;
+const itemLabel = (item) => (item && typeof item === "object" && "label" in item) ? item.label : item;
+
const formatFilterItem = (item, field, type, fmtDate) => {
+ const label = itemLabel(item);
if (FIELD_DISPLAY_MAP[field]) {
- return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
+ return FIELD_DISPLAY_MAP[field][String(label)] ?? label;
}
- if (type === "date" && item) {
- return fmtDate(item);
+ if (type === "date" && label) {
+ return fmtDate(label);
}
- return item;
+ return label;
};
// ─── Reusable empty state ─────────────────────────────────────────────────────
@@ -37,17 +45,20 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check
const { fmtDate } = useDateFormat();
if (items.length === 0) return ;
- return items.map((item) => (
-
- ));
+ return items.map((item) => {
+ const value = itemValue(item);
+ return (
+
+ );
+ });
};
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx
index 09e9a5e..e2950aa 100644
--- a/src/components/generic/StickyAnnouncementBar.jsx
+++ b/src/components/generic/StickyAnnouncementBar.jsx
@@ -1,22 +1,22 @@
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
-import {
- AlertDialog, AlertDialogAction, AlertDialogCancel,
- AlertDialogContent, AlertDialogDescription,
- AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
-import { NotificationIcon, getTypeAccent, resolveNotificationLink, resolveStickyStyle } from "@/components/generic/notificationDisplay";
+import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
+import { getTierColor, getContrastText } from "@/utils/tierColors";
+import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
-// Explicit link_url (from the admin "On Open" section) always wins. Falls back
-// to the type-based resolver for broadcasts sent before that field existed.
+const ROTATE_INTERVAL_MS = 6000;
+
+// Explicit link_url (from the admin "On Open" section) always wins, using the
+// admin-authored button label when set. Falls back to the type-based resolver
+// for broadcasts sent before that field existed.
function resolveClickAction(stickyAnnouncement) {
const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
if (explicitUrl) {
return {
- label: "Open Link",
+ label: stickyAnnouncement.data?.linkLabel || "Open Link",
go: (navigate) => (explicitUrl.startsWith("/")
? navigate(explicitUrl)
: window.open(explicitUrl, "_blank", "noopener,noreferrer")),
@@ -27,99 +27,140 @@ function resolveClickAction(stickyAnnouncement) {
export default function StickyAnnouncementBar() {
const navigate = useNavigate();
- const { stickyAnnouncement, markSeen } = useClientNotifications();
+ const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications();
+ const [activeIndex, setActiveIndex] = useState(0);
const [detailsOpen, setDetailsOpen] = useState(false);
+ const count = stickyAnnouncements.length;
+ // Derived rather than clamped via effect — safe the instant the array
+ // shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
+ const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
+
+ // Auto-rotate through active announcements while more than one is live.
+ useEffect(() => {
+ if (count <= 1) return;
+ const id = setInterval(() => {
+ setActiveIndex((i) => (i + 1) % count);
+ }, ROTATE_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, [count]);
+
+ const current = stickyAnnouncements[safeIndex];
+
const onDismiss = useCallback(async () => {
- if (!stickyAnnouncement) return;
- await markSeen(stickyAnnouncement.notification_id);
- }, [stickyAnnouncement, markSeen]);
+ if (!current) return;
+ await markSeen(current.notification_id);
+ }, [current, markSeen]);
- // Opening the dialog must NOT mark it seen — markSeen clears
- // stickyAnnouncement, which would unmount this component (dialog included)
- // before it ever shows. Only the X button dismisses/marks seen.
+ // Opening the dialog must NOT mark it seen — markSeen removes the row from
+ // stickyAnnouncements, which would unmount this component (dialog included)
+ // before it ever shows.
const onClickBanner = useCallback(() => {
- if (!stickyAnnouncement) return;
+ if (!current) return;
setDetailsOpen(true);
- }, [stickyAnnouncement]);
+ }, [current]);
- if (!stickyAnnouncement) return null;
+ // Closing the details dialog (X, Escape, overlay click — any reason)
+ // dismisses whichever announcement was being viewed at the time. This is
+ // the only dismiss path when multiple are active (no per-item X on the bar
+ // itself — see the count > 1 branch below).
+ const onDialogOpenChange = useCallback((open) => {
+ setDetailsOpen(open);
+ if (!open) void onDismiss();
+ }, [onDismiss]);
- const accentClass = getTypeAccent(stickyAnnouncement.type);
- const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
- const clickAction = resolveClickAction(stickyAnnouncement);
+ if (!current) return null;
+
+ const swatch = getTierColor(current.color || "indigo").swatch;
+ const textColor = getContrastText(swatch, current.color || "indigo");
+ const clickAction = resolveClickAction(current);
return (
<>
-
+
-
-
-
-
+
+
+ {current.title || "Announcement"}
+
-
-
- {stickyAnnouncement.title || "Announcement"}
-
-
- {stickyAnnouncement.message || ""}
-
-
+ {clickAction && (
+
+ )}
-
+ {count > 1 && (
+
+ {stickyAnnouncements.map((a, i) => (
+
+ )}
+
+ {/* Dismiss-X only makes sense for a single active announcement —
+ with multiple, the dialog's own close button (shadcn Dialog)
+ is the way to close/step away, no per-item dismiss from the bar. */}
+ {count <= 1 && (
+
- {/* Full-content view — plain text info, or with an Open Link action
- when the announcement was created with a link (see AddNotificationBroadcast's
- "On Open" section). */}
-
-
-
-
-
- Badges and milestones learners can earn across the platform.
-
-
-
-
-
-
-
-
- System achievements are auto-granted by platform events (registration, course completion, etc.)
- and cannot be deleted or have their key/type changed — everything else stays editable.
-
- {isAdd ? "Define a new badge or milestone learners can earn." : "Update this achievement's details."}
-
-
-
-
- {isSystem && (
-
-
-
- This is a system achievement — it's auto-granted by platform code that references
- its key directly, so the key and type are locked. Label, description, icon, trigger and active state are still editable.
-