From b0f40e5133f27d5810892ac52c0e44f3d057ae81 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 4 Jul 2026 03:35:22 +0800 Subject: [PATCH] add: more things Signed-off-by: Kenneth Obsequio --- .../generic/Dialogs/PermanentDeleteDialog.jsx | 141 +++++++ src/components/generic/MarkdownCheatsheet.jsx | 31 -- .../generic/SendEmailBroadcastDialog.jsx | 98 ----- src/components/generic/Table/DataTable.jsx | 3 +- .../generic/Table/buildSelectionColumn.jsx | 28 +- src/contexts/AdminAdvertisementContext.jsx | 26 ++ src/contexts/AdminAssetsContext.jsx | 31 ++ src/contexts/AdminCoursesContext.jsx | 100 +++++ src/contexts/AdminEmailBroadcastContext.jsx | 74 ---- src/contexts/AdminEmailTemplateContext.jsx | 76 ---- .../AdminNotificationBroadcastContext.jsx | 26 ++ src/contexts/AdminTaskContext.jsx | 56 +++ src/contexts/AdminTiersContext.jsx | 38 ++ src/contexts/AdminUserContext.jsx | 28 ++ src/contexts/AdminUserGroupContext.jsx | 28 ++ src/data/adminTiles.data.js | 3 +- src/data/emailBroadcastStatus.data.js | 10 - src/data/emailTemplateCategories.data.js | 46 --- src/data/emailTemplatePlaceholders.data.js | 14 - src/data/emailTemplateStatus.data.js | 11 - .../ArchivedAdvertisementsTable.jsx | 175 +++++++++ .../components/assets/ArchivedAssetsTable.jsx | 39 +- .../courses/ArchivedCourseTable.jsx | 43 +- .../courses/ArchivedLessonsTable.jsx | 39 +- .../components/courses/ArchivedUnitsTable.jsx | 46 ++- .../ArchivedNotificationBroadcastsTable.jsx | 174 +++++++++ .../components/task/ArchiveTaskListTable.jsx | 44 +++ .../components/task/ArchiveTaskTable.jsx | 42 ++ .../tiers/ArchivedTierPlansTable.jsx | 43 ++ .../user_groups/ArchiveGroupTable.jsx | 38 ++ .../components/users/ArchiveUserTable.jsx | 40 +- .../admin/components/users/UserTable.jsx | 4 + .../advertisements/archive/columns.config.jsx | 29 ++ .../archive/rowActions.config.jsx | 26 ++ .../archive/selection.config.jsx | 43 ++ .../advertisements/archive/toolbar.config.jsx | 42 ++ .../assets/archive/rowActions.config.jsx | 11 +- .../assets/archive/selection.config.jsx | 16 +- .../courses/archive/rowActions.config.jsx | 11 +- .../courses/archive/selection.config.jsx | 16 +- .../lessons/archive/rowActions.config.jsx | 11 +- .../lessons/archive/selection.config.jsx | 16 +- .../units/archive/rowActions.config.jsx | 11 +- .../units/archive/selection.config.jsx | 20 +- .../notifications/archive/columns.config.jsx | 29 ++ .../archive/rowActions.config.jsx | 26 ++ .../archive/selection.config.jsx | 43 ++ .../notifications/archive/toolbar.config.jsx | 42 ++ .../task_list/archive/rowActions.config.jsx | 11 +- .../task_list/archive/selection.config.jsx | 14 +- .../config/task_list/rowActions.config.jsx | 12 +- .../task/archive/rowActions.config.jsx | 11 +- .../task/archive/selection.config.jsx | 14 +- .../task_list/task/rowActions.config.jsx | 10 +- .../tiers/plans/archive/rowActions.config.jsx | 11 +- .../tiers/plans/archive/selection.config.jsx | 14 +- .../user_groups/archive/rowActions.config.jsx | 11 +- .../user_groups/archive/selection.config.jsx | 17 +- .../config/user_groups/rowActions.config.jsx | 1 + .../config/user_groups/selection.config.jsx | 3 +- .../users/archive/rowActions.config.jsx | 11 +- .../config/users/archive/selection.config.jsx | 17 +- .../admin/config/users/selection.config.jsx | 7 +- .../advertisements/AdvertisementList.jsx | 16 +- .../ArchivedAdvertisementList.jsx | 26 ++ .../email_templates/AddEmailTemplate.jsx | 367 ------------------ .../email_templates/EditEmailTemplate.jsx | 297 -------------- .../pages/email_templates/EmailBroadcasts.jsx | 140 ------- .../pages/email_templates/EmailTemplates.jsx | 272 ------------- .../ArchivedNotificationBroadcastList.jsx | 26 ++ .../NotificationBroadcastList.jsx | 6 +- .../admin/pages/task_list/CreateTaskList.jsx | 81 +++- .../admin/pages/task_list/TaskQueueStep.jsx | 191 +++++++++ .../admin/pages/task_list/task/CreateTask.jsx | 46 +-- .../admin/pages/task_list/task/EditTask.jsx | 31 +- .../admin/pages/task_list/task/task.schema.js | 53 +++ src/modules/admin/routes/AdminRoutes.jsx | 23 +- src/utils/markdownToHtml.util.js | 11 - 78 files changed, 2116 insertions(+), 1621 deletions(-) create mode 100644 src/components/generic/Dialogs/PermanentDeleteDialog.jsx delete mode 100644 src/components/generic/MarkdownCheatsheet.jsx delete mode 100644 src/components/generic/SendEmailBroadcastDialog.jsx delete mode 100644 src/contexts/AdminEmailBroadcastContext.jsx delete mode 100644 src/contexts/AdminEmailTemplateContext.jsx delete mode 100644 src/data/emailBroadcastStatus.data.js delete mode 100644 src/data/emailTemplateCategories.data.js delete mode 100644 src/data/emailTemplatePlaceholders.data.js delete mode 100644 src/data/emailTemplateStatus.data.js create mode 100644 src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx create mode 100644 src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx create mode 100644 src/modules/admin/config/advertisements/archive/columns.config.jsx create mode 100644 src/modules/admin/config/advertisements/archive/rowActions.config.jsx create mode 100644 src/modules/admin/config/advertisements/archive/selection.config.jsx create mode 100644 src/modules/admin/config/advertisements/archive/toolbar.config.jsx create mode 100644 src/modules/admin/config/notifications/archive/columns.config.jsx create mode 100644 src/modules/admin/config/notifications/archive/rowActions.config.jsx create mode 100644 src/modules/admin/config/notifications/archive/selection.config.jsx create mode 100644 src/modules/admin/config/notifications/archive/toolbar.config.jsx create mode 100644 src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx delete mode 100644 src/modules/admin/pages/email_templates/AddEmailTemplate.jsx delete mode 100644 src/modules/admin/pages/email_templates/EditEmailTemplate.jsx delete mode 100644 src/modules/admin/pages/email_templates/EmailBroadcasts.jsx delete mode 100644 src/modules/admin/pages/email_templates/EmailTemplates.jsx create mode 100644 src/modules/admin/pages/notifications/ArchivedNotificationBroadcastList.jsx create mode 100644 src/modules/admin/pages/task_list/TaskQueueStep.jsx create mode 100644 src/modules/admin/pages/task_list/task/task.schema.js delete mode 100644 src/utils/markdownToHtml.util.js diff --git a/src/components/generic/Dialogs/PermanentDeleteDialog.jsx b/src/components/generic/Dialogs/PermanentDeleteDialog.jsx new file mode 100644 index 0000000..084cc4d --- /dev/null +++ b/src/components/generic/Dialogs/PermanentDeleteDialog.jsx @@ -0,0 +1,141 @@ +// ─── components/PermanentDeleteDialog.jsx ──────────────────────────────────── +import { useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { AlertTriangle } from "lucide-react"; + +/** + * Generic permanent-delete dialog — works for any entity (users, groups, etc.) + * Unlike ArchiveDialog, this is irreversible: there is no Restore after this. + * + * Single: r.name} ... /> + * Bulk: + * + * @param {Function} onDelete (id | { ids }) => Promise — called with single id or { ids } + * @param {Function} getName (entity) => string — how to display the entity name + * @param {string} entityLabel e.g. "User", "Group" + * @param {boolean} loading from whichever context the parent uses + * @param {Function} onImpactCheck optional — async () => { label, count }[] + * called when dialog opens (single delete only). + * Returns an array of impact lines to warn about. + * Items with count === 0 are filtered out automatically. + */ +export function PermanentDeleteDialog({ + open, + onOpenChange, + entity, + ids, + entityLabel = "Item", + getName, + onDelete, + loading, + onSuccess, + onImpactCheck, +}) { + const isBulk = Array.isArray(ids) && ids.length > 0; + const count = isBulk ? ids.length : 1; + + const [impactLoading, setImpactLoading] = useState(false); + const [impacts, setImpacts] = useState([]); + + const displayName = isBulk + ? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}` + : (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item"); + + // Fetch impact when dialog opens for a single-entity delete + useEffect(() => { + if (!open || isBulk || !onImpactCheck) { + setImpacts([]); + return; + } + setImpactLoading(true); + setImpacts([]); + onImpactCheck() + .then((rows) => setImpacts((rows ?? []).filter((r) => r.count > 0))) + .catch(() => setImpacts([])) + .finally(() => setImpactLoading(false)); + }, [open]); + + const handleDelete = async () => { + const res = isBulk + ? await onDelete({ ids }) + : await onDelete(entity); + + if (res) { + onOpenChange(false); + onSuccess?.(); + } + }; + + const hasImpact = impacts.length > 0; + + return ( + + + + + Permanently Delete {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel} + + + {impactLoading ? ( +
+ Checking impact… +
+ ) : ( + <> + + Are you sure you want to permanently delete{" "} + {displayName}?{" "} + This action cannot be undone. + + + {hasImpact && ( +
+
+ + This will also permanently remove related data +
+
    + {impacts.map((impact, i) => ( +
  • + {impact.count} {impact.label} +
  • + ))} +
+

+ Deleting anyway will permanently remove all of the above along with it. +

+
+ )} + +
+ This is different from Archive — there is no Restore after this. +
+ + )} +
+ + + Cancel + + {loading && } + Delete{isBulk ? ` ${count}` : ""} Permanently + + +
+
+ ); +} diff --git a/src/components/generic/MarkdownCheatsheet.jsx b/src/components/generic/MarkdownCheatsheet.jsx deleted file mode 100644 index 1fa0da3..0000000 --- a/src/components/generic/MarkdownCheatsheet.jsx +++ /dev/null @@ -1,31 +0,0 @@ -const ROWS = [ - { md: "**bold**", out: "bold" }, - { md: "*italic*", out: "italic" }, - { md: "# Heading", out: "large heading (## and ### for smaller)" }, - { md: "[link text](https://…)", out: "a link" }, - { md: "- item", out: "bullet list" }, - { md: "1. item", out: "numbered list" }, - { md: "`code`", out: "inline code" }, - { md: "blank line", out: "starts a new paragraph" }, -]; - -// Compact reference for the Markdown body editor — shown under the textarea -// wherever admins author Markdown that gets converted to HTML on save. -export function MarkdownCheatsheet() { - return ( -
-

Quick Markdown reference

-
- {ROWS.map((r) => ( -
- {r.md} - → {r.out} -
- ))} -
-

- {"{{placeholder}}"} tokens still work anywhere in the text. -

