Empty text",
- }}
- />
- {!vidLeft && }
-
+ {vidLeft &&
}
+
Empty text",
+ }}
+ />
+ {!vidLeft && }
+
);
}
\ No newline at end of file
diff --git a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx
index 5103bdc..30c3b2b 100644
--- a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx
+++ b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx
@@ -49,7 +49,7 @@ import { cn } from "@/lib/utils";
* // With custom click handler
*
, onClick: (e, navigate) => navigate(-1) },
+ * { label: "Home", icon:
, onClick: (e, navigate) => navigate("/admin") },
* { label: "Settings", to: `/admin/${adminId}/settings` },
* { label: "Profile" },
* ]}
diff --git a/src/components/generic/DatePickerButton.jsx b/src/components/generic/DatePickerButton.jsx
new file mode 100644
index 0000000..39f1e1a
--- /dev/null
+++ b/src/components/generic/DatePickerButton.jsx
@@ -0,0 +1,59 @@
+import { useState } from "react";
+import { CalendarIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Calendar } from "@/components/ui/calendar";
+import { useDateFormat } from "@/hooks/useDateFormat";
+
+// Single-date picker button used by "From"/"To" range filters (Activity Feed,
+// User Activity tab). Extracted so both pages share one implementation.
+export function DatePickerButton({ value, onChange, placeholder, disabled }) {
+ const [open, setOpen] = useState(false);
+ const { fmtDate } = useDateFormat();
+
+ const label = value ? fmtDate(value) : placeholder;
+
+ return (
+
+
+
+
+ {label}
+
+
+
+ { onChange(d ?? null); setOpen(false); }}
+ disabled={disabled}
+ initialFocus
+ />
+
+ { onChange(new Date()); setOpen(false); }}
+ >
+ Today
+
+ {value && (
+ { onChange(null); setOpen(false); }}
+ >
+ Clear
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/generic/NotificationDetailDialog.jsx b/src/components/generic/NotificationDetailDialog.jsx
index 808bf11..e21d73f 100644
--- a/src/components/generic/NotificationDetailDialog.jsx
+++ b/src/components/generic/NotificationDetailDialog.jsx
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { useDateFormat } from "@/hooks/useDateFormat";
-import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay";
+import { NotificationIcon, getTypeAccent, getTypeButtonClasses, resolveNotificationLink } from "@/components/generic/notificationDisplay";
export default function NotificationDetailDialog({ notification, onOpenChange }) {
const { fmtDateTime } = useDateFormat();
@@ -81,7 +81,12 @@ export default function NotificationDetailDialog({ notification, onOpenChange })
)}
{link && (
-
+
{navigating ? : <>{link.label} >}
)}
diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx
index 571ae27..c4628b1 100644
--- a/src/components/generic/Sheet/FilterSheet.jsx
+++ b/src/components/generic/Sheet/FilterSheet.jsx
@@ -10,6 +10,9 @@ import { Spinner } from "@/components/ui/spinner";
const FIELD_DISPLAY_MAP = {
is_active: { true: "Active", false: "Inactive" },
is_verified: { true: "Verified", false: "Not Verified" },
+ is_banned: { true: "Banned", false: "Not Banned" },
+ is_public: { true: "Public", false: "Private" },
+ is_required: { true: "Yes", false: "No" },
};
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx
index e2950aa..56810c8 100644
--- a/src/components/generic/StickyAnnouncementBar.jsx
+++ b/src/components/generic/StickyAnnouncementBar.jsx
@@ -9,19 +9,10 @@ import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouse
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.
+// resolveNotificationLink already gives explicit link_url (the admin "On
+// Open" section) precedence over the type-based fallbacks, for broadcasts
+// sent before that field existed.
function resolveClickAction(stickyAnnouncement) {
- const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
- if (explicitUrl) {
- return {
- label: stickyAnnouncement.data?.linkLabel || "Open Link",
- go: (navigate) => (explicitUrl.startsWith("/")
- ? navigate(explicitUrl)
- : window.open(explicitUrl, "_blank", "noopener,noreferrer")),
- };
- }
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
}
diff --git a/src/components/generic/Table/ActiveFilterPills.jsx b/src/components/generic/Table/ActiveFilterPills.jsx
index 1aca886..cab087f 100644
--- a/src/components/generic/Table/ActiveFilterPills.jsx
+++ b/src/components/generic/Table/ActiveFilterPills.jsx
@@ -1,5 +1,41 @@
import { Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { BOOLEAN_FIELD_LABELS } from "@/utils/table.util";
+
+// Resolves a filter's raw value(s) into what should actually be shown on the
+// pill. Handles every shape ColumnFilter/FilterSheet can produce:
+// - date range: { from, to }
+// - boolean columns: "true" / "false" (or an array of those)
+// - id-backed columns (e.g. createdBy/updatedBy): raw ids — resolved via
+// fieldOptions[field], the { value, label } picklist DataTable cached
+// from the last time that column's filter sheet was opened
+// - everything else (free text, plain enum values): shown as-is
+function resolveFilterDisplay(filter, fieldOptions) {
+ const raw = filter.value;
+
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
+ const { from, to } = raw;
+ if (from && to) return `${from} – ${to}`;
+ if (from) return `From ${from}`;
+ if (to) return `Until ${to}`;
+ return "";
+ }
+
+ const values = Array.isArray(raw) ? raw : [raw];
+ const boolLabels = BOOLEAN_FIELD_LABELS[filter.id];
+ const options = fieldOptions?.[filter.id];
+
+ const labels = values.map((v) => {
+ if (boolLabels) return boolLabels[v === "true" ? 1 : 0];
+ if (Array.isArray(options)) {
+ const match = options.find((o) => String(o?.value ?? o) === String(v));
+ if (match !== undefined) return match?.label ?? match?.value ?? match;
+ }
+ return v;
+ });
+
+ return labels.join(", ");
+}
/**
* ActiveFilterPills
@@ -9,6 +45,8 @@ import { Button } from "@/components/ui/button";
* Props:
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
* @param {Array} attributes - Array of { field, name } for display labels
+ * @param {Object} [fieldOptions] - { [field]: [{ value, label }] } picklist cache,
+ * used to resolve id-backed filters (createdBy, etc.)
* @param {Function} onRemove - (id: string) => void — remove a single filter
* @param {Function} [onClearAll] - () => void — clear all filters
* @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills
@@ -17,6 +55,7 @@ import { Button } from "@/components/ui/button";
export function ActiveFilterPills({
filters = [],
attributes = [],
+ fieldOptions = {},
onRemove,
onClearAll,
showClearButton = false,
@@ -50,7 +89,7 @@ export function ActiveFilterPills({
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full px-2.5 py-0.5"
>
{attr?.name ?? f.id}:
- {String(f.value)}
+ {resolveFilterDisplay(f, fieldOptions)}
onRemove(f.id)}
className="ml-0.5 hover:text-destructive transition-colors"
diff --git a/src/components/generic/Table/DataTable.jsx b/src/components/generic/Table/DataTable.jsx
index deea9fb..c4bc6b9 100644
--- a/src/components/generic/Table/DataTable.jsx
+++ b/src/components/generic/Table/DataTable.jsx
@@ -47,6 +47,11 @@ export default function DataTable({
const [filterState, setFilterState] = useState({
open: false, column: null, attr: null, data: [],
});
+ // Caches the { value, label } picklist fetched per field (e.g. createdBy's
+ // [{value: 56, label: "Obsequio, Russell..."}]) so ActiveFilterPills can
+ // show a name instead of a raw id once a filter is applied — the sheet
+ // itself only holds this data while open.
+ const [fieldOptions, setFieldOptions] = useState({});
const activeFilters = columnFilters.filter((f) => f.value !== "");
@@ -108,6 +113,7 @@ export default function DataTable({
const data = await onFetchFilterData(attr.field);
setFilterState({ open: true, column, attr, data });
+ setFieldOptions((prev) => ({ ...prev, [attr.field]: data }));
};
const table = useReactTable({
@@ -204,6 +210,7 @@ export default function DataTable({
{
setColumnFilters([]);
filtersRef.current = [];
@@ -227,6 +234,7 @@ export default function DataTable({
{
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
diff --git a/src/components/generic/notificationDisplay.jsx b/src/components/generic/notificationDisplay.jsx
index f691b0a..aabaa00 100644
--- a/src/components/generic/notificationDisplay.jsx
+++ b/src/components/generic/notificationDisplay.jsx
@@ -31,6 +31,24 @@ export function getTypeAccent(type) {
return TYPE_ACCENT[type] ?? "bg-muted text-foreground";
}
+// Outline-button variant of TYPE_ACCENT — same per-type color family, tuned
+// for a bordered CTA (e.g. "View task list" / "Open Link") instead of a
+// filled icon badge, so the action button reads as the same type as the
+// notification it belongs to rather than a generic button for every type.
+const TYPE_BUTTON_CLASSES = {
+ achievement: "border-yellow-300 text-yellow-700 hover:bg-yellow-50 dark:border-yellow-800 dark:text-yellow-400 dark:hover:bg-yellow-950/40",
+ course: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
+ milestone: "border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/40",
+ task: "border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950/40",
+ announcement: "border-indigo-300 text-indigo-700 hover:bg-indigo-50 dark:border-indigo-800 dark:text-indigo-400 dark:hover:bg-indigo-950/40",
+ tier_expired: "border-amber-300 text-amber-700 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-400 dark:hover:bg-amber-950/40",
+ assessment: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
+};
+
+export function getTypeButtonClasses(type) {
+ return TYPE_BUTTON_CLASSES[type] ?? "";
+}
+
export function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
@@ -41,6 +59,21 @@ export function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`;
}
+// Admin-authored links only ever get typed as either an internal path
+// ("/course/123") or a bare/https URL — a host typed without a scheme
+// (e.g. "example.com") isn't "/"-prefixed, so it would otherwise fall
+// through to window.open() and resolve as a broken relative path instead of
+// navigating off-site. Prepend https:// whenever no scheme is present.
+export function normalizeExternalUrl(url) {
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // already has a scheme (https:, http:, mailto:, tel:, ...)
+ return `https://${url}`;
+}
+
+export function goToLink(url, navigate) {
+ if (url.startsWith("/")) navigate(url);
+ else window.open(normalizeExternalUrl(url), "_blank", "noopener,noreferrer");
+}
+
// Resolves a course uuid to its numeric course_id — client course pages route by course_id, not uuid.
async function goToCourse(navigate, courseUuid) {
try {
@@ -61,6 +94,15 @@ async function goToCourse(navigate, courseUuid) {
export function resolveNotificationLink(type, data) {
if (!data) return null;
+ // An explicit admin-authored link (the broadcast form's "On Open" section)
+ // always wins over the type-based fallbacks below, same precedence the
+ // sticky banner uses — otherwise a notification with a configured button
+ // silently loses it whenever it's delivered as a regular notification
+ // instead of a sticky one.
+ if (data.linkUrl) {
+ return { label: data.linkLabel || "Open Link", go: (navigate) => goToLink(data.linkUrl, navigate) };
+ }
+
switch (type) {
case "course":
return data.courseUuid
diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx
index 8a85e04..2d4ef48 100644
--- a/src/contexts/AdminAssetsContext.jsx
+++ b/src/contexts/AdminAssetsContext.jsx
@@ -114,7 +114,7 @@ export function AssetsProvider({ children }) {
try {
return await fn();
} catch (err) {
- const message = err?.response?.data?.message ?? "Something went wrong.";
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
toast(message);
return null;
} finally {
diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx
index f29abde..c707634 100644
--- a/src/contexts/AdminLibraryContext.jsx
+++ b/src/contexts/AdminLibraryContext.jsx
@@ -232,7 +232,7 @@ export function LibraryProvider({ children }) {
(field) =>
request(async () => {
const { data } = await api.get(`${UNITS_BASE}/field-values`, { params: { field } });
- return data;
+ return data?.data ?? [];
}),
[request],
);
@@ -411,7 +411,7 @@ export function LibraryProvider({ children }) {
(field) =>
request(async () => {
const { data } = await api.get(`${LESSONS_BASE}/field-values`, { params: { field } });
- return data;
+ return data?.data ?? [];
}),
[request],
);
diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx
index b441e3d..b43bd16 100644
--- a/src/contexts/AdminUserContext.jsx
+++ b/src/contexts/AdminUserContext.jsx
@@ -268,10 +268,10 @@ export const UserProvider = ({ children }) => {
// ─── GET /api/admin/users/:id/activity ────────────────────────────────────
const fetchUserActivity = useCallback(
- async (userId, { page = 1, limit = 20, action = undefined } = {}) => {
+ async (userId, { page = 1, limit = 20, action = undefined, from = undefined, to = undefined } = {}) => {
setActivityLoading(true);
try {
- const res = await api.get(`${BASE}/users/${userId}/activity`, { params: { page, limit, action } });
+ const res = await api.get(`${BASE}/users/${userId}/activity`, { params: { page, limit, action, from, to } });
const d = res.data?.data;
setActivity(d?.activities ?? []);
setActivityPagination({
diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx
index d5ac3e1..b32405a 100644
--- a/src/contexts/AdminUserGroupContext.jsx
+++ b/src/contexts/AdminUserGroupContext.jsx
@@ -110,7 +110,7 @@ export function UserGroupProvider({ children }) {
(field) =>
request(async () => {
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
- return res.data;
+ return res.data?.data;
}),
[request]
);
diff --git a/src/contexts/StaffGroupContext.jsx b/src/contexts/StaffGroupContext.jsx
index 56536da..1553035 100644
--- a/src/contexts/StaffGroupContext.jsx
+++ b/src/contexts/StaffGroupContext.jsx
@@ -150,7 +150,7 @@ export const StaffGroupProvider = ({ children }) => {
},
}
);
- return res.data;
+ return res.data?.data;
}),
[membersRequest]
);
diff --git a/src/contexts/StaffTaskContext.jsx b/src/contexts/StaffTaskContext.jsx
index aaebb4b..4c73129 100644
--- a/src/contexts/StaffTaskContext.jsx
+++ b/src/contexts/StaffTaskContext.jsx
@@ -110,7 +110,7 @@ export const StaffTaskProvider = ({ children }) => {
const res = await api.get(`${BASE}/task-lists/field-values`, {
params: { field, ...buildParams(args) },
});
- return res.data;
+ return res.data?.data;
}),
[request]
);
@@ -257,7 +257,7 @@ export const StaffTaskProvider = ({ children }) => {
const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/field-values`, {
params: { field, ...buildParams(args) },
});
- return res.data;
+ return res.data?.data;
}),
[request]
);
diff --git a/src/data/advertisement.data.js b/src/data/advertisement.data.js
index 7b36cde..3ceab27 100644
--- a/src/data/advertisement.data.js
+++ b/src/data/advertisement.data.js
@@ -37,5 +37,13 @@ export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
ADVERTISEMENT_STATUSES.map((s) => [s.value, s])
);
+// Filterable subset for the main (non-archived) list. "expired" is excluded —
+// the expire_advertisements cron sweeps any ad past its end_date into the
+// Archived table within a minute, so this list will practically never hold
+// one; offering it as a filter here just returns an empty result.
+export const ADVERTISEMENT_FILTERABLE_STATUSES = ADVERTISEMENT_STATUSES.filter(
+ (s) => s.value !== "expired"
+);
+
// Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2))
export const MAX_CTAS = 2;
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index 15eb6d7..8a1633d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -15,6 +15,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
+@import "./styles/typeset.css";
@custom-variant dark (&:is(.dark *));
diff --git a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx
index 744c412..47838af 100644
--- a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx
+++ b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { toast } from "sonner";
+import api from "@/utils/api.util";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import DataTable from "@/components/generic/Table/DataTable";
@@ -14,6 +16,7 @@ import { buildRowActions } from "../../config/advertisements/arc
import { buildSelectionActions } from "../../config/advertisements/archive/selection.config";
import { getTimestamp } from "@/utils/timestamp.util";
+import { useDateFormat } from "@/hooks/useDateFormat";
export default function ArchivedAdvertisementsTable() {
const [restoreTarget, setRestoreTarget] = useState(null);
@@ -29,6 +32,7 @@ export default function ArchivedAdvertisementsTable() {
});
const navigate = useNavigate();
+ const { fmtDateTime } = useDateFormat();
const {
advertisements, attributes, pagination, setPagination, loading,
@@ -57,6 +61,22 @@ export default function ArchivedAdvertisementsTable() {
onDelete: (row) => setDeleteTarget(row),
});
+ // "Remove Expired" — the expireAdvertisements cron already auto-archives
+ // (soft-deletes) any ad past its end_date and stamps status: 'expired' on
+ // it before doing so, so this only ever targets naturally-expired ads,
+ // never ones an admin manually archived while still active/scheduled.
+ const handleRemoveExpired = async () => {
+ const { data } = await api.get("/admin/advertisements/archived", {
+ params: { page: 1, limit: 1000, filters: JSON.stringify([{ id: "status", value: "expired" }]) },
+ });
+ const ids = (data?.data?.data ?? []).map((a) => a.advertisement_id);
+ if (!ids.length) {
+ toast("No expired advertisements to remove.");
+ return;
+ }
+ setDeleteIds(ids);
+ };
+
const toolbarActions = buildToolbarActions({
fetchAdvertisements: fetchArchivedAdvertisements,
pagination,
@@ -65,6 +85,7 @@ export default function ArchivedAdvertisementsTable() {
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
+ onRemoveExpired: handleRemoveExpired,
});
const selectionActions = buildSelectionActions({
@@ -77,8 +98,8 @@ export default function ArchivedAdvertisementsTable() {
});
const columns = useMemo(
- () => buildDataColumns(attributes, rowActions),
- [attributes]
+ () => buildDataColumns(attributes, rowActions, fmtDateTime),
+ [attributes, fmtDateTime]
);
const handleRestoreSuccess = () => {
diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx
index 2138031..ff924ef 100644
--- a/src/modules/admin/components/courses/LessonsPreview.jsx
+++ b/src/modules/admin/components/courses/LessonsPreview.jsx
@@ -2,7 +2,6 @@
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
-import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
@@ -159,7 +158,7 @@ export function PreviewBlock({ block }) {
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
-export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
+export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true }) {
return (
300}
@@ -176,8 +175,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
diff --git a/src/modules/admin/config/advertisements/archive/columns.config.jsx b/src/modules/admin/config/advertisements/archive/columns.config.jsx
index ad04146..dd5be08 100644
--- a/src/modules/admin/config/advertisements/archive/columns.config.jsx
+++ b/src/modules/admin/config/advertisements/archive/columns.config.jsx
@@ -9,18 +9,28 @@ export const columnPinning = {
left: [],
};
-const cellOverrides = {};
+const DATE_TIME_FIELDS = ["start_date", "end_date", "createdAt", "updatedAt", "deletedAt"];
/**
* Builds the full column array for the Archived Advertisements table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
+ * @param {Function} [fmtDateTime] - date+time formatter (from useDateFormat), falls back to a plain locale string
* @returns {Array} TanStack column definitions
*/
-export function buildDataColumns(attributes, rowActions) {
+export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—") {
const visibleAttributes = attributes.filter((a) => !a.hidden);
+ // Ad scheduling is time-sensitive — the default date-only cell renderer
+ // drops the time-of-day, so these fields get an explicit date+time cell.
+ const cellOverrides = Object.fromEntries(
+ DATE_TIME_FIELDS.map((field) => [
+ field,
+ (info) =>
{fmtDateTime(info.getValue())} ,
+ ])
+ );
+
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
diff --git a/src/modules/admin/config/advertisements/archive/toolbar.config.jsx b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx
index 3a90e27..87f1554 100644
--- a/src/modules/admin/config/advertisements/archive/toolbar.config.jsx
+++ b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx
@@ -1,5 +1,5 @@
// config/advertisements/archive/toolbar.config.jsx
-import { RefreshCw, Download } from "lucide-react";
+import { RefreshCw, Download, Ban } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
@@ -11,8 +11,9 @@ import { exportTableToExcel } from "@/utils/excel.util";
* @param {Function} deps.getFilters
* @param {Function} deps.getSort
* @param {Function} deps.getTableInstance
+ * @param {Function} [deps.onRemoveExpired] - triggers the "Remove Expired" bulk-purge flow
*/
-export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
+export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance, onRemoveExpired }) {
return [
{
key: "refresh",
@@ -38,5 +39,13 @@ export function buildToolbarActions({ fetchAdvertisements, pagination, exportCon
tableInstance: table ?? getTableInstance(),
}),
},
+ ...(onRemoveExpired ? [{
+ key: "remove-expired",
+ type: "button",
+ icon:
,
+ label: "Remove Expired",
+ className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
+ onClick: onRemoveExpired,
+ }] : []),
];
}
diff --git a/src/modules/admin/pages/activity/ActivityFeed.jsx b/src/modules/admin/pages/activity/ActivityFeed.jsx
index 46e3b43..8e5b951 100644
--- a/src/modules/admin/pages/activity/ActivityFeed.jsx
+++ b/src/modules/admin/pages/activity/ActivityFeed.jsx
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
-import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react";
+import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -9,9 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Calendar } from "@/components/ui/calendar";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import { DatePickerButton } from "@/components/generic/DatePickerButton";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
@@ -195,57 +194,6 @@ export default function ActivityFeed() {
);
}
-// ─── DatePickerButton ─────────────────────────────────────────────────────────
-function DatePickerButton({ value, onChange, placeholder, disabled }) {
- const [open, setOpen] = useState(false);
- const { fmtDate } = useDateFormat();
-
- const label = value ? fmtDate(value) : placeholder;
-
- return (
-
-
-
-
- {label}
-
-
-
- { onChange(d ?? null); setOpen(false); }}
- disabled={disabled}
- initialFocus
- />
-
- { onChange(new Date()); setOpen(false); }}
- >
- Today
-
- {value && (
- { onChange(null); setOpen(false); }}
- >
- Clear
-
- )}
-
-
-
- );
-}
-
// ─── Row ──────────────────────────────────────────────────────────────────────
function initials(name, email) {
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
@@ -293,10 +241,10 @@ function ActivityRow({ row, onViewUser }) {
{ts ? (
- {timeAgo(ts)}
+ {fmtDateTime(ts)}
- {fmtDateTime(ts)}
+ {timeAgo(ts)}
) : (
diff --git a/src/modules/admin/pages/activity/UserActivityPage.jsx b/src/modules/admin/pages/activity/UserActivityPage.jsx
index 9994626..660a91e 100644
--- a/src/modules/admin/pages/activity/UserActivityPage.jsx
+++ b/src/modules/admin/pages/activity/UserActivityPage.jsx
@@ -8,13 +8,20 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { DatePickerButton } from "@/components/generic/DatePickerButton";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
+import { fmtISO } from "@/utils/datetime.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const LIMIT = 20;
+function toDateStr(d) {
+ if (!d) return undefined;
+ return fmtISO(d);
+}
+
export default function UserActivityPage() {
const { userId } = useParams();
const navigate = useNavigate();
@@ -22,6 +29,8 @@ export default function UserActivityPage() {
const [page, setPage] = useState(1);
const [action, setAction] = useState("all");
+ const [from, setFrom] = useState(null);
+ const [to, setTo] = useState(null);
const load = useCallback(
(p = 1) => {
@@ -29,14 +38,16 @@ export default function UserActivityPage() {
page: p,
limit: LIMIT,
action: action === "all" ? undefined : action,
+ from: toDateStr(from),
+ to: toDateStr(to),
});
setPage(p);
},
- [fetchUserActivity, userId, action]
+ [fetchUserActivity, userId, action, from, to]
);
useEffect(() => { fetchUser(userId); }, [userId]);
- useEffect(() => { load(1); }, [action, userId]);
+ useEffect(() => { load(1); }, [action, from, to, userId]);
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
@@ -76,7 +87,7 @@ export default function UserActivityPage() {
{/* ─── Filter ──────────────────────────────────────────────── */}
-