-
- ); -} diff --git a/src/components/generic/SendEmailBroadcastDialog.jsx b/src/components/generic/SendEmailBroadcastDialog.jsx deleted file mode 100644 index d71c9fc..0000000 --- a/src/components/generic/SendEmailBroadcastDialog.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Send } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Spinner } from "@/components/ui/spinner"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker"; -import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data"; -import { useAdminEmailBroadcasts } from "@/contexts/AdminEmailBroadcastContext"; - -// Reused from Notification Broadcasts so admins pick an audience the same way -// everywhere — same target types, same picker for task list / course / tier plan. -export function SendEmailBroadcastDialog({ open, onOpenChange, template }) { - const navigate = useNavigate(); - const { createBroadcast, loading } = useAdminEmailBroadcasts(); - - const [targetType, setTargetType] = useState(""); - const [targetId, setTargetId] = useState(null); - const [error, setError] = useState(""); - - const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget; - - const reset = () => { setTargetType(""); setTargetId(null); setError(""); }; - - const handleSend = async () => { - if (!targetType) return setError("Please select an audience."); - if (needsTarget && !targetId) return setError("Please select a specific target."); - setError(""); - - const result = await createBroadcast({ - email_template_id: template.email_template_id, - target_type: targetType, - target_id: needsTarget ? targetId : null, - }); - if (result) { - reset(); - onOpenChange(false); - navigate("/admin/email-broadcasts"); - } - }; - - return ( - { onOpenChange(v); if (!v) reset(); }}> - - - Send to Recipients - - Send {template?.label} as a real email. - Delivery is paced in the background — this won't block or time out. - - - -
-
- - - {targetType && ( -

{TARGET_TYPE_MAP[targetType]?.description}

- )} -
- - {needsTarget && ( -
- - -
- )} - - {error &&

{error}

} -
- - - - - -
-
- ); -} diff --git a/src/components/generic/Table/DataTable.jsx b/src/components/generic/Table/DataTable.jsx index 9470813..3127479 100644 --- a/src/components/generic/Table/DataTable.jsx +++ b/src/components/generic/Table/DataTable.jsx @@ -32,6 +32,7 @@ export default function DataTable({ pageSizeOptions = pageSizes, columnPinning = { right: [], left: [] }, className = "", + enableRowSelection = true, }) { // ─── Refs — always hold latest filters and sort ─────────────────────────── const filtersRef = useRef([]); @@ -114,7 +115,7 @@ export default function DataTable({ pageSize: pagination.limit, }, }, - enableRowSelection: true, + enableRowSelection, onRowSelectionChange: setRowSelection, manualPagination: true, manualSorting: true, diff --git a/src/components/generic/Table/buildSelectionColumn.jsx b/src/components/generic/Table/buildSelectionColumn.jsx index dab86cf..3cedcf1 100644 --- a/src/components/generic/Table/buildSelectionColumn.jsx +++ b/src/components/generic/Table/buildSelectionColumn.jsx @@ -8,7 +8,11 @@ import { Checkbox } from "@/components/ui/checkbox"; * * The header renders a "select all on this page" checkbox with an * indeterminate state when only some rows are checked. - * Each cell renders a per-row checkbox. + * Each cell renders a per-row checkbox, disabled for any row TanStack + * reports as non-selectable via `row.getCanSelect()` — controlled by + * passing a function to DataTable's `enableRowSelection` prop, e.g. + * `enableRowSelection={(row) => row.original.user_id !== currentUserId}` + * to prevent a user from selecting their own row. * * @returns {Object} TanStack ColumnDef * @@ -49,14 +53,18 @@ export function buildSelectionColumn() { ), - cell: ({ row }) => ( -
- row.toggleSelected(!!value)} - aria-label="Select row" - /> -
- ), + cell: ({ row }) => { + const canSelect = row.getCanSelect(); + return ( +
+ row.toggleSelected(!!value)} + disabled={!canSelect} + aria-label={canSelect ? "Select row" : "Row cannot be selected"} + /> +
+ ); + }, }; } diff --git a/src/contexts/AdminAdvertisementContext.jsx b/src/contexts/AdminAdvertisementContext.jsx index 40e07bd..bd2ff31 100644 --- a/src/contexts/AdminAdvertisementContext.jsx +++ b/src/contexts/AdminAdvertisementContext.jsx @@ -179,6 +179,30 @@ export function AdvertisementsProvider({ children }) { [request] ); + // ─── DELETE /api/admin/advertisements/:advertisementId/permanent ────────── + const permanentlyDeleteAdvertisement = useCallback( + (advertisementId) => + request(async () => { + const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`); + setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId)); + toast.success("Advertisement permanently deleted."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/advertisements/bulk/permanent ─────────────────────── + const permanentlyDeleteAdvertisements = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } }); + setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id))); + toast.success(`${ids.length} advertisement(s) permanently deleted.`); + return res.data; + }), + [request] + ); + // ─── GET /api/admin/advertisements/field-values ──────────────────────────── const fetchAdvertisementFieldValues = useCallback( (field) => @@ -207,6 +231,8 @@ export function AdvertisementsProvider({ children }) { archiveAdvertisements, restoreAdvertisement, restoreAdvertisements, + permanentlyDeleteAdvertisement, + permanentlyDeleteAdvertisements, fetchAdvertisementFieldValues }}> {children} diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 6e96595..982ca15 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -294,6 +294,35 @@ export function AssetsProvider({ children }) { [request] ); + // ─── DELETE /api/admin/assets/:assetId/permanent ───────────────────────── + const permanentlyDeleteAsset = useCallback( + (assetId) => + request(async () => { + const res = await api.delete(`/admin/assets/${assetId}/permanent`); + setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); + setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); + invalidateListCache(); + toast.success("Asset permanently deleted."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/assets/bulk/permanent ───────────────────────────── + const permanentlyDeleteAssets = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete("/admin/assets/bulk/permanent", { + data: { ids }, + }); + setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); + invalidateListCache(); + toast.success(`${ids.length} asset(s) permanently deleted.`); + return res.data; + }), + [request] + ); + // ─── GET /api/admin/assets/field-values ────────────────────────────────────── const fetchAssetFieldValues = useCallback( (field) => @@ -324,6 +353,8 @@ export function AssetsProvider({ children }) { archiveAssets, restoreAsset, restoreAssets, + permanentlyDeleteAsset, + permanentlyDeleteAssets, fetchAssetFieldValues }}> {children} diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index 3b0cf2d..842c86f 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -199,6 +199,42 @@ export function CoursesProvider({ children }) { [request], ); + const permanentlyDeleteCourse = useCallback( + (courseId) => + request(async () => { + const { data } = await api.delete(`${BASE}/${courseId}/permanent`); + setCourses((prev) => prev.filter((c) => c.course_id !== courseId)); + setCourse((prev) => (prev?.course_id === courseId ? null : prev)); + toast.success("Course permanently deleted."); + return data; + }), + [request], + ); + + const permanentlyDeleteCourses = useCallback( + ({ ids }) => + request(async () => { + const { data } = await api.delete(`${BASE}/bulk/permanent`, { data: { ids } }); + setCourses((prev) => prev.filter((c) => !ids.includes(c.course_id))); + toast.success("Courses permanently deleted."); + return data; + }), + [request], + ); + + const fetchCoursePermanentDeleteImpact = useCallback( + (courseId) => + request(async () => { + const { data } = await api.get(`${BASE}/${courseId}/permanent-delete-impact`); + const { unitCount, lessonCount } = data?.data ?? {}; + return [ + { label: "unit(s)", count: unitCount ?? 0 }, + { label: "lesson(s)", count: lessonCount ?? 0 }, + ]; + }), + [request], + ); + // ========================================================================= // PREREQUISITES // ========================================================================= @@ -358,6 +394,39 @@ export function CoursesProvider({ children }) { [request], ); + const permanentlyDeleteUnit = useCallback( + (courseId, unitId) => + request(async () => { + const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/permanent`); + setUnits((prev) => prev.filter((u) => u.unit_id !== unitId)); + setUnit((prev) => (prev?.unit_id === unitId ? null : prev)); + toast.success("Unit permanently deleted."); + return data; + }), + [request], + ); + + const permanentlyDeleteUnits = useCallback( + (courseId, { ids }) => + request(async () => { + const { data } = await api.delete(`${BASE}/${courseId}/units/bulk/permanent`, { data: { ids } }); + setUnits((prev) => prev.filter((u) => !ids.includes(u.unit_id))); + toast.success("Units permanently deleted."); + return data; + }), + [request], + ); + + const fetchUnitPermanentDeleteImpact = useCallback( + (courseId, unitId) => + request(async () => { + const { data } = await api.get(`${BASE}/${courseId}/units/${unitId}/permanent-delete-impact`); + const { lessonCount } = data?.data ?? {}; + return [{ label: "lesson(s)", count: lessonCount ?? 0 }]; + }), + [request], + ); + // ========================================================================= // UNIT QUIZ // ========================================================================= @@ -667,6 +736,29 @@ export function CoursesProvider({ children }) { [request], ); + const permanentlyDeleteLesson = useCallback( + (courseId, unitId, lessonId) => + request(async () => { + const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/${lessonId}/permanent`); + setLessons((prev) => prev.filter((l) => l.lesson_id !== lessonId)); + setLesson((prev) => (prev?.lesson_id === lessonId ? null : prev)); + toast.success("Lesson permanently deleted."); + return data; + }), + [request], + ); + + const permanentlyDeleteLessons = useCallback( + (courseId, unitId, { ids }) => + request(async () => { + const { data } = await api.delete(`${BASE}/${courseId}/units/${unitId}/lessons/bulk/permanent`, { data: { ids } }); + setLessons((prev) => prev.filter((l) => !ids.includes(l.lesson_id))); + toast.success("Lessons permanently deleted."); + return data; + }), + [request], + ); + // ========================================================================= // LESSON PAGE // ========================================================================= @@ -1064,6 +1156,9 @@ export function CoursesProvider({ children }) { fetchArchivedCourse, restoreCourse, restoreCourses, + permanentlyDeleteCourse, + permanentlyDeleteCourses, + fetchCoursePermanentDeleteImpact, // ── prerequisites ────────────────────────────────────────────────────── fetchPrerequisites, @@ -1082,6 +1177,9 @@ export function CoursesProvider({ children }) { fetchArchivedUnit, restoreUnit, restoreUnits, + permanentlyDeleteUnit, + permanentlyDeleteUnits, + fetchUnitPermanentDeleteImpact, // ── unit quiz ────────────────────────────────────────────────────────── fetchQuiz, @@ -1119,6 +1217,8 @@ export function CoursesProvider({ children }) { fetchArchivedLesson, restoreLesson, restoreLessons, + permanentlyDeleteLesson, + permanentlyDeleteLessons, // ── lesson page ──────────────────────────────────────────────────────── fetchLessonPage, diff --git a/src/contexts/AdminEmailBroadcastContext.jsx b/src/contexts/AdminEmailBroadcastContext.jsx deleted file mode 100644 index 46aba2b..0000000 --- a/src/contexts/AdminEmailBroadcastContext.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createContext, useCallback, useContext, useState } from "react"; -import api from "@/utils/api.util"; -import { toast } from "sonner"; - -const AdminEmailBroadcastContext = createContext(null); - -export function useAdminEmailBroadcasts() { - const ctx = useContext(AdminEmailBroadcastContext); - if (!ctx) throw new Error("useAdminEmailBroadcasts must be used inside AdminEmailBroadcastProvider"); - return ctx; -} - -export function AdminEmailBroadcastProvider({ children }) { - const [broadcasts, setBroadcasts] = useState([]); - const [broadcast, setBroadcast] = useState(null); - const [loading, setLoading] = useState(false); - - const request = useCallback(async (fn) => { - setLoading(true); - try { return await fn(); } - catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); - return null; - } finally { setLoading(false); } - }, []); - - // Silent variant for polling — no loading spinner flicker, no toast noise on transient failures. - const fetchBroadcastsQuiet = useCallback(async () => { - try { - const { data } = await api.get("/admin/email-broadcasts"); - setBroadcasts(data.data ?? []); - return data.data; - } catch { return null; } - }, []); - - const fetchBroadcasts = useCallback(() => - request(async () => { - const { data } = await api.get("/admin/email-broadcasts"); - setBroadcasts(data.data ?? []); - return data.data; - }), [request]); - - const fetchBroadcast = useCallback((id) => - request(async () => { - const { data } = await api.get(`/admin/email-broadcasts/${id}`); - setBroadcast(data.data ?? null); - return data.data; - }), [request]); - - const createBroadcast = useCallback((payload) => - request(async () => { - const { data } = await api.post("/admin/email-broadcasts", payload); - toast.success(data.message ?? "Broadcast queued."); - return data.data; - }), [request]); - - const cancelBroadcast = useCallback((id) => - request(async () => { - const { data } = await api.patch(`/admin/email-broadcasts/${id}/cancel`); - setBroadcasts((prev) => prev.map((b) => (String(b.email_broadcast_id) === String(id) ? data.data : b))); - toast.success("Broadcast canceled."); - return data.data; - }), [request]); - - return ( - - {children} - - ); -} diff --git a/src/contexts/AdminEmailTemplateContext.jsx b/src/contexts/AdminEmailTemplateContext.jsx deleted file mode 100644 index 41b77b6..0000000 --- a/src/contexts/AdminEmailTemplateContext.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import { createContext, useCallback, useContext, useState } from "react"; -import api from "@/utils/api.util"; -import { toast } from "sonner"; - -const AdminEmailTemplateContext = createContext(null); - -export function useAdminEmailTemplates() { - const ctx = useContext(AdminEmailTemplateContext); - if (!ctx) throw new Error("useAdminEmailTemplates must be used inside AdminEmailTemplateProvider"); - return ctx; -} - -export function AdminEmailTemplateProvider({ children }) { - const [templates, setTemplates] = useState([]); - const [template, setTemplate] = useState(null); - const [loading, setLoading] = useState(false); - - const request = useCallback(async (fn) => { - setLoading(true); - try { return await fn(); } - catch (err) { - toast.error(err?.response?.data?.message ?? "Something went wrong."); - return null; - } finally { setLoading(false); } - }, []); - - const fetchTemplates = useCallback(() => - request(async () => { - const { data } = await api.get("/admin/email-templates"); - setTemplates(data.data ?? []); - return data.data; - }), [request]); - - const fetchTemplate = useCallback((id) => - request(async () => { - const { data } = await api.get(`/admin/email-templates/${id}`); - setTemplate(data.data ?? null); - return data.data; - }), [request]); - - const createTemplate = useCallback((payload) => - request(async () => { - const { data } = await api.post("/admin/email-templates", payload); - toast.success("Email template created."); - return data.data; - }), [request]); - - const updateTemplate = useCallback((id, payload) => - request(async () => { - const { data } = await api.put(`/admin/email-templates/${id}`, payload); - setTemplates((prev) => - prev.map((t) => (String(t.email_template_id) === String(id) ? data.data : t)) - ); - if (template && String(template.email_template_id) === String(id)) setTemplate(data.data); - toast.success("Email template updated."); - return data.data; - }), [request, template]); - - const deleteTemplate = useCallback((id) => - request(async () => { - await api.delete(`/admin/email-templates/${id}`); - setTemplates((prev) => prev.filter((t) => String(t.email_template_id) !== String(id))); - toast.success("Email template deleted."); - return true; - }), [request]); - - return ( - - {children} - - ); -} diff --git a/src/contexts/AdminNotificationBroadcastContext.jsx b/src/contexts/AdminNotificationBroadcastContext.jsx index 7aad4bb..30ad1db 100644 --- a/src/contexts/AdminNotificationBroadcastContext.jsx +++ b/src/contexts/AdminNotificationBroadcastContext.jsx @@ -195,6 +195,30 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); + // ─── DELETE /api/admin/notification-broadcasts/:broadcastId/permanent ───── + const permanentlyDeleteBroadcast = useCallback( + (broadcastId) => + request(async () => { + const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}/permanent`); + setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); + toast.success("Notification broadcast permanently deleted."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/notification-broadcasts/bulk/permanent ───────────── + const permanentlyDeleteBroadcasts = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete("/admin/notification-broadcasts/bulk/permanent", { data: { ids } }); + setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); + toast.success(`${ids.length} notification broadcast(s) permanently deleted.`); + return res.data; + }), + [request] + ); + return ( {children} diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index b77c67d..8951fce 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -189,6 +189,39 @@ export function AdminTaskProvider({ children }) { [request] ); + const permanentlyDeleteTaskList = useCallback( + (taskListId) => + request(async () => { + await api.delete(`${BASE}/${taskListId}/permanent`); + toast.success('Task list permanently deleted.'); + return true; + }), + [request] + ); + + const bulkPermanentlyDeleteTaskLists = useCallback( + (ids) => + request(async () => { + await api.post(`${BASE}/bulk-delete`, { ids }); + toast.success(`${ids.length} task list(s) permanently deleted.`); + return true; + }), + [request] + ); + + const fetchTaskListPermanentDeleteImpact = useCallback( + (taskListId) => + request(async () => { + const res = await api.get(`${BASE}/${taskListId}/permanent-delete-impact`); + const { taskCount, completionCount } = res.data?.data ?? {}; + return [ + { label: "task(s)", count: taskCount ?? 0 }, + { label: "task completion record(s)", count: completionCount ?? 0 }, + ]; + }), + [request] + ); + const fetchTaskListFieldValues = useCallback( (field) => request(async () => { @@ -354,6 +387,26 @@ export function AdminTaskProvider({ children }) { [request] ); + const permanentlyDeleteTask = useCallback( + (taskListId, taskId) => + request(async () => { + await api.delete(`${BASE}/${taskListId}/tasks/${taskId}/permanent`); + toast.success('Task permanently deleted.'); + return true; + }), + [request] + ); + + const bulkPermanentlyDeleteTasks = useCallback( + (taskListId, ids) => + request(async () => { + await api.post(`${BASE}/${taskListId}/tasks/bulk-delete`, { ids }); + toast.success(`${ids.length} task(s) permanently deleted.`); + return true; + }), + [request] + ); + const fetchTaskFieldValues = useCallback( (taskListId, field) => request(async () => { @@ -539,6 +592,8 @@ export function AdminTaskProvider({ children }) { createTaskList, updateTaskList, archiveTaskList, restoreTaskList, bulkArchiveTaskLists, bulkRestoreTaskLists, + permanentlyDeleteTaskList, bulkPermanentlyDeleteTaskLists, + fetchTaskListPermanentDeleteImpact, fetchTaskListFieldValues, // ── Task List Group actions ─────────────────────────────────────── @@ -551,6 +606,7 @@ export function AdminTaskProvider({ children }) { createTask, updateTask, archiveTask, restoreTask, bulkArchiveTasks, bulkRestoreTasks, + permanentlyDeleteTask, bulkPermanentlyDeleteTasks, fetchTaskFieldValues, // ── Completion actions ──────────────────────────────────────────── diff --git a/src/contexts/AdminTiersContext.jsx b/src/contexts/AdminTiersContext.jsx index a0abe9f..b1e0192 100644 --- a/src/contexts/AdminTiersContext.jsx +++ b/src/contexts/AdminTiersContext.jsx @@ -118,6 +118,43 @@ export function AdminTiersProvider({ children }) { } finally { setLoading(false); } }, []); + const permanentlyDeletePlan = useCallback(async (id) => { + setLoading(true); + try { + await api.delete(`/admin/tiers/${id}/permanent`); + toast.success("Plan permanently deleted."); + return true; + } catch (err) { + toast.error(err?.response?.data?.message ?? "Could not permanently delete plan."); + return false; + } finally { setLoading(false); } + }, []); + + const bulkPermanentlyDeletePlans = useCallback(async (ids) => { + setLoading(true); + try { + await api.post("/admin/tiers/bulk/permanent-delete", { ids }); + toast.success("Plans permanently deleted."); + return true; + } catch (err) { + toast.error(err?.response?.data?.message ?? "Could not permanently delete plans."); + return false; + } finally { setLoading(false); } + }, []); + + const fetchPlanPermanentDeleteImpact = useCallback(async (id) => { + try { + const { data } = await api.get(`/admin/tiers/${id}/permanent-delete-impact`); + const { active_subscriber_count, payment_count } = data?.data ?? {}; + return [ + { label: "active subscriber(s)", count: active_subscriber_count ?? 0 }, + { label: "payment record(s) on file — deletion will be blocked while these exist", count: payment_count ?? 0 }, + ]; + } catch { + return []; + } + }, []); + // ─── User Tiers ─────────────────────────────────────────────────────────── const fetchUserTiers = useCallback(async (userId) => { @@ -198,6 +235,7 @@ export function AdminTiersProvider({ children }) { createPlan, updatePlan, deletePlan, restorePlan, bulkDeletePlans, bulkRestorePlans, + permanentlyDeletePlan, bulkPermanentlyDeletePlans, fetchPlanPermanentDeleteImpact, // User tier actions fetchUserTiers, grantTier, revokeTier, diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index d643f8d..ec22f0f 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -194,6 +194,33 @@ export const UserProvider = ({ children }) => { [request] ); + // ─── DELETE /api/admin/users/:id/permanent ──────────────────────────────── + const permanentlyDeleteUser = useCallback( + (userId) => + request(async () => { + const res = await api.delete(`${BASE}/users/${userId}/permanent`); + setUsers((prev) => prev.filter((u) => u.user_id !== userId)); + toast.success("User permanently deleted."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/users/bulk/permanent ─────────────────────────────── + const permanentlyDeleteUsers = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete(`${BASE}/users/bulk/permanent`, { data: { ids } }); + const { deleted_ids } = res.data?.data ?? {}; + if (deleted_ids?.length) { + setUsers((prev) => prev.filter((u) => !deleted_ids.includes(u.user_id))); + toast.success(`${deleted_ids.length} user(s) permanently deleted.`); + } + return res.data; + }), + [request] + ); + // ─── GET /api/admin/users/:id/sessions ──────────────────────────────────── const fetchUserSessions = useCallback( (userId) => @@ -380,6 +407,7 @@ export const UserProvider = ({ children }) => { addStaffUser, updateUser, deactivateUser, deactivateUsers, restoreUser, restoreUsers, + permanentlyDeleteUser, permanentlyDeleteUsers, fetchUserSessions, terminateSession, fetchUserFieldValues, fetchUserAchievements, diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx index 8919f9b..bd69a56 100644 --- a/src/contexts/AdminUserGroupContext.jsx +++ b/src/contexts/AdminUserGroupContext.jsx @@ -208,6 +208,33 @@ export function UserGroupProvider({ children }) { [request] ); + // ─── DELETE /api/admin/groups/:gid/permanent ────────────────────────────── + const permanentlyDeleteGroup = useCallback( + (gid) => + request(async () => { + const res = await api.delete(`${BASE}/groups/${gid}/permanent`); + setGroups((prev) => prev.filter((g) => g.group_id !== gid)); + toast.success("Group permanently deleted."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/groups/bulk/permanent ────────────────────────────── + const permanentlyDeleteGroups = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete(`${BASE}/groups/bulk/permanent`, { data: { ids } }); + const { deleted_ids } = res.data?.data ?? {}; + if (deleted_ids?.length) { + setGroups((prev) => prev.filter((g) => !deleted_ids.includes(g.group_id))); + toast.success(`${deleted_ids.length} group(s) permanently deleted.`); + } + return res.data; + }), + [request] + ); + // ─── POST /api/admin/groups/:gid/users ──────────────────────────────────── const addUsersToGroup = useCallback( (gid, user_ids) => @@ -242,6 +269,7 @@ export function UserGroupProvider({ children }) { createGroup, updateGroup, deactivateGroup, deactivateGroups, restoreGroup, restoreGroups, + permanentlyDeleteGroup, permanentlyDeleteGroups, addUsersToGroup, removeUsersFromGroup, }}> {children} diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index f1fe9de..f554c13 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -1,4 +1,4 @@ -import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy, Mail } from "lucide-react"; +import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy } from "lucide-react"; export const ADMIN_SECTIONS = [ { @@ -41,7 +41,6 @@ export const ADMIN_SECTIONS = [ { key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" }, { key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" }, { key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" }, - { key: "email-templates", label: "Email Templates", icon: Mail, link: "/admin/email-templates" }, ], }, ]; \ No newline at end of file diff --git a/src/data/emailBroadcastStatus.data.js b/src/data/emailBroadcastStatus.data.js deleted file mode 100644 index c522e40..0000000 --- a/src/data/emailBroadcastStatus.data.js +++ /dev/null @@ -1,10 +0,0 @@ -export const EMAIL_BROADCAST_STATUSES = [ - { value: "queued", label: "Queued", badgeClass: "bg-muted text-muted-foreground border-border" }, - { value: "sending", label: "Sending", badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700" }, - { value: "completed", label: "Completed", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" }, - { value: "canceled", label: "Canceled", badgeClass: "bg-muted text-muted-foreground border-border" }, -]; - -export const EMAIL_BROADCAST_STATUS_MAP = Object.fromEntries( - EMAIL_BROADCAST_STATUSES.map((s) => [s.value, s]) -); diff --git a/src/data/emailTemplateCategories.data.js b/src/data/emailTemplateCategories.data.js deleted file mode 100644 index ea9ad6d..0000000 --- a/src/data/emailTemplateCategories.data.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Megaphone, BadgePercent, Shield, Tag } from "lucide-react"; - -// Purely organizational — lets admins tell at a glance what an email template -// is for. Independent of `is_system` (which is about whether the type/row can -// be renamed or deleted, not what it's used for). -export const EMAIL_TEMPLATE_CATEGORIES = [ - { - value: "announcement", - label: "Announcement", - description: "Platform news, updates, or broadcast-style messages to users.", - icon: Megaphone, - badgeClass: "bg-blue-100 text-blue-700 border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700", - }, - { - value: "advertisement", - label: "Advertisement", - description: "Promotional or marketing content (offers, plans, campaigns).", - icon: BadgePercent, - badgeClass: "bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700", - }, - { - value: "system", - label: "System", - description: "Account and transactional emails triggered by platform events.", - icon: Shield, - badgeClass: "bg-slate-100 text-slate-700 border-slate-400 dark:bg-slate-800/60 dark:text-slate-300 dark:border-slate-600", - }, - { - value: "other", - label: "Other", - description: "Anything that doesn't fit the categories above.", - icon: Tag, - badgeClass: "bg-purple-100 text-purple-700 border-purple-400 dark:bg-purple-900/40 dark:text-purple-400 dark:border-purple-700", - }, -]; - -export const getEmailTemplateCategory = (value) => - EMAIL_TEMPLATE_CATEGORIES.find((c) => c.value === value) ?? EMAIL_TEMPLATE_CATEGORIES[3]; - -// Mirrors BROADCASTABLE_CATEGORIES in controllers/admin/email_broadcasts.controller.js — -// only these categories make sense to blast to real recipients. System/transactional -// templates are triggered per-user by app events, never mass-sent. -export const BROADCASTABLE_CATEGORIES = ["announcement", "advertisement"]; - -export const isBroadcastable = (template) => - BROADCASTABLE_CATEGORIES.includes(template?.category) && template?.status === "sent"; diff --git a/src/data/emailTemplatePlaceholders.data.js b/src/data/emailTemplatePlaceholders.data.js deleted file mode 100644 index c1d25d7..0000000 --- a/src/data/emailTemplatePlaceholders.data.js +++ /dev/null @@ -1,14 +0,0 @@ -// Reference-only registry of {{placeholder}} tokens available per system email -// type. Purely informational for the admin editor — the backend derives the -// real substitution data from wherever sendEmail({ type, data }) is called in -// code, this just tells the admin what's actually available to reference. -export const EMAIL_TEMPLATE_PLACEHOLDERS = { - OTP: ["otp", "expiryMinutes"], - WELCOME: ["name"], - PASSWORD_CHANGED: [], - ADDED_TO_GROUP: ["groupName"], - TASK_ASSIGNED: ["taskTitle", "dueDate"], - BAN_LIFTED: ["name", "email", "date"], - BANNED: ["name", "email", "date", "reason", "duration_word", "duration_label", "suspension_note"], - ADD_STAFF: ["name", "email", "password", "expiryHours"], -}; diff --git a/src/data/emailTemplateStatus.data.js b/src/data/emailTemplateStatus.data.js deleted file mode 100644 index 8c4017a..0000000 --- a/src/data/emailTemplateStatus.data.js +++ /dev/null @@ -1,11 +0,0 @@ -// A template is "sent" once it has live subject/html_body — that's the only -// content services/email.service.js's sendEmail() ever reads on the backend. -// Editing a sent template writes to draft_subject/draft_html_body instead, so -// "pending changes" means there's a draft sitting on top of the live version. -export const hasPendingChanges = (t) => - t?.status === "sent" && (t?.draft_subject != null || t?.draft_html_body != null); - -export const STATUS_META = { - draft: { label: "Draft", badgeClass: "bg-muted text-muted-foreground border-border" }, - sent: { label: "Sent", badgeClass: "bg-emerald-100 text-emerald-700 border-emerald-400 dark:bg-emerald-900/40 dark:text-emerald-400 dark:border-emerald-700" }, -}; diff --git a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx new file mode 100644 index 0000000..744c412 --- /dev/null +++ b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx @@ -0,0 +1,175 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; + +import { buildDataColumns, columnPinning } from "../../config/advertisements/archive/columns.config"; +import { buildToolbarActions } from "../../config/advertisements/archive/toolbar.config"; +import { buildRowActions } from "../../config/advertisements/archive/rowActions.config"; +import { buildSelectionActions } from "../../config/advertisements/archive/selection.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchivedAdvertisementsTable() { + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => {}, + setFilters: () => {}, + }); + + const navigate = useNavigate(); + + const { + advertisements, attributes, pagination, setPagination, loading, + fetchArchivedAdvertisements, restoreAdvertisement, restoreAdvertisements, + permanentlyDeleteAdvertisement, permanentlyDeleteAdvertisements, + fetchAdvertisementFieldValues, + } = useAdvertisements(); + + useEffect(() => { + fetchArchivedAdvertisements({ page: 1, limit: pagination?.limit ?? 10 }); + }, []); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: advertisements, + attributes, + filename: `${getTimestamp()}_ArchivedAdvertisements`, + sheetName: "Archived Advertisements", + }; + + const rowActions = buildRowActions({ + onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchAdvertisements: fetchArchivedAdvertisements, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onSingleRestore: (row) => setRestoreTarget(row), + onBulkRestore: (ids) => setRestoreIds(ids), + onSingleDelete: (row) => setDeleteTarget(row), + onBulkDelete: (ids) => setDeleteIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes] + ); + + const handleRestoreSuccess = () => { + setRestoreTarget(null); + setRestoreIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedAdvertisements({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedAdvertisements({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="archived advertisement" + emptyMessage="No archived advertisements found." + /> + + {/* ── Single restore ── */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Advertisement" + getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"} + onRestore={(a) => restoreAdvertisement(a?.advertisement_id)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* ── Bulk restore ── */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Advertisement" + onRestore={(ids) => restoreAdvertisements(ids)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* ── Single permanent delete ── */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Advertisement" + getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"} + onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* ── Bulk permanent delete ── */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Advertisement" + onDelete={(ids) => permanentlyDeleteAdvertisements(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + ); +} diff --git a/src/modules/admin/components/assets/ArchivedAssetsTable.jsx b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx index ba8b1ae..11cbdc2 100644 --- a/src/modules/admin/components/assets/ArchivedAssetsTable.jsx +++ b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx @@ -6,6 +6,7 @@ import { useAssets } from "@/contexts/AdminAssetsContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/assets/archive/columns.config"; import { buildToolbarActions } from "../../config/assets/archive/toolbar.config"; @@ -17,6 +18,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchivedAssetsTable() { const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -29,7 +32,8 @@ export default function ArchivedAssetsTable() { const { assets, attributes, pagination, setPagination, loading, - fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues + fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues, + permanentlyDeleteAsset, permanentlyDeleteAssets } = useAssets(); useEffect(() => { @@ -49,6 +53,7 @@ export default function ArchivedAssetsTable() { const rowActions = buildRowActions({ onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }); const toolbarActions = buildToolbarActions({ @@ -65,6 +70,8 @@ export default function ArchivedAssetsTable() { exportConfig, onSingleRestore: (row) => setRestoreTarget(row), onBulkRestore: (ids) => setRestoreIds(ids), + onSingleDelete: (row) => setDeleteTarget(row), + onBulkDelete: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -80,6 +87,13 @@ export default function ArchivedAssetsTable() { fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 }); + }; + return ( <> + + {/* ── Single permanent delete ── */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Asset" + getName={(a) => a?.display_name ?? a?.original_name} + onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* ── Bulk permanent delete ── */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Asset" + onDelete={(ids) => permanentlyDeleteAssets(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } \ No newline at end of file diff --git a/src/modules/admin/components/courses/ArchivedCourseTable.jsx b/src/modules/admin/components/courses/ArchivedCourseTable.jsx index 92a8821..7c59999 100644 --- a/src/modules/admin/components/courses/ArchivedCourseTable.jsx +++ b/src/modules/admin/components/courses/ArchivedCourseTable.jsx @@ -7,6 +7,7 @@ import { useAuth } from "@/contexts/AuthContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/courses/archive/columns.config"; import { buildToolbarActions } from "../../config/courses/archive/toolbar.config"; @@ -18,6 +19,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchivedCoursesTable() { const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -28,7 +31,11 @@ export default function ArchivedCoursesTable() { const navigate = useNavigate(); - const { courses, attributes, pagination, setPagination, loading, fetchArchivedCourses, restoreCourse, restoreCourses } = useCourses(); + const { + courses, attributes, pagination, setPagination, loading, fetchArchivedCourses, + restoreCourse, restoreCourses, + permanentlyDeleteCourse, permanentlyDeleteCourses, fetchCoursePermanentDeleteImpact, + } = useCourses(); const handleRefsReady = (refs) => { tableRefsRef.current = refs; @@ -44,6 +51,7 @@ export default function ArchivedCoursesTable() { const rowActions = useMemo(() => buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }), []); const toolbarActions = buildToolbarActions({ @@ -60,6 +68,8 @@ export default function ArchivedCoursesTable() { exportConfig, restoreCourse: (row) => setRestoreTarget(row), // single restoreCourses: (ids) => setRestoreIds(ids), // bulk + deleteCourse: (row) => setDeleteTarget(row), + deleteCourses: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -75,6 +85,13 @@ export default function ArchivedCoursesTable() { fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Course" + getName={(c) => c?.title} + onDelete={(c) => permanentlyDeleteCourse(c?.course_id)} + onImpactCheck={() => fetchCoursePermanentDeleteImpact(deleteTarget?.course_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Course" + onDelete={permanentlyDeleteCourses} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } diff --git a/src/modules/admin/components/courses/ArchivedLessonsTable.jsx b/src/modules/admin/components/courses/ArchivedLessonsTable.jsx index b27cb5e..912c28b 100644 --- a/src/modules/admin/components/courses/ArchivedLessonsTable.jsx +++ b/src/modules/admin/components/courses/ArchivedLessonsTable.jsx @@ -6,6 +6,7 @@ import { useCourses } from "@/contexts/AdminCoursesContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/courses/lessons/archive/columns.config"; import { buildToolbarActions } from "../../config/courses/lessons/archive/toolbar.config"; @@ -17,6 +18,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchivedLessonsTable({ courseId, unitId }) { const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -29,7 +32,8 @@ export default function ArchivedLessonsTable({ courseId, unitId }) { const { course, unit, lessons, attributes, pagination, setPagination, loading, - fetchArchivedLessons, restoreLesson, restoreLessons, fetchLessonFieldValues + fetchArchivedLessons, restoreLesson, restoreLessons, fetchLessonFieldValues, + permanentlyDeleteLesson, permanentlyDeleteLessons } = useCourses(); const handleRefsReady = (refs) => { @@ -51,6 +55,7 @@ export default function ArchivedLessonsTable({ courseId, unitId }) { const rowActions = useMemo(() => buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }), [courseId, unitId]); const toolbarActions = buildToolbarActions({ @@ -69,6 +74,8 @@ export default function ArchivedLessonsTable({ courseId, unitId }) { exportConfig, restoreLesson: (row) => setRestoreTarget(row), // single restoreLessons: (ids) => setRestoreIds(ids), // bulk + deleteLesson: (row) => setDeleteTarget(row), + deleteLessons: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -84,6 +91,13 @@ export default function ArchivedLessonsTable({ courseId, unitId }) { fetchArchivedLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Lesson" + getName={(c) => c?.title} + onDelete={(c) => permanentlyDeleteLesson(courseId, unitId, c?.lesson_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Lesson" + onDelete={(ids) => permanentlyDeleteLessons(courseId, unitId, ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } diff --git a/src/modules/admin/components/courses/ArchivedUnitsTable.jsx b/src/modules/admin/components/courses/ArchivedUnitsTable.jsx index bc81f43..be20083 100644 --- a/src/modules/admin/components/courses/ArchivedUnitsTable.jsx +++ b/src/modules/admin/components/courses/ArchivedUnitsTable.jsx @@ -6,6 +6,7 @@ import { useCourses } from "@/contexts/AdminCoursesContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/courses/units/archive/columns.config"; import { buildToolbarActions } from "../../config/courses/units/archive/toolbar.config"; @@ -17,6 +18,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchivedUnitsTable({ courseId }) { const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -29,7 +32,8 @@ export default function ArchivedUnitsTable({ courseId }) { const { course, units, attributes, pagination, setPagination, loading, - fetchArchivedUnits, restoreUnit, restoreUnits, fetchUnitFieldValues + fetchArchivedUnits, restoreUnit, restoreUnits, fetchUnitFieldValues, + permanentlyDeleteUnit, permanentlyDeleteUnits, fetchUnitPermanentDeleteImpact, } = useCourses(); const handleRefsReady = (refs) => { @@ -49,7 +53,8 @@ export default function ArchivedUnitsTable({ courseId }) { ); const rowActions = useMemo(() => buildRowActions({ - onRestore: (row) => setRestoreTarget(row) + onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }), [courseId]); const toolbarActions = buildToolbarActions({ @@ -65,8 +70,10 @@ export default function ArchivedUnitsTable({ courseId }) { const selectionActions = buildSelectionActions({ exportConfig, - restoreCourse: (row) => setRestoreTarget(row), // single - restoreCourses: (ids) => setRestoreIds(ids), // bulk + restoreUnit: (row) => setRestoreTarget(row), // single + restoreUnits: (ids) => setRestoreIds(ids), // bulk + deleteUnit: (row) => setDeleteTarget(row), + deleteUnits: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -82,6 +89,13 @@ export default function ArchivedUnitsTable({ courseId }) { fetchArchivedUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Unit" + getName={(c) => c?.title} + onDelete={(c) => permanentlyDeleteUnit(courseId, c?.unit_id)} + onImpactCheck={() => fetchUnitPermanentDeleteImpact(courseId, deleteTarget?.unit_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Unit" + onDelete={(ids) => permanentlyDeleteUnits(courseId, ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } diff --git a/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx b/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx new file mode 100644 index 0000000..b32f13c --- /dev/null +++ b/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext"; + +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; + +import { buildDataColumns, columnPinning } from "../../config/notifications/archive/columns.config"; +import { buildToolbarActions } from "../../config/notifications/archive/toolbar.config"; +import { buildRowActions } from "../../config/notifications/archive/rowActions.config"; +import { buildSelectionActions } from "../../config/notifications/archive/selection.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchivedNotificationBroadcastsTable() { + const [restoreTarget, setRestoreTarget] = useState(null); + const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); + + const tableRefsRef = useRef({ + getFilters: () => [], + getSort: () => [], + resetSelection: () => {}, + setFilters: () => {}, + }); + + const navigate = useNavigate(); + + const { + broadcasts, attributes, pagination, setPagination, loading, + fetchArchivedBroadcasts, restoreBroadcast, restoreBroadcasts, + permanentlyDeleteBroadcast, permanentlyDeleteBroadcasts, + } = useNotificationBroadcasts(); + + useEffect(() => { + fetchArchivedBroadcasts({ page: 1, limit: pagination?.limit ?? 10 }); + }, []); + + const handleRefsReady = (refs) => { + tableRefsRef.current = refs; + }; + + const exportConfig = { + allData: broadcasts, + attributes, + filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`, + sheetName: "Archived Notifications", + }; + + const rowActions = buildRowActions({ + onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchBroadcasts: fetchArchivedBroadcasts, + pagination, + exportConfig, + navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + onSingleRestore: (row) => setRestoreTarget(row), + onBulkRestore: (ids) => setRestoreIds(ids), + onSingleDelete: (row) => setDeleteTarget(row), + onBulkDelete: (ids) => setDeleteIds(ids), + getTableInstance: () => tableRefsRef.current.tableInstance, + }); + + const columns = useMemo( + () => buildDataColumns(attributes, rowActions), + [attributes] + ); + + const handleRestoreSuccess = () => { + setRestoreTarget(null); + setRestoreIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedBroadcasts({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedBroadcasts({ page: 1, limit: pagination?.limit ?? 10 }); + }; + + return ( + <> + Promise.resolve([])} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="archived notification" + emptyMessage="No archived notifications found." + /> + + {/* ── Single restore ── */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Notification" + getName={(b) => b?.title ?? "this notification"} + onRestore={(b) => restoreBroadcast(b?.broadcast_id)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* ── Bulk restore ── */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Notification" + onRestore={(ids) => restoreBroadcasts(ids)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* ── Single permanent delete ── */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Notification" + getName={(b) => b?.title ?? "this notification"} + onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* ── Bulk permanent delete ── */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Notification" + onDelete={(ids) => permanentlyDeleteBroadcasts(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + ); +} diff --git a/src/modules/admin/components/task/ArchiveTaskListTable.jsx b/src/modules/admin/components/task/ArchiveTaskListTable.jsx index a9bd6f7..4e5007f 100644 --- a/src/modules/admin/components/task/ArchiveTaskListTable.jsx +++ b/src/modules/admin/components/task/ArchiveTaskListTable.jsx @@ -6,6 +6,7 @@ import { useAdminTask } from "@/contexts/AdminTaskContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/archive/columns.config"; import { buildToolbarActions } from "@/modules/admin/config/task_list/archive/toolbar.config"; @@ -22,10 +23,15 @@ export default function ArchiveTaskListTable() { fetchArchivedTaskLists, restoreTaskList, bulkRestoreTaskLists, + permanentlyDeleteTaskList, + bulkPermanentlyDeleteTaskLists, + fetchTaskListPermanentDeleteImpact, } = useAdminTask(); const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -50,6 +56,18 @@ export default function ArchiveTaskListTable() { }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedTaskLists({ + page: 1, + limit: pagination?.limit ?? 10, + filters: tableRefsRef.current.getFilters(), + sort: tableRefsRef.current.getSort(), + }); + }; + const exportConfig = { allData: taskLists, attributes, @@ -60,6 +78,7 @@ export default function ArchiveTaskListTable() { const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }); const toolbarActions = buildToolbarActions({ @@ -74,6 +93,7 @@ export default function ArchiveTaskListTable() { const selectionActions = buildSelectionActions({ exportConfig, onBulkRestore: (ids) => setRestoreIds(ids), + onBulkDelete: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -133,6 +153,30 @@ export default function ArchiveTaskListTable() { loading={loading} onSuccess={handleSuccess} /> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Task List" + getName={(r) => r?.name} + onDelete={(r) => permanentlyDeleteTaskList(r?.task_list_id)} + onImpactCheck={() => fetchTaskListPermanentDeleteImpact(deleteTarget?.task_list_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Task List" + onDelete={({ ids }) => bulkPermanentlyDeleteTaskLists(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } \ No newline at end of file diff --git a/src/modules/admin/components/task/ArchiveTaskTable.jsx b/src/modules/admin/components/task/ArchiveTaskTable.jsx index 1969ed6..ce5780d 100644 --- a/src/modules/admin/components/task/ArchiveTaskTable.jsx +++ b/src/modules/admin/components/task/ArchiveTaskTable.jsx @@ -6,6 +6,7 @@ import { useAdminTask } from "@/contexts/AdminTaskContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/task/archive/columns.config"; import { buildToolbarActions } from "@/modules/admin/config/task_list/task/archive/toolbar.config"; @@ -34,10 +35,14 @@ export default function ArchivedTaskTable() { fetchTaskList, fetchArchivedTasks, fetchTaskFieldValues, restoreTask, bulkRestoreTasks, + permanentlyDeleteTask, + bulkPermanentlyDeleteTasks, } = useAdminTask(); const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const [showGroupsDialog, setShowGroupsDialog] = useState(false); const tableRefsRef = useRef({ @@ -74,6 +79,18 @@ export default function ArchivedTaskTable() { }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedTasks(taskListId, { + page: 1, + limit: pagination?.limit ?? 10, + filters: tableRefsRef.current.getFilters(), + sort: tableRefsRef.current.getSort(), + }); + }; + const exportConfig = useMemo(() => ({ allData: tasks, attributes, @@ -84,6 +101,7 @@ export default function ArchivedTaskTable() { const rowActions = useMemo(() => buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }), [navigate]); const toolbarActions = useMemo(() => buildToolbarActions({ @@ -97,6 +115,7 @@ export default function ArchivedTaskTable() { const selectionActions = useMemo(() => buildSelectionActions({ exportConfig, onBulkRestore: (ids) => setRestoreIds(ids), // ← was onRestoreMany + onBulkDelete: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }), [exportConfig]); @@ -226,6 +245,29 @@ export default function ArchivedTaskTable() { onSuccess={handleSuccess} /> + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Task" + getName={(r) => r?.name} + onDelete={(r) => permanentlyDeleteTask(taskListId, r?.task_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Task" + onDelete={({ ids }) => bulkPermanentlyDeleteTasks(taskListId, ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + diff --git a/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx index ef3e69a..80d2a01 100644 --- a/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx +++ b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx @@ -6,6 +6,7 @@ import { useTiers } from "@/contexts/AdminTiersContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/tiers/plans/archive/columns.config"; import { buildToolbarActions } from "../../config/tiers/plans/archive/toolbar.config"; @@ -20,10 +21,13 @@ export default function ArchivedTierPlansTable() { const { plans, planAttributes, planPagination, setPlanPagination, loading, fetchPlans, restorePlan, bulkRestorePlans, + permanentlyDeletePlan, bulkPermanentlyDeletePlans, fetchPlanPermanentDeleteImpact, } = useTiers(); const [restoreTarget, setRestoreTarget] = useState(null); const [restoreIds, setRestoreIds] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -49,6 +53,7 @@ export default function ArchivedTierPlansTable() { const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }); const toolbarActions = buildToolbarActions({ @@ -65,6 +70,8 @@ export default function ArchivedTierPlansTable() { exportConfig, onRestore: (row) => setRestoreTarget(row), onRestoreMany: (ids) => setRestoreIds(ids), + onDelete: (row) => setDeleteTarget(row), + onDeleteMany: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -85,6 +92,18 @@ export default function ArchivedTierPlansTable() { }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchived({ + page: 1, + limit: planPagination?.limit ?? 10, + filters: tableRefsRef.current.getFilters(), + sort: tableRefsRef.current.getSort(), + }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Plan" + getName={(r) => r?.label} + onDelete={(entity) => permanentlyDeletePlan(entity?.plan_id)} + onImpactCheck={() => fetchPlanPermanentDeleteImpact(deleteTarget?.plan_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Plan" + onDelete={({ ids }) => bulkPermanentlyDeletePlans(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } diff --git a/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx b/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx index ea35d5f..4a731eb 100644 --- a/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx +++ b/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx @@ -5,6 +5,7 @@ import { useUserGroups } from "@/contexts/AdminUserGroupContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildDataColumns, columnPinning } from "../../config/user_groups/archive/columns.config"; import { buildToolbarActions } from "../../config/user_groups/archive/toolbar.config"; @@ -16,6 +17,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchiveGroupTable() { const [restoreTarget, setRestoreTarget] = useState(null); // single: row object const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} }); const navigate = useNavigate(); @@ -29,6 +32,8 @@ export default function ArchiveGroupTable() { fetchArchivedGroups, restoreGroup, restoreGroups, + permanentlyDeleteGroup, + permanentlyDeleteGroups, } = useUserGroups(); const exportConfig = { @@ -41,6 +46,7 @@ export default function ArchiveGroupTable() { const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }); const toolbarActions = buildToolbarActions({ @@ -54,6 +60,8 @@ export default function ArchiveGroupTable() { exportConfig, restoreGroup: (row) => setRestoreTarget(row), // single restoreGroups: (ids) => setRestoreIds(ids), // bulk + deleteGroup: (row) => setDeleteTarget(row), + deleteGroups: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -66,6 +74,13 @@ export default function ArchiveGroupTable() { fetchArchivedGroups({ page: 1, limit: pagination.limit }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedGroups({ page: 1, limit: pagination.limit }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="Group" + getName={(g) => g?.name} + onDelete={(g) => permanentlyDeleteGroup(g?.group_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Group" + onDelete={permanentlyDeleteGroups} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } \ No newline at end of file diff --git a/src/modules/admin/components/users/ArchiveUserTable.jsx b/src/modules/admin/components/users/ArchiveUserTable.jsx index fe48236..f3e1378 100644 --- a/src/modules/admin/components/users/ArchiveUserTable.jsx +++ b/src/modules/admin/components/users/ArchiveUserTable.jsx @@ -5,6 +5,7 @@ import { useUsers } from "@/contexts/AdminUserContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; +import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog"; import { buildUserColumns, columnPinning } from "../../config/users/archive/columns.config"; import { buildToolbarActions } from "../../config/users/archive/toolbar.config"; @@ -16,6 +17,8 @@ import { getTimestamp } from "@/utils/timestamp.util"; export default function ArchiveGroupTable() { const [restoreTarget, setRestoreTarget] = useState(null); // single: row object const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteIds, setDeleteIds] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => { } }); const navigate = useNavigate(); @@ -28,7 +31,9 @@ export default function ArchiveGroupTable() { fetchArchivedUsers, fetchUserFieldValues, restoreUser, - restoreUsers + restoreUsers, + permanentlyDeleteUser, + permanentlyDeleteUsers } = useUsers(); // Shared export config — passed into toolbar + selection configs @@ -42,6 +47,7 @@ export default function ArchiveGroupTable() { const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), + onDelete: (row) => setDeleteTarget(row), }); const toolbarActions = buildToolbarActions({ fetchArchivedUsers, pagination, exportConfig, navigate, @@ -53,6 +59,8 @@ export default function ArchiveGroupTable() { exportConfig, restoreUser: (row) => setRestoreTarget(row), restoreUsers: (ids) => setRestoreIds(ids), + onDeleteUser: (row) => setDeleteTarget(row), + onDeleteUsers: (ids) => setDeleteIds(ids), getTableInstance: () => tableRefsRef.current.tableInstance, }); @@ -65,6 +73,13 @@ export default function ArchiveGroupTable() { fetchArchivedUsers({ page: 1, limit: pagination.limit }); }; + const handleDeleteSuccess = () => { + setDeleteTarget(null); + setDeleteIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedUsers({ page: 1, limit: pagination.limit }); + }; + return ( <> + + {/* Single permanent delete */} + !v && setDeleteTarget(null)} + entity={deleteTarget} + entityLabel="User" + getName={(u) => u?.personal_info?.name?.full_name ?? u?.email} + onDelete={(u) => permanentlyDeleteUser(u?.user_id)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + {/* Bulk permanent delete */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="User" + onDelete={permanentlyDeleteUsers} + loading={loading} + onSuccess={handleDeleteSuccess} + /> ); } \ No newline at end of file diff --git a/src/modules/admin/components/users/UserTable.jsx b/src/modules/admin/components/users/UserTable.jsx index 9ad2634..f729617 100644 --- a/src/modules/admin/components/users/UserTable.jsx +++ b/src/modules/admin/components/users/UserTable.jsx @@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom"; import { useUsers } from "@/contexts/AdminUserContext"; import { useDashboard } from "@/contexts/AdminDashboardContext"; +import { useAuth } from "@/contexts/AuthContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; @@ -38,6 +39,8 @@ export default function UsersTable() { const navigate = useNavigate(); + const { user: currentUser } = useAuth(); + const { users, attributes, pagination, setPagination, loading, fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers, @@ -171,6 +174,7 @@ export default function UsersTable() { selectionActions={selectionActions} recordLabel="user" emptyMessage="No users match the current filters." + enableRowSelection={(row) => row.original.user_id !== currentUser?.user_id} /> {/* Single archive */} diff --git a/src/modules/admin/config/advertisements/archive/columns.config.jsx b/src/modules/admin/config/advertisements/archive/columns.config.jsx new file mode 100644 index 0000000..ad04146 --- /dev/null +++ b/src/modules/admin/config/advertisements/archive/columns.config.jsx @@ -0,0 +1,29 @@ +// config/advertisements/archive/columns.config.jsx + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = {}; + +/** + * 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 + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Advertisement Actions" }), + ]; +} diff --git a/src/modules/admin/config/advertisements/archive/rowActions.config.jsx b/src/modules/admin/config/advertisements/archive/rowActions.config.jsx new file mode 100644 index 0000000..d245e8a --- /dev/null +++ b/src/modules/admin/config/advertisements/archive/rowActions.config.jsx @@ -0,0 +1,26 @@ +// config/advertisements/archive/rowActions.config.jsx +import { RotateCcw, Trash2 } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.onRestore (row) => void — open restore dialog + * @param {Function} deps.onDelete (row) => void — open permanent-delete dialog + */ +export function buildRowActions({ onRestore, onDelete }) { + return [ + { + key: "restore", + label: "Restore", + className: "text-emerald-600 focus:text-emerald-600", + icon: , + onClick: (row) => onRestore(row), + }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, + ]; +} diff --git a/src/modules/admin/config/advertisements/archive/selection.config.jsx b/src/modules/admin/config/advertisements/archive/selection.config.jsx new file mode 100644 index 0000000..0174ac4 --- /dev/null +++ b/src/modules/admin/config/advertisements/archive/selection.config.jsx @@ -0,0 +1,43 @@ +// config/advertisements/archive/selection.config.jsx +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, onSingleDelete, onBulkDelete, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700", + onClick: (rows) => { + const ids = rows.map((r) => r.advertisement_id); + ids.length === 1 + ? onSingleRestore(rows[0]) + : onBulkRestore(ids); + }, + }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.advertisement_id); + ids.length === 1 + ? onSingleDelete(rows[0]) + : onBulkDelete(ids); + }, + }, + ]; +} diff --git a/src/modules/admin/config/advertisements/archive/toolbar.config.jsx b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx new file mode 100644 index 0000000..3a90e27 --- /dev/null +++ b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx @@ -0,0 +1,42 @@ +// config/advertisements/archive/toolbar.config.jsx +import { RefreshCw, Download } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchAdvertisements + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchAdvertisements({ + page: 1, + limit: pagination.limit, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance(), + }), + }, + ]; +} diff --git a/src/modules/admin/config/assets/archive/rowActions.config.jsx b/src/modules/admin/config/assets/archive/rowActions.config.jsx index 03112a9..8e913b2 100644 --- a/src/modules/admin/config/assets/archive/rowActions.config.jsx +++ b/src/modules/admin/config/assets/archive/rowActions.config.jsx @@ -1,5 +1,5 @@ // modules/admin/config/assets/rowActions.config.jsx -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -7,7 +7,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.onEdit (row) → void — navigate to edit page * @param {Function} deps.onArchive (row) → void — open archive dialog */ -export function buildRowActions({ onRestore }) { +export function buildRowActions({ onRestore, onDelete }) { return [ { key: "restore", @@ -17,5 +17,12 @@ export function buildRowActions({ onRestore }) { onClick: (row) => onRestore(row), hidden: (row) => row.is_active, }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/assets/archive/selection.config.jsx b/src/modules/admin/config/assets/archive/selection.config.jsx index 63420d2..e74fdc7 100644 --- a/src/modules/admin/config/assets/archive/selection.config.jsx +++ b/src/modules/admin/config/assets/archive/selection.config.jsx @@ -1,8 +1,8 @@ // config/assets/archive/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; -export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, getTableInstance }) { +export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, onSingleDelete, onBulkDelete, getTableInstance }) { return [ { key: "export-selected", @@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRes : onBulkRestore(ids); }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.asset_id); + ids.length === 1 + ? onSingleDelete(rows[0]) + : onBulkDelete(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/archive/rowActions.config.jsx b/src/modules/admin/config/courses/archive/rowActions.config.jsx index 9d44130..9282afd 100644 --- a/src/modules/admin/config/courses/archive/rowActions.config.jsx +++ b/src/modules/admin/config/courses/archive/rowActions.config.jsx @@ -1,5 +1,5 @@ // modules/admin/config/assets/rowActions.config.jsx -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -7,7 +7,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.onEdit (row) → void — navigate to edit page * @param {Function} deps.onArchive (row) → void — open archive dialog */ -export function buildRowActions({ onRestore }) { +export function buildRowActions({ onRestore, onDelete }) { return [ { key: "restore", @@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) { icon: , onClick: (row) => onRestore(row), }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/archive/selection.config.jsx b/src/modules/admin/config/courses/archive/selection.config.jsx index 4635601..4777a84 100644 --- a/src/modules/admin/config/courses/archive/selection.config.jsx +++ b/src/modules/admin/config/courses/archive/selection.config.jsx @@ -1,8 +1,8 @@ // config/assets/archive/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; -export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, getTableInstance }) { +export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, deleteCourse, deleteCourses, getTableInstance }) { return [ { key: "export-selected", @@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, restoreCourse, restoreCour : restoreCourses(ids); }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.course_id); + ids.length === 1 + ? deleteCourse(rows[0]) + : deleteCourses(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/lessons/archive/rowActions.config.jsx b/src/modules/admin/config/courses/lessons/archive/rowActions.config.jsx index 9d44130..9282afd 100644 --- a/src/modules/admin/config/courses/lessons/archive/rowActions.config.jsx +++ b/src/modules/admin/config/courses/lessons/archive/rowActions.config.jsx @@ -1,5 +1,5 @@ // modules/admin/config/assets/rowActions.config.jsx -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -7,7 +7,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.onEdit (row) → void — navigate to edit page * @param {Function} deps.onArchive (row) → void — open archive dialog */ -export function buildRowActions({ onRestore }) { +export function buildRowActions({ onRestore, onDelete }) { return [ { key: "restore", @@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) { icon: , onClick: (row) => onRestore(row), }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/lessons/archive/selection.config.jsx b/src/modules/admin/config/courses/lessons/archive/selection.config.jsx index 841b1df..7664878 100644 --- a/src/modules/admin/config/courses/lessons/archive/selection.config.jsx +++ b/src/modules/admin/config/courses/lessons/archive/selection.config.jsx @@ -1,8 +1,8 @@ // config/assets/archive/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; -export function buildSelectionActions({ exportConfig, restoreLesson, restoreLessons, getTableInstance }) { +export function buildSelectionActions({ exportConfig, restoreLesson, restoreLessons, deleteLesson, deleteLessons, getTableInstance }) { return [ { key: "export-selected", @@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, restoreLesson, restoreLess : restoreLessons(ids); }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.lesson_id); + ids.length === 1 + ? deleteLesson(rows[0]) + : deleteLessons(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/units/archive/rowActions.config.jsx b/src/modules/admin/config/courses/units/archive/rowActions.config.jsx index 9d44130..9282afd 100644 --- a/src/modules/admin/config/courses/units/archive/rowActions.config.jsx +++ b/src/modules/admin/config/courses/units/archive/rowActions.config.jsx @@ -1,5 +1,5 @@ // modules/admin/config/assets/rowActions.config.jsx -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -7,7 +7,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.onEdit (row) → void — navigate to edit page * @param {Function} deps.onArchive (row) → void — open archive dialog */ -export function buildRowActions({ onRestore }) { +export function buildRowActions({ onRestore, onDelete }) { return [ { key: "restore", @@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) { icon: , onClick: (row) => onRestore(row), }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/courses/units/archive/selection.config.jsx b/src/modules/admin/config/courses/units/archive/selection.config.jsx index 8ca78fe..d0c5c68 100644 --- a/src/modules/admin/config/courses/units/archive/selection.config.jsx +++ b/src/modules/admin/config/courses/units/archive/selection.config.jsx @@ -1,8 +1,8 @@ // config/assets/archive/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; -export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, getTableInstance }) { +export function buildSelectionActions({ exportConfig, restoreUnit, restoreUnits, deleteUnit, deleteUnits, getTableInstance }) { return [ { key: "export-selected", @@ -23,8 +23,20 @@ export function buildSelectionActions({ exportConfig, restoreCourse, restoreCour onClick: (rows) => { const ids = rows.map((r) => r.unit_id); ids.length === 1 - ? restoreCourse(rows[0]) - : restoreCourses(ids); + ? restoreUnit(rows[0]) + : restoreUnits(ids); + }, + }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.unit_id); + ids.length === 1 + ? deleteUnit(rows[0]) + : deleteUnits(ids); }, }, ]; diff --git a/src/modules/admin/config/notifications/archive/columns.config.jsx b/src/modules/admin/config/notifications/archive/columns.config.jsx new file mode 100644 index 0000000..9298460 --- /dev/null +++ b/src/modules/admin/config/notifications/archive/columns.config.jsx @@ -0,0 +1,29 @@ +// config/notifications/archive/columns.config.jsx + +import { buildColumns } from "@/utils/table.util"; +import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; +import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +const cellOverrides = {}; + +/** + * Builds the full column array for the Archived Notification Broadcasts table. + * + * @param {Array} attributes Field definitions from the server (drives data columns) + * @param {Array} rowActions Row-level kebab action definitions + * @returns {Array} TanStack column definitions + */ +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Notification Actions" }), + ]; +} diff --git a/src/modules/admin/config/notifications/archive/rowActions.config.jsx b/src/modules/admin/config/notifications/archive/rowActions.config.jsx new file mode 100644 index 0000000..93b9e9e --- /dev/null +++ b/src/modules/admin/config/notifications/archive/rowActions.config.jsx @@ -0,0 +1,26 @@ +// config/notifications/archive/rowActions.config.jsx +import { RotateCcw, Trash2 } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.onRestore (row) => void — open restore dialog + * @param {Function} deps.onDelete (row) => void — open permanent-delete dialog + */ +export function buildRowActions({ onRestore, onDelete }) { + return [ + { + key: "restore", + label: "Restore", + className: "text-emerald-600 focus:text-emerald-600", + icon: , + onClick: (row) => onRestore(row), + }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, + ]; +} diff --git a/src/modules/admin/config/notifications/archive/selection.config.jsx b/src/modules/admin/config/notifications/archive/selection.config.jsx new file mode 100644 index 0000000..30e0afd --- /dev/null +++ b/src/modules/admin/config/notifications/archive/selection.config.jsx @@ -0,0 +1,43 @@ +// config/notifications/archive/selection.config.jsx +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, onSingleDelete, onBulkDelete, getTableInstance }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700", + onClick: (rows) => { + const ids = rows.map((r) => r.broadcast_id); + ids.length === 1 + ? onSingleRestore(rows[0]) + : onBulkRestore(ids); + }, + }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.broadcast_id); + ids.length === 1 + ? onSingleDelete(rows[0]) + : onBulkDelete(ids); + }, + }, + ]; +} diff --git a/src/modules/admin/config/notifications/archive/toolbar.config.jsx b/src/modules/admin/config/notifications/archive/toolbar.config.jsx new file mode 100644 index 0000000..fb6c823 --- /dev/null +++ b/src/modules/admin/config/notifications/archive/toolbar.config.jsx @@ -0,0 +1,42 @@ +// config/notifications/archive/toolbar.config.jsx +import { RefreshCw, Download } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchBroadcasts + * @param {Object} deps.pagination + * @param {Object} deps.exportConfig + * @param {Function} deps.navigate + * @param {Function} deps.getFilters + * @param {Function} deps.getSort + * @param {Function} deps.getTableInstance + */ +export function buildToolbarActions({ fetchBroadcasts, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => + fetchBroadcasts({ + page: 1, + limit: pagination.limit, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => + exportTableToExcel({ + ...exportConfig, + tableInstance: table ?? getTableInstance(), + }), + }, + ]; +} diff --git a/src/modules/admin/config/task_list/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/archive/rowActions.config.jsx index dd1c962..57aa6e4 100644 --- a/src/modules/admin/config/task_list/archive/rowActions.config.jsx +++ b/src/modules/admin/config/task_list/archive/rowActions.config.jsx @@ -1,6 +1,6 @@ -import { RotateCcw, Eye } from "lucide-react"; +import { RotateCcw, Eye, Trash2 } from "lucide-react"; -export function buildRowActions({ navigate, onRestore }) { +export function buildRowActions({ navigate, onRestore, onDelete }) { return [ // { // key: "view", @@ -14,5 +14,12 @@ export function buildRowActions({ navigate, onRestore }) { icon: , onClick: (row) => onRestore(row), }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/task_list/archive/selection.config.jsx b/src/modules/admin/config/task_list/archive/selection.config.jsx index 3053a1a..065ba8d 100644 --- a/src/modules/admin/config/task_list/archive/selection.config.jsx +++ b/src/modules/admin/config/task_list/archive/selection.config.jsx @@ -1,9 +1,10 @@ -import { Download, RotateCcw } from "lucide-react"; +import { Download, RotateCcw, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; export function buildSelectionActions({ exportConfig, onBulkRestore, + onBulkDelete, getTableInstance, }) { return [ @@ -28,5 +29,16 @@ export function buildSelectionActions({ onBulkRestore?.(ids); }, }, + { + key: "delete-selected", + label: "Delete Selected", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.task_list_id).filter(Boolean); + if (!ids.length) return; + onBulkDelete?.(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/task_list/rowActions.config.jsx b/src/modules/admin/config/task_list/rowActions.config.jsx index e574c1d..0de9834 100644 --- a/src/modules/admin/config/task_list/rowActions.config.jsx +++ b/src/modules/admin/config/task_list/rowActions.config.jsx @@ -1,21 +1,15 @@ -import { Eye, Pencil, Archive, ArchiveRestore, NotebookPen, Info } from "lucide-react"; +import { Eye, Archive, ArchiveRestore, NotebookPen, Info } from "lucide-react"; export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) { return [ { - key: "edit", + key: "view", label: "View Info", icon: , onClick: (row) => navigate(`${row.task_list_id}/view`), }, { - key: "edit", - label: "Edit Info", - icon: , - onClick: (row) => navigate(`${row.task_list_id}/edit`), - }, - { - key: "view", + key: "tasks", label: "View Tasks", icon: , onClick: (row) => navigate(`${row.task_list_id}/tasks`), diff --git a/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx index ac6a925..30a49e9 100644 --- a/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx +++ b/src/modules/admin/config/task_list/task/archive/rowActions.config.jsx @@ -3,7 +3,7 @@ // // Each onClick receives the row's data object from buildRowActionsColumn. -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.archiveUser Archive handler from useManagement * @returns {Array} rowActions */ -export function buildRowActions({ navigate, onRestore }) { +export function buildRowActions({ navigate, onRestore, onDelete }) { return [ { key: "restore", @@ -20,5 +20,12 @@ export function buildRowActions({ navigate, onRestore }) { onClick: (row) => onRestore(row), hidden: (row) => row.is_active, }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/archive/selection.config.jsx b/src/modules/admin/config/task_list/task/archive/selection.config.jsx index 3d9938d..014ca00 100644 --- a/src/modules/admin/config/task_list/task/archive/selection.config.jsx +++ b/src/modules/admin/config/task_list/task/archive/selection.config.jsx @@ -1,9 +1,10 @@ -import { Download, RotateCcw } from "lucide-react"; +import { Download, RotateCcw, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; export function buildSelectionActions({ exportConfig, onBulkRestore, + onBulkDelete, getTableInstance, }) { return [ @@ -28,5 +29,16 @@ export function buildSelectionActions({ onBulkRestore?.(ids); }, }, + { + key: "delete-selected", + label: "Delete Selected", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.task_id).filter(Boolean); + if (!ids.length) return; + onBulkDelete?.(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/task_list/task/rowActions.config.jsx b/src/modules/admin/config/task_list/task/rowActions.config.jsx index 635d248..6675291 100644 --- a/src/modules/admin/config/task_list/task/rowActions.config.jsx +++ b/src/modules/admin/config/task_list/task/rowActions.config.jsx @@ -1,19 +1,13 @@ -import { Eye, Pencil, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react"; +import { Eye, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react"; export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) { return [ { - key: "edit", + key: "view", label: "View Info", icon: , onClick: (row) => navigate(`${row.task_id}/view`), }, - { - key: "edit", - label: "Edit Info", - icon: , - onClick: (row) => navigate(`${row.task_id}/edit`), - }, { key: "completions", label: "Completions", diff --git a/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx b/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx index cee6fed..6999477 100644 --- a/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx +++ b/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx @@ -1,6 +1,6 @@ -import { Eye, RotateCcw } from "lucide-react"; +import { Eye, RotateCcw, Trash2 } from "lucide-react"; -export function buildRowActions({ navigate, onRestore }) { +export function buildRowActions({ navigate, onRestore, onDelete }) { return [ { key: "view", @@ -16,5 +16,12 @@ export function buildRowActions({ navigate, onRestore }) { onClick: (row) => onRestore(row), separator: true, }, + { + key: "delete", + label: "Delete", + icon: , + className: "text-destructive focus:text-destructive", + onClick: (row) => onDelete(row), + }, ]; } diff --git a/src/modules/admin/config/tiers/plans/archive/selection.config.jsx b/src/modules/admin/config/tiers/plans/archive/selection.config.jsx index e759710..a28d16c 100644 --- a/src/modules/admin/config/tiers/plans/archive/selection.config.jsx +++ b/src/modules/admin/config/tiers/plans/archive/selection.config.jsx @@ -1,10 +1,12 @@ -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; export function buildSelectionActions({ exportConfig, onRestore, onRestoreMany, + onDelete, + onDeleteMany, getTableInstance, }) { return [ @@ -29,5 +31,15 @@ export function buildSelectionActions({ ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids); }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.plan_id); + ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids); + }, + }, ]; } diff --git a/src/modules/admin/config/user_groups/archive/rowActions.config.jsx b/src/modules/admin/config/user_groups/archive/rowActions.config.jsx index 3d96d9f..586fd16 100644 --- a/src/modules/admin/config/user_groups/archive/rowActions.config.jsx +++ b/src/modules/admin/config/user_groups/archive/rowActions.config.jsx @@ -3,7 +3,7 @@ // // Each onClick receives the row's data object from buildRowActionsColumn. -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.archiveUser Archive handler from useManagement * @returns {Array} rowActions */ -export function buildRowActions({ navigate, onRestore }) { +export function buildRowActions({ navigate, onRestore, onDelete }) { return [ { key: "restore", @@ -21,5 +21,12 @@ export function buildRowActions({ navigate, onRestore }) { onClick: (row) => onRestore(row), hidden: (row) => row.is_active, }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/archive/selection.config.jsx b/src/modules/admin/config/user_groups/archive/selection.config.jsx index 1a7d2d2..db9be66 100644 --- a/src/modules/admin/config/user_groups/archive/selection.config.jsx +++ b/src/modules/admin/config/user_groups/archive/selection.config.jsx @@ -1,5 +1,5 @@ // config/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; /** @@ -8,7 +8,7 @@ import { exportTableToExcel } from "@/utils/excel.util"; * @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.deleteUser Delete handler from useManagement */ -export function buildSelectionActions({ exportConfig, restoreGroup, restoreGroups, getTableInstance }) { +export function buildSelectionActions({ exportConfig, restoreGroup, restoreGroups, deleteGroup, deleteGroups, getTableInstance }) { return [ { key: "export-selected", @@ -30,5 +30,18 @@ export function buildSelectionActions({ exportConfig, restoreGroup, restoreGroup : restoreGroups(ids); // opens bulk dialog }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: + "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.group_id); + ids.length === 1 + ? deleteGroup(rows[0]) + : deleteGroups(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/rowActions.config.jsx b/src/modules/admin/config/user_groups/rowActions.config.jsx index 9c57b01..eae57bf 100644 --- a/src/modules/admin/config/user_groups/rowActions.config.jsx +++ b/src/modules/admin/config/user_groups/rowActions.config.jsx @@ -32,6 +32,7 @@ export function buildRowActions({ navigate, onEdit, onArchive }) { icon: , onClick: (row) => onArchive(row), hidden: (row) => !row.is_active, + disabled: (row) => row.group_code === "NOGRP", separator: true, }, ]; diff --git a/src/modules/admin/config/user_groups/selection.config.jsx b/src/modules/admin/config/user_groups/selection.config.jsx index b03deda..b5cdc0b 100644 --- a/src/modules/admin/config/user_groups/selection.config.jsx +++ b/src/modules/admin/config/user_groups/selection.config.jsx @@ -28,7 +28,8 @@ export function buildSelectionActions({ exportConfig, archiveGroup, archiveGroup ? archiveGroup(rows[0]) // opens single dialog : archiveGroups(ids); // opens bulk dialog }, - hidden: (rows) => rows.every((r) => r.status === "archived"), + hidden: (rows) => rows.every((r) => !r.is_active), + disabled: (rows) => rows.some((r) => r.group_code === "NOGRP"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/users/archive/rowActions.config.jsx b/src/modules/admin/config/users/archive/rowActions.config.jsx index 3d96d9f..586fd16 100644 --- a/src/modules/admin/config/users/archive/rowActions.config.jsx +++ b/src/modules/admin/config/users/archive/rowActions.config.jsx @@ -3,7 +3,7 @@ // // Each onClick receives the row's data object from buildRowActionsColumn. -import { RotateCcw } from "lucide-react"; +import { RotateCcw, Trash2 } from "lucide-react"; /** * @param {Object} deps @@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react"; * @param {Function} deps.archiveUser Archive handler from useManagement * @returns {Array} rowActions */ -export function buildRowActions({ navigate, onRestore }) { +export function buildRowActions({ navigate, onRestore, onDelete }) { return [ { key: "restore", @@ -21,5 +21,12 @@ export function buildRowActions({ navigate, onRestore }) { onClick: (row) => onRestore(row), hidden: (row) => row.is_active, }, + { + key: "delete", + label: "Delete", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onDelete(row), + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/users/archive/selection.config.jsx b/src/modules/admin/config/users/archive/selection.config.jsx index ee38739..9cf3749 100644 --- a/src/modules/admin/config/users/archive/selection.config.jsx +++ b/src/modules/admin/config/users/archive/selection.config.jsx @@ -1,5 +1,5 @@ // config/selection.config.jsx -import { Download, ArchiveRestore } from "lucide-react"; +import { Download, ArchiveRestore, Trash2 } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; /** @@ -8,7 +8,7 @@ import { exportTableToExcel } from "@/utils/excel.util"; * @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.deleteUser Delete handler from useManagement */ -export function buildSelectionActions({ exportConfig, restoreUser, restoreUsers, getTableInstance }) { +export function buildSelectionActions({ exportConfig, restoreUser, restoreUsers, onDeleteUser, onDeleteUsers, getTableInstance }) { return [ { key: "export-selected", @@ -30,5 +30,18 @@ export function buildSelectionActions({ exportConfig, restoreUser, restoreUsers, : restoreUsers(ids); // opens bulk dialog }, }, + { + key: "delete-selected", + label: "Delete", + icon: , + className: + "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.user_id); + ids.length === 1 + ? onDeleteUser(rows[0]) + : onDeleteUsers(ids); + }, + }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/users/selection.config.jsx b/src/modules/admin/config/users/selection.config.jsx index eb9738e..1e32a04 100644 --- a/src/modules/admin/config/users/selection.config.jsx +++ b/src/modules/admin/config/users/selection.config.jsx @@ -26,11 +26,8 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, key: "unban-selected", label: "Unban", icon: , - onClick: (rows) => { - const ids = rows.filter((r) => r.is_banned).map((r) => r.user_id); - if (ids.length) unbanUsers(ids); - }, - hidden: (rows) => rows.every((r) => !r.is_banned), + onClick: (rows) => unbanUsers(rows.map((r) => r.user_id)), + disabled: (rows) => !rows.every((r) => r.is_banned), }, { key: "archive-selected", diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx index f7415cb..b47c44c 100644 --- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx +++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react"; +import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2, Archive } from "lucide-react"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { resolveAssetSrc } from "@/utils/media.util"; @@ -69,10 +69,16 @@ export default function AdvertisementList() {

Advertisements

Manage public-facing banners, popups, and promotional placements

- +
+ + +
{/* ── Stat cards ─────────────────────────────────────────────── */} diff --git a/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx b/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx new file mode 100644 index 0000000..9753adb --- /dev/null +++ b/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx @@ -0,0 +1,26 @@ +import { House } from "lucide-react"; + +import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; +import ArchivedAdvertisementsTable from "../../components/advertisements/ArchivedAdvertisementsTable"; + +export default function ArchivedAdvertisementList() { + const items = [ + { label: "Home", icon: , to: `/admin` }, + { label: "Advertisements", to: `/admin/advertisements` }, + { label: "Archived" }, + ]; + + return ( +
+
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/modules/admin/pages/email_templates/AddEmailTemplate.jsx b/src/modules/admin/pages/email_templates/AddEmailTemplate.jsx deleted file mode 100644 index 0bfec47..0000000 --- a/src/modules/admin/pages/email_templates/AddEmailTemplate.jsx +++ /dev/null @@ -1,367 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useForm, Controller } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import ReactMarkdown from "react-markdown"; -import { ChevronRight, ChevronLeft, Check, Tags, FileText, Code2, ClipboardCheck, House, Eye, Send } from "lucide-react"; - -import { useAdminEmailTemplates, AdminEmailTemplateProvider } from "@/contexts/AdminEmailTemplateContext"; -import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory } from "@/data/emailTemplateCategories.data"; -import { markdownToHtml } from "@/utils/markdownToHtml.util"; -import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { Spinner } from "@/components/ui/spinner"; -import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; -import { PageMeta } from "@/contexts/MetadataContext"; - -// ─── Zod schema ─────────────────────────────────────────────────────────────── -// body_markdown is what the admin actually authors — converted to html_body -// (the column services/email.service.js reads) right before submission. -const emailTemplateSchema = z.object({ - category: z.enum(["announcement", "advertisement", "system", "other"]), - type: z.string().min(1, "Type is required").regex(/^[A-Z][A-Z0-9_]*$/, "Uppercase letters, numbers or underscores only, starting with a letter."), - label: z.string().min(1, "Label is required"), - subject: z.string().min(1, "Subject is required"), - body_markdown: z.string().min(1, "Body is required"), -}); - -// ─── Steps ──────────────────────────────────────────────────────────────────── -const STEPS = [ - { id: 0, label: "Category", icon: Tags, fields: ["category"] }, - { id: 1, label: "Details", icon: FileText, fields: ["type", "label"] }, - { id: 2, label: "Content", icon: Code2, fields: ["subject", "body_markdown"] }, - { id: 3, label: "Review", icon: ClipboardCheck, fields: [] }, -]; - -const DEFAULT_VALUES = { - category: "", - type: "", - label: "", - subject: "", - body_markdown: "", -}; - -function Field({ label, required, error, children, hint }) { - return ( -
- - {children} - {hint && !error &&

{hint}

} - {error &&

{error}

} -
- ); -} - -// ─── Step 1 — Category ──────────────────────────────────────────────────────── -function StepCategory({ control, error }) { - return ( -
-

- What is this email for? This just helps organize templates in the list — it doesn't change how or when the email is sent. -

- ( -
- {EMAIL_TEMPLATE_CATEGORIES.map((cat) => { - const Icon = cat.icon; - const selected = field.value === cat.value; - return ( - - ); - })} -
- )} - /> - {error &&

{error}

} -
- ); -} - -// ─── Step 2 — Details ───────────────────────────────────────────────────────── -function StepDetails({ register, errors, typeValue }) { - return ( -
- - v.toUpperCase() })} - placeholder="e.g. INVOICE_RECEIPT" - style={{ textTransform: "uppercase" }} - /> - - - - - {typeValue && ( -
- Custom templates aren't triggered automatically — a developer needs to call{" "} - sendEmail({"{"} type: "{typeValue}", data {"}"}) from code. -
- )} -
- ); -} - -// ─── Step 3 — Content ───────────────────────────────────────────────────────── -function StepContent({ register, errors, bodyMarkdown }) { - const [showPreview, setShowPreview] = useState(false); - - return ( -
- - - - -
-
- -
- - -
-
-

- Write this in Markdown — it's converted to HTML automatically when you save (mandatory - storage format; only HTML is ever sent). Header, footer and signature are fixed and added automatically; - this box is just the message content in between. Reference dynamic values with{" "} - {"{{placeholder}}"} tokens. -

- {showPreview ? ( -
- {bodyMarkdown?.trim() ? {bodyMarkdown} :

Nothing to preview yet.

} -
- ) : ( -