mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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: <PermanentDeleteDialog entity={rowObject} getName={(r) => r.name} ... />
|
||||||
|
* Bulk: <PermanentDeleteDialog ids={[1, 2, 3]} entityLabel="Group" ... />
|
||||||
|
*
|
||||||
|
* @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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Permanently Delete {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
|
||||||
|
{impactLoading ? (
|
||||||
|
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||||
|
<Spinner className="size-4" /> Checking impact…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to permanently delete{" "}
|
||||||
|
<span className="font-medium text-foreground">{displayName}</span>?{" "}
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
|
||||||
|
{hasImpact && (
|
||||||
|
<div className="mt-3 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 font-medium text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
This will also permanently remove related data
|
||||||
|
</div>
|
||||||
|
<ul className="ml-5 list-disc text-sm text-amber-800 dark:text-amber-300 space-y-0.5">
|
||||||
|
{impacts.map((impact, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-semibold">{impact.count}</span> {impact.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-400 pt-0.5">
|
||||||
|
Deleting anyway will permanently remove all of the above along with it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs text-destructive">
|
||||||
|
This is different from Archive — there is no Restore after this.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading || impactLoading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{loading && <Spinner className="size-4 mr-2" />}
|
||||||
|
Delete{isBulk ? ` ${count}` : ""} Permanently
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="rounded-md border bg-muted/40 p-3 text-xs">
|
|
||||||
<p className="font-medium text-foreground mb-2">Quick Markdown reference</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
|
||||||
{ROWS.map((r) => (
|
|
||||||
<div key={r.md} className="flex items-center gap-2 min-w-0">
|
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded shrink-0">{r.md}</code>
|
|
||||||
<span className="text-muted-foreground truncate">→ {r.out}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens still work anywhere in the text.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Send to Recipients</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Send <span className="font-semibold text-foreground">{template?.label}</span> as a real email.
|
|
||||||
Delivery is paced in the background — this won't block or time out.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Audience</Label>
|
|
||||||
<Select value={targetType} onValueChange={(v) => { setTargetType(v); setTargetId(null); }}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Select an audience" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TARGET_TYPE_OPTIONS.map((t) => (
|
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{targetType && (
|
|
||||||
<p className="text-xs text-muted-foreground">{TARGET_TYPE_MAP[targetType]?.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{needsTarget && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Target</Label>
|
|
||||||
<BroadcastTargetPicker targetType={targetType} value={targetId} onChange={setTargetId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
|
|
||||||
<Button onClick={handleSend} disabled={loading}>
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -32,6 +32,7 @@ export default function DataTable({
|
|||||||
pageSizeOptions = pageSizes,
|
pageSizeOptions = pageSizes,
|
||||||
columnPinning = { right: [], left: [] },
|
columnPinning = { right: [], left: [] },
|
||||||
className = "",
|
className = "",
|
||||||
|
enableRowSelection = true,
|
||||||
}) {
|
}) {
|
||||||
// ─── Refs — always hold latest filters and sort ───────────────────────────
|
// ─── Refs — always hold latest filters and sort ───────────────────────────
|
||||||
const filtersRef = useRef([]);
|
const filtersRef = useRef([]);
|
||||||
@@ -114,7 +115,7 @@ export default function DataTable({
|
|||||||
pageSize: pagination.limit,
|
pageSize: pagination.limit,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
enableRowSelection: true,
|
enableRowSelection,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
manualSorting: true,
|
manualSorting: true,
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
*
|
*
|
||||||
* The header renders a "select all on this page" checkbox with an
|
* The header renders a "select all on this page" checkbox with an
|
||||||
* indeterminate state when only some rows are checked.
|
* 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
|
* @returns {Object} TanStack ColumnDef
|
||||||
*
|
*
|
||||||
@@ -49,14 +53,18 @@ export function buildSelectionColumn() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="flex items-center justify-center px-1">
|
const canSelect = row.getCanSelect();
|
||||||
<Checkbox
|
return (
|
||||||
checked={row.getIsSelected()}
|
<div className="flex items-center justify-center px-1">
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
<Checkbox
|
||||||
aria-label="Select row"
|
checked={row.getIsSelected()}
|
||||||
/>
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
</div>
|
disabled={!canSelect}
|
||||||
),
|
aria-label={canSelect ? "Select row" : "Row cannot be selected"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,30 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
[request]
|
[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 ────────────────────────────
|
// ─── GET /api/admin/advertisements/field-values ────────────────────────────
|
||||||
const fetchAdvertisementFieldValues = useCallback(
|
const fetchAdvertisementFieldValues = useCallback(
|
||||||
(field) =>
|
(field) =>
|
||||||
@@ -207,6 +231,8 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
archiveAdvertisements,
|
archiveAdvertisements,
|
||||||
restoreAdvertisement,
|
restoreAdvertisement,
|
||||||
restoreAdvertisements,
|
restoreAdvertisements,
|
||||||
|
permanentlyDeleteAdvertisement,
|
||||||
|
permanentlyDeleteAdvertisements,
|
||||||
fetchAdvertisementFieldValues
|
fetchAdvertisementFieldValues
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -294,6 +294,35 @@ export function AssetsProvider({ children }) {
|
|||||||
[request]
|
[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 ──────────────────────────────────────
|
// ─── GET /api/admin/assets/field-values ──────────────────────────────────────
|
||||||
const fetchAssetFieldValues = useCallback(
|
const fetchAssetFieldValues = useCallback(
|
||||||
(field) =>
|
(field) =>
|
||||||
@@ -324,6 +353,8 @@ export function AssetsProvider({ children }) {
|
|||||||
archiveAssets,
|
archiveAssets,
|
||||||
restoreAsset,
|
restoreAsset,
|
||||||
restoreAssets,
|
restoreAssets,
|
||||||
|
permanentlyDeleteAsset,
|
||||||
|
permanentlyDeleteAssets,
|
||||||
fetchAssetFieldValues
|
fetchAssetFieldValues
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -199,6 +199,42 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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
|
// PREREQUISITES
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -358,6 +394,39 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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
|
// UNIT QUIZ
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -667,6 +736,29 @@ export function CoursesProvider({ children }) {
|
|||||||
[request],
|
[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
|
// LESSON PAGE
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1064,6 +1156,9 @@ export function CoursesProvider({ children }) {
|
|||||||
fetchArchivedCourse,
|
fetchArchivedCourse,
|
||||||
restoreCourse,
|
restoreCourse,
|
||||||
restoreCourses,
|
restoreCourses,
|
||||||
|
permanentlyDeleteCourse,
|
||||||
|
permanentlyDeleteCourses,
|
||||||
|
fetchCoursePermanentDeleteImpact,
|
||||||
|
|
||||||
// ── prerequisites ──────────────────────────────────────────────────────
|
// ── prerequisites ──────────────────────────────────────────────────────
|
||||||
fetchPrerequisites,
|
fetchPrerequisites,
|
||||||
@@ -1082,6 +1177,9 @@ export function CoursesProvider({ children }) {
|
|||||||
fetchArchivedUnit,
|
fetchArchivedUnit,
|
||||||
restoreUnit,
|
restoreUnit,
|
||||||
restoreUnits,
|
restoreUnits,
|
||||||
|
permanentlyDeleteUnit,
|
||||||
|
permanentlyDeleteUnits,
|
||||||
|
fetchUnitPermanentDeleteImpact,
|
||||||
|
|
||||||
// ── unit quiz ──────────────────────────────────────────────────────────
|
// ── unit quiz ──────────────────────────────────────────────────────────
|
||||||
fetchQuiz,
|
fetchQuiz,
|
||||||
@@ -1119,6 +1217,8 @@ export function CoursesProvider({ children }) {
|
|||||||
fetchArchivedLesson,
|
fetchArchivedLesson,
|
||||||
restoreLesson,
|
restoreLesson,
|
||||||
restoreLessons,
|
restoreLessons,
|
||||||
|
permanentlyDeleteLesson,
|
||||||
|
permanentlyDeleteLessons,
|
||||||
|
|
||||||
// ── lesson page ────────────────────────────────────────────────────────
|
// ── lesson page ────────────────────────────────────────────────────────
|
||||||
fetchLessonPage,
|
fetchLessonPage,
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<AdminEmailBroadcastContext.Provider value={{
|
|
||||||
broadcasts, broadcast, loading,
|
|
||||||
fetchBroadcasts, fetchBroadcastsQuiet, fetchBroadcast,
|
|
||||||
createBroadcast, cancelBroadcast,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AdminEmailBroadcastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<AdminEmailTemplateContext.Provider value={{
|
|
||||||
templates, template, loading,
|
|
||||||
fetchTemplates, fetchTemplate,
|
|
||||||
createTemplate, updateTemplate, deleteTemplate,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AdminEmailTemplateContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -195,6 +195,30 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[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 (
|
return (
|
||||||
<NotificationBroadcastsContext.Provider value={{
|
<NotificationBroadcastsContext.Provider value={{
|
||||||
broadcasts,
|
broadcasts,
|
||||||
@@ -214,6 +238,8 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
archiveBroadcasts,
|
archiveBroadcasts,
|
||||||
restoreBroadcast,
|
restoreBroadcast,
|
||||||
restoreBroadcasts,
|
restoreBroadcasts,
|
||||||
|
permanentlyDeleteBroadcast,
|
||||||
|
permanentlyDeleteBroadcasts,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationBroadcastsContext.Provider>
|
</NotificationBroadcastsContext.Provider>
|
||||||
|
|||||||
@@ -189,6 +189,39 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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(
|
const fetchTaskListFieldValues = useCallback(
|
||||||
(field) =>
|
(field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -354,6 +387,26 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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(
|
const fetchTaskFieldValues = useCallback(
|
||||||
(taskListId, field) =>
|
(taskListId, field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -539,6 +592,8 @@ export function AdminTaskProvider({ children }) {
|
|||||||
createTaskList, updateTaskList,
|
createTaskList, updateTaskList,
|
||||||
archiveTaskList, restoreTaskList,
|
archiveTaskList, restoreTaskList,
|
||||||
bulkArchiveTaskLists, bulkRestoreTaskLists,
|
bulkArchiveTaskLists, bulkRestoreTaskLists,
|
||||||
|
permanentlyDeleteTaskList, bulkPermanentlyDeleteTaskLists,
|
||||||
|
fetchTaskListPermanentDeleteImpact,
|
||||||
fetchTaskListFieldValues,
|
fetchTaskListFieldValues,
|
||||||
|
|
||||||
// ── Task List Group actions ───────────────────────────────────────
|
// ── Task List Group actions ───────────────────────────────────────
|
||||||
@@ -551,6 +606,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
createTask, updateTask,
|
createTask, updateTask,
|
||||||
archiveTask, restoreTask,
|
archiveTask, restoreTask,
|
||||||
bulkArchiveTasks, bulkRestoreTasks,
|
bulkArchiveTasks, bulkRestoreTasks,
|
||||||
|
permanentlyDeleteTask, bulkPermanentlyDeleteTasks,
|
||||||
fetchTaskFieldValues,
|
fetchTaskFieldValues,
|
||||||
|
|
||||||
// ── Completion actions ────────────────────────────────────────────
|
// ── Completion actions ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -118,6 +118,43 @@ export function AdminTiersProvider({ children }) {
|
|||||||
} finally { setLoading(false); }
|
} 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 ───────────────────────────────────────────────────────────
|
// ─── User Tiers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const fetchUserTiers = useCallback(async (userId) => {
|
const fetchUserTiers = useCallback(async (userId) => {
|
||||||
@@ -198,6 +235,7 @@ export function AdminTiersProvider({ children }) {
|
|||||||
createPlan, updatePlan,
|
createPlan, updatePlan,
|
||||||
deletePlan, restorePlan,
|
deletePlan, restorePlan,
|
||||||
bulkDeletePlans, bulkRestorePlans,
|
bulkDeletePlans, bulkRestorePlans,
|
||||||
|
permanentlyDeletePlan, bulkPermanentlyDeletePlans, fetchPlanPermanentDeleteImpact,
|
||||||
|
|
||||||
// User tier actions
|
// User tier actions
|
||||||
fetchUserTiers, grantTier, revokeTier,
|
fetchUserTiers, grantTier, revokeTier,
|
||||||
|
|||||||
@@ -194,6 +194,33 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request]
|
[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 ────────────────────────────────────
|
// ─── GET /api/admin/users/:id/sessions ────────────────────────────────────
|
||||||
const fetchUserSessions = useCallback(
|
const fetchUserSessions = useCallback(
|
||||||
(userId) =>
|
(userId) =>
|
||||||
@@ -380,6 +407,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
addStaffUser, updateUser,
|
addStaffUser, updateUser,
|
||||||
deactivateUser, deactivateUsers,
|
deactivateUser, deactivateUsers,
|
||||||
restoreUser, restoreUsers,
|
restoreUser, restoreUsers,
|
||||||
|
permanentlyDeleteUser, permanentlyDeleteUsers,
|
||||||
fetchUserSessions, terminateSession,
|
fetchUserSessions, terminateSession,
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
fetchUserAchievements,
|
fetchUserAchievements,
|
||||||
|
|||||||
@@ -208,6 +208,33 @@ export function UserGroupProvider({ children }) {
|
|||||||
[request]
|
[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 ────────────────────────────────────
|
// ─── POST /api/admin/groups/:gid/users ────────────────────────────────────
|
||||||
const addUsersToGroup = useCallback(
|
const addUsersToGroup = useCallback(
|
||||||
(gid, user_ids) =>
|
(gid, user_ids) =>
|
||||||
@@ -242,6 +269,7 @@ export function UserGroupProvider({ children }) {
|
|||||||
createGroup, updateGroup,
|
createGroup, updateGroup,
|
||||||
deactivateGroup, deactivateGroups,
|
deactivateGroup, deactivateGroups,
|
||||||
restoreGroup, restoreGroups,
|
restoreGroup, restoreGroups,
|
||||||
|
permanentlyDeleteGroup, permanentlyDeleteGroups,
|
||||||
addUsersToGroup, removeUsersFromGroup,
|
addUsersToGroup, removeUsersFromGroup,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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 = [
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,6 @@ export const ADMIN_SECTIONS = [
|
|||||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
||||||
{ key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" },
|
{ key: "notifications", label: "Notifications", icon: Bell, link: "/admin/notifications" },
|
||||||
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
|
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
|
||||||
{ key: "email-templates", label: "Email Templates", icon: Mail, link: "/admin/email-templates" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -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])
|
|
||||||
);
|
|
||||||
@@ -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";
|
|
||||||
@@ -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"],
|
|
||||||
};
|
|
||||||
@@ -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" },
|
|
||||||
};
|
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Archived Advertisements"
|
||||||
|
data={advertisements}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchArchivedAdvertisements}
|
||||||
|
onFetchFilterData={fetchAdvertisementFieldValues}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="archived advertisement"
|
||||||
|
emptyMessage="No archived advertisements found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single restore ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreTarget}
|
||||||
|
onOpenChange={(v) => !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 ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreIds}
|
||||||
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
|
ids={restoreIds ?? []}
|
||||||
|
entityLabel="Advertisement"
|
||||||
|
onRestore={(ids) => restoreAdvertisements(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleRestoreSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single permanent delete ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Advertisement"
|
||||||
|
onDelete={(ids) => permanentlyDeleteAdvertisements(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useAssets } from "@/contexts/AdminAssetsContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||||
|
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
|
||||||
|
|
||||||
import { buildDataColumns, columnPinning } from "../../config/assets/archive/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/assets/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/assets/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/assets/archive/toolbar.config";
|
||||||
@@ -17,6 +18,8 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
|||||||
export default function ArchivedAssetsTable() {
|
export default function ArchivedAssetsTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -29,7 +32,8 @@ export default function ArchivedAssetsTable() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
assets, attributes, pagination, setPagination, loading,
|
assets, attributes, pagination, setPagination, loading,
|
||||||
fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues
|
fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues,
|
||||||
|
permanentlyDeleteAsset, permanentlyDeleteAssets
|
||||||
} = useAssets();
|
} = useAssets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +53,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -65,6 +70,8 @@ export default function ArchivedAssetsTable() {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
onSingleRestore: (row) => setRestoreTarget(row),
|
onSingleRestore: (row) => setRestoreTarget(row),
|
||||||
onBulkRestore: (ids) => setRestoreIds(ids),
|
onBulkRestore: (ids) => setRestoreIds(ids),
|
||||||
|
onSingleDelete: (row) => setDeleteTarget(row),
|
||||||
|
onBulkDelete: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +87,13 @@ export default function ArchivedAssetsTable() {
|
|||||||
fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSuccess = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -132,6 +146,29 @@ export default function ArchivedAssetsTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Single permanent delete ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Asset"
|
||||||
|
onDelete={(ids) => permanentlyDeleteAssets(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||||
|
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
|
||||||
|
|
||||||
import { buildDataColumns, columnPinning } from "../../config/courses/archive/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/courses/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/courses/archive/toolbar.config";
|
||||||
@@ -18,6 +19,8 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
|||||||
export default function ArchivedCoursesTable() {
|
export default function ArchivedCoursesTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -28,7 +31,11 @@ export default function ArchivedCoursesTable() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
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) => {
|
const handleRefsReady = (refs) => {
|
||||||
tableRefsRef.current = refs;
|
tableRefsRef.current = refs;
|
||||||
@@ -44,6 +51,7 @@ export default function ArchivedCoursesTable() {
|
|||||||
const rowActions = useMemo(() => buildRowActions({
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -60,6 +68,8 @@ export default function ArchivedCoursesTable() {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
restoreCourse: (row) => setRestoreTarget(row), // single
|
restoreCourse: (row) => setRestoreTarget(row), // single
|
||||||
restoreCourses: (ids) => setRestoreIds(ids), // bulk
|
restoreCourses: (ids) => setRestoreIds(ids), // bulk
|
||||||
|
deleteCourse: (row) => setDeleteTarget(row),
|
||||||
|
deleteCourses: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +85,13 @@ export default function ArchivedCoursesTable() {
|
|||||||
fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSuccess = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchivedCourses({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -127,6 +144,30 @@ export default function ArchivedCoursesTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Course"
|
||||||
|
onDelete={permanentlyDeleteCourses}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/courses/lessons/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/lessons/archive/toolbar.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 }) {
|
export default function ArchivedLessonsTable({ courseId, unitId }) {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -29,7 +32,8 @@ export default function ArchivedLessonsTable({ courseId, unitId }) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
course, unit, lessons, attributes, pagination, setPagination, loading,
|
course, unit, lessons, attributes, pagination, setPagination, loading,
|
||||||
fetchArchivedLessons, restoreLesson, restoreLessons, fetchLessonFieldValues
|
fetchArchivedLessons, restoreLesson, restoreLessons, fetchLessonFieldValues,
|
||||||
|
permanentlyDeleteLesson, permanentlyDeleteLessons
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
const handleRefsReady = (refs) => {
|
const handleRefsReady = (refs) => {
|
||||||
@@ -51,6 +55,7 @@ export default function ArchivedLessonsTable({ courseId, unitId }) {
|
|||||||
const rowActions = useMemo(() => buildRowActions({
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
}), [courseId, unitId]);
|
}), [courseId, unitId]);
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -69,6 +74,8 @@ export default function ArchivedLessonsTable({ courseId, unitId }) {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
restoreLesson: (row) => setRestoreTarget(row), // single
|
restoreLesson: (row) => setRestoreTarget(row), // single
|
||||||
restoreLessons: (ids) => setRestoreIds(ids), // bulk
|
restoreLessons: (ids) => setRestoreIds(ids), // bulk
|
||||||
|
deleteLesson: (row) => setDeleteTarget(row),
|
||||||
|
deleteLessons: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +91,13 @@ export default function ArchivedLessonsTable({ courseId, unitId }) {
|
|||||||
fetchArchivedLessons(courseId, unitId, { page: 1, limit: pagination?.limit ?? 10 });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -136,6 +150,29 @@ export default function ArchivedLessonsTable({ courseId, unitId }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Lesson"
|
||||||
|
onDelete={(ids) => permanentlyDeleteLessons(courseId, unitId, ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/courses/units/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/courses/units/archive/toolbar.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 }) {
|
export default function ArchivedUnitsTable({ courseId }) {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -29,7 +32,8 @@ export default function ArchivedUnitsTable({ courseId }) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
course, units, attributes, pagination, setPagination, loading,
|
course, units, attributes, pagination, setPagination, loading,
|
||||||
fetchArchivedUnits, restoreUnit, restoreUnits, fetchUnitFieldValues
|
fetchArchivedUnits, restoreUnit, restoreUnits, fetchUnitFieldValues,
|
||||||
|
permanentlyDeleteUnit, permanentlyDeleteUnits, fetchUnitPermanentDeleteImpact,
|
||||||
} = useCourses();
|
} = useCourses();
|
||||||
|
|
||||||
const handleRefsReady = (refs) => {
|
const handleRefsReady = (refs) => {
|
||||||
@@ -49,7 +53,8 @@ export default function ArchivedUnitsTable({ courseId }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rowActions = useMemo(() => buildRowActions({
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
onRestore: (row) => setRestoreTarget(row)
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
}), [courseId]);
|
}), [courseId]);
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -65,8 +70,10 @@ export default function ArchivedUnitsTable({ courseId }) {
|
|||||||
|
|
||||||
const selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
restoreCourse: (row) => setRestoreTarget(row), // single
|
restoreUnit: (row) => setRestoreTarget(row), // single
|
||||||
restoreCourses: (ids) => setRestoreIds(ids), // bulk
|
restoreUnits: (ids) => setRestoreIds(ids), // bulk
|
||||||
|
deleteUnit: (row) => setDeleteTarget(row),
|
||||||
|
deleteUnits: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +89,13 @@ export default function ArchivedUnitsTable({ courseId }) {
|
|||||||
fetchArchivedUnits(courseId, { page: 1, limit: pagination?.limit ?? 10 });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -134,6 +148,30 @@ export default function ArchivedUnitsTable({ courseId }) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Unit"
|
||||||
|
onDelete={(ids) => permanentlyDeleteUnits(courseId, ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Archived Notifications"
|
||||||
|
data={broadcasts}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchArchivedBroadcasts}
|
||||||
|
onFetchFilterData={() => Promise.resolve([])}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="archived notification"
|
||||||
|
emptyMessage="No archived notifications found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single restore ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreTarget}
|
||||||
|
onOpenChange={(v) => !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 ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreIds}
|
||||||
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
|
ids={restoreIds ?? []}
|
||||||
|
entityLabel="Notification"
|
||||||
|
onRestore={(ids) => restoreBroadcasts(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleRestoreSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single permanent delete ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 ── */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Notification"
|
||||||
|
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useAdminTask } from "@/contexts/AdminTaskContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/archive/columns.config";
|
||||||
import { buildToolbarActions } from "@/modules/admin/config/task_list/archive/toolbar.config";
|
import { buildToolbarActions } from "@/modules/admin/config/task_list/archive/toolbar.config";
|
||||||
@@ -22,10 +23,15 @@ export default function ArchiveTaskListTable() {
|
|||||||
fetchArchivedTaskLists,
|
fetchArchivedTaskLists,
|
||||||
restoreTaskList,
|
restoreTaskList,
|
||||||
bulkRestoreTaskLists,
|
bulkRestoreTaskLists,
|
||||||
|
permanentlyDeleteTaskList,
|
||||||
|
bulkPermanentlyDeleteTaskLists,
|
||||||
|
fetchTaskListPermanentDeleteImpact,
|
||||||
} = useAdminTask();
|
} = useAdminTask();
|
||||||
|
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
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 = {
|
const exportConfig = {
|
||||||
allData: taskLists,
|
allData: taskLists,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -60,6 +78,7 @@ export default function ArchiveTaskListTable() {
|
|||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -74,6 +93,7 @@ export default function ArchiveTaskListTable() {
|
|||||||
const selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onBulkRestore: (ids) => setRestoreIds(ids),
|
onBulkRestore: (ids) => setRestoreIds(ids),
|
||||||
|
onBulkDelete: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,6 +153,30 @@ export default function ArchiveTaskListTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Task List"
|
||||||
|
onDelete={({ ids }) => bulkPermanentlyDeleteTaskLists(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useAdminTask } from "@/contexts/AdminTaskContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "@/modules/admin/config/task_list/task/archive/columns.config";
|
||||||
import { buildToolbarActions } from "@/modules/admin/config/task_list/task/archive/toolbar.config";
|
import { buildToolbarActions } from "@/modules/admin/config/task_list/task/archive/toolbar.config";
|
||||||
@@ -34,10 +35,14 @@ export default function ArchivedTaskTable() {
|
|||||||
fetchTaskList, fetchArchivedTasks, fetchTaskFieldValues,
|
fetchTaskList, fetchArchivedTasks, fetchTaskFieldValues,
|
||||||
restoreTask,
|
restoreTask,
|
||||||
bulkRestoreTasks,
|
bulkRestoreTasks,
|
||||||
|
permanentlyDeleteTask,
|
||||||
|
bulkPermanentlyDeleteTasks,
|
||||||
} = useAdminTask();
|
} = useAdminTask();
|
||||||
|
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
const [showGroupsDialog, setShowGroupsDialog] = useState(false);
|
const [showGroupsDialog, setShowGroupsDialog] = useState(false);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
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(() => ({
|
const exportConfig = useMemo(() => ({
|
||||||
allData: tasks,
|
allData: tasks,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -84,6 +101,7 @@ export default function ArchivedTaskTable() {
|
|||||||
const rowActions = useMemo(() => buildRowActions({
|
const rowActions = useMemo(() => buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
}), [navigate]);
|
}), [navigate]);
|
||||||
|
|
||||||
const toolbarActions = useMemo(() => buildToolbarActions({
|
const toolbarActions = useMemo(() => buildToolbarActions({
|
||||||
@@ -97,6 +115,7 @@ export default function ArchivedTaskTable() {
|
|||||||
const selectionActions = useMemo(() => buildSelectionActions({
|
const selectionActions = useMemo(() => buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onBulkRestore: (ids) => setRestoreIds(ids), // ← was onRestoreMany
|
onBulkRestore: (ids) => setRestoreIds(ids), // ← was onRestoreMany
|
||||||
|
onBulkDelete: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
}), [exportConfig]);
|
}), [exportConfig]);
|
||||||
|
|
||||||
@@ -226,6 +245,29 @@ export default function ArchivedTaskTable() {
|
|||||||
onSuccess={handleSuccess}
|
onSuccess={handleSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Task"
|
||||||
|
onDelete={({ ids }) => bulkPermanentlyDeleteTasks(taskListId, ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
|
<Dialog open={showGroupsDialog} onOpenChange={setShowGroupsDialog}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTiers } from "@/contexts/AdminTiersContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/tiers/plans/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/tiers/plans/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/tiers/plans/archive/toolbar.config";
|
||||||
@@ -20,10 +21,13 @@ export default function ArchivedTierPlansTable() {
|
|||||||
const {
|
const {
|
||||||
plans, planAttributes, planPagination, setPlanPagination,
|
plans, planAttributes, planPagination, setPlanPagination,
|
||||||
loading, fetchPlans, restorePlan, bulkRestorePlans,
|
loading, fetchPlans, restorePlan, bulkRestorePlans,
|
||||||
|
permanentlyDeletePlan, bulkPermanentlyDeletePlans, fetchPlanPermanentDeleteImpact,
|
||||||
} = useTiers();
|
} = useTiers();
|
||||||
|
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
const [restoreIds, setRestoreIds] = useState(null);
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -49,6 +53,7 @@ export default function ArchivedTierPlansTable() {
|
|||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -65,6 +70,8 @@ export default function ArchivedTierPlansTable() {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
|
onDeleteMany: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -135,6 +154,30 @@ export default function ArchivedTierPlansTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Plan"
|
||||||
|
onDelete={({ ids }) => bulkPermanentlyDeletePlans(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useUserGroups } from "@/contexts/AdminUserGroupContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
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 { buildDataColumns, columnPinning } from "../../config/user_groups/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/user_groups/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/user_groups/archive/toolbar.config";
|
||||||
@@ -16,6 +17,8 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
|||||||
export default function ArchiveGroupTable() {
|
export default function ArchiveGroupTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
||||||
const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids
|
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 tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -29,6 +32,8 @@ export default function ArchiveGroupTable() {
|
|||||||
fetchArchivedGroups,
|
fetchArchivedGroups,
|
||||||
restoreGroup,
|
restoreGroup,
|
||||||
restoreGroups,
|
restoreGroups,
|
||||||
|
permanentlyDeleteGroup,
|
||||||
|
permanentlyDeleteGroups,
|
||||||
} = useUserGroups();
|
} = useUserGroups();
|
||||||
|
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
@@ -41,6 +46,7 @@ export default function ArchiveGroupTable() {
|
|||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
@@ -54,6 +60,8 @@ export default function ArchiveGroupTable() {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
restoreGroup: (row) => setRestoreTarget(row), // single
|
restoreGroup: (row) => setRestoreTarget(row), // single
|
||||||
restoreGroups: (ids) => setRestoreIds(ids), // bulk
|
restoreGroups: (ids) => setRestoreIds(ids), // bulk
|
||||||
|
deleteGroup: (row) => setDeleteTarget(row),
|
||||||
|
deleteGroups: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +74,13 @@ export default function ArchiveGroupTable() {
|
|||||||
fetchArchivedGroups({ page: 1, limit: pagination.limit });
|
fetchArchivedGroups({ page: 1, limit: pagination.limit });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSuccess = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchivedGroups({ page: 1, limit: pagination.limit });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -118,6 +133,29 @@ export default function ArchiveGroupTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
|
entity={deleteTarget}
|
||||||
|
entityLabel="Group"
|
||||||
|
getName={(g) => g?.name}
|
||||||
|
onDelete={(g) => permanentlyDeleteGroup(g?.group_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bulk permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Group"
|
||||||
|
onDelete={permanentlyDeleteGroups}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useUsers } from "@/contexts/AdminUserContext";
|
|||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||||
|
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
|
||||||
|
|
||||||
import { buildUserColumns, columnPinning } from "../../config/users/archive/columns.config";
|
import { buildUserColumns, columnPinning } from "../../config/users/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/users/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/users/archive/toolbar.config";
|
||||||
@@ -16,6 +17,8 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
|||||||
export default function ArchiveGroupTable() {
|
export default function ArchiveGroupTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
||||||
const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids
|
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 tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => { } });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ export default function ArchiveGroupTable() {
|
|||||||
fetchArchivedUsers,
|
fetchArchivedUsers,
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
restoreUser,
|
restoreUser,
|
||||||
restoreUsers
|
restoreUsers,
|
||||||
|
permanentlyDeleteUser,
|
||||||
|
permanentlyDeleteUsers
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
// Shared export config — passed into toolbar + selection configs
|
// Shared export config — passed into toolbar + selection configs
|
||||||
@@ -42,6 +47,7 @@ export default function ArchiveGroupTable() {
|
|||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
});
|
});
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchArchivedUsers, pagination, exportConfig, navigate,
|
fetchArchivedUsers, pagination, exportConfig, navigate,
|
||||||
@@ -53,6 +59,8 @@ export default function ArchiveGroupTable() {
|
|||||||
exportConfig,
|
exportConfig,
|
||||||
restoreUser: (row) => setRestoreTarget(row),
|
restoreUser: (row) => setRestoreTarget(row),
|
||||||
restoreUsers: (ids) => setRestoreIds(ids),
|
restoreUsers: (ids) => setRestoreIds(ids),
|
||||||
|
onDeleteUser: (row) => setDeleteTarget(row),
|
||||||
|
onDeleteUsers: (ids) => setDeleteIds(ids),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +73,13 @@ export default function ArchiveGroupTable() {
|
|||||||
fetchArchivedUsers({ page: 1, limit: pagination.limit });
|
fetchArchivedUsers({ page: 1, limit: pagination.limit });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSuccess = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchivedUsers({ page: 1, limit: pagination.limit });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -117,6 +132,29 @@ export default function ArchiveGroupTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !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 */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="User"
|
||||||
|
onDelete={permanentlyDeleteUsers}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useUsers } from "@/contexts/AdminUserContext";
|
import { useUsers } from "@/contexts/AdminUserContext";
|
||||||
import { useDashboard } from "@/contexts/AdminDashboardContext";
|
import { useDashboard } from "@/contexts/AdminDashboardContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
@@ -38,6 +39,8 @@ export default function UsersTable() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
users, attributes, pagination, setPagination, loading,
|
users, attributes, pagination, setPagination, loading,
|
||||||
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
||||||
@@ -171,6 +174,7 @@ export default function UsersTable() {
|
|||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="user"
|
recordLabel="user"
|
||||||
emptyMessage="No users match the current filters."
|
emptyMessage="No users match the current filters."
|
||||||
|
enableRowSelection={(row) => row.original.user_id !== currentUser?.user_id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Single archive */}
|
{/* Single archive */}
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onRestore(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (rows, table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
selectedRows: rows,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "restore-selected",
|
||||||
|
label: "Restore",
|
||||||
|
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||||
|
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: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
label: "Refresh",
|
||||||
|
onClick: () =>
|
||||||
|
fetchAdvertisements({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
label: "Export",
|
||||||
|
onClick: (table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// modules/admin/config/assets/rowActions.config.jsx
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @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.onEdit (row) → void — navigate to edit page
|
||||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onRestore }) {
|
export function buildRowActions({ onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -17,5 +17,12 @@ export function buildRowActions({ onRestore }) {
|
|||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
hidden: (row) => row.is_active,
|
hidden: (row) => row.is_active,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// config/assets/archive/selection.config.jsx
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, getTableInstance }) {
|
export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, onSingleDelete, onBulkDelete, getTableInstance }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRes
|
|||||||
: onBulkRestore(ids);
|
: onBulkRestore(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// modules/admin/config/assets/rowActions.config.jsx
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @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.onEdit (row) → void — navigate to edit page
|
||||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onRestore }) {
|
export function buildRowActions({ onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) {
|
|||||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// config/assets/archive/selection.config.jsx
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, getTableInstance }) {
|
export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, deleteCourse, deleteCourses, getTableInstance }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, restoreCourse, restoreCour
|
|||||||
: restoreCourses(ids);
|
: restoreCourses(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// modules/admin/config/assets/rowActions.config.jsx
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @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.onEdit (row) → void — navigate to edit page
|
||||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onRestore }) {
|
export function buildRowActions({ onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) {
|
|||||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// config/assets/archive/selection.config.jsx
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({ exportConfig, restoreLesson, restoreLessons, getTableInstance }) {
|
export function buildSelectionActions({ exportConfig, restoreLesson, restoreLessons, deleteLesson, deleteLessons, getTableInstance }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -27,5 +27,17 @@ export function buildSelectionActions({ exportConfig, restoreLesson, restoreLess
|
|||||||
: restoreLessons(ids);
|
: restoreLessons(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// modules/admin/config/assets/rowActions.config.jsx
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @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.onEdit (row) → void — navigate to edit page
|
||||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onRestore }) {
|
export function buildRowActions({ onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -16,5 +16,12 @@ export function buildRowActions({ onRestore }) {
|
|||||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// config/assets/archive/selection.config.jsx
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({ exportConfig, restoreCourse, restoreCourses, getTableInstance }) {
|
export function buildSelectionActions({ exportConfig, restoreUnit, restoreUnits, deleteUnit, deleteUnits, getTableInstance }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -23,8 +23,20 @@ export function buildSelectionActions({ exportConfig, restoreCourse, restoreCour
|
|||||||
onClick: (rows) => {
|
onClick: (rows) => {
|
||||||
const ids = rows.map((r) => r.unit_id);
|
const ids = rows.map((r) => r.unit_id);
|
||||||
ids.length === 1
|
ids.length === 1
|
||||||
? restoreCourse(rows[0])
|
? restoreUnit(rows[0])
|
||||||
: restoreCourses(ids);
|
: restoreUnits(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onRestore(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <Download className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (rows, table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
selectedRows: rows,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "restore-selected",
|
||||||
|
label: "Restore",
|
||||||
|
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||||
|
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: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
label: "Refresh",
|
||||||
|
onClick: () =>
|
||||||
|
fetchBroadcasts({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
label: "Export",
|
||||||
|
onClick: (table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 [
|
return [
|
||||||
// {
|
// {
|
||||||
// key: "view",
|
// key: "view",
|
||||||
@@ -14,5 +14,12 @@ export function buildRowActions({ navigate, onRestore }) {
|
|||||||
icon: <RotateCcw className="size-4" />,
|
icon: <RotateCcw className="size-4" />,
|
||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Download, RotateCcw } from "lucide-react";
|
import { Download, RotateCcw, Trash2 } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({
|
export function buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onBulkRestore,
|
onBulkRestore,
|
||||||
|
onBulkDelete,
|
||||||
getTableInstance,
|
getTableInstance,
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
@@ -28,5 +29,16 @@ export function buildSelectionActions({
|
|||||||
onBulkRestore?.(ids);
|
onBulkRestore?.(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete Selected",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -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 }) {
|
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "view",
|
||||||
label: "View Info",
|
label: "View Info",
|
||||||
icon: <Eye className="size-4" />,
|
icon: <Eye className="size-4" />,
|
||||||
onClick: (row) => navigate(`${row.task_list_id}/view`),
|
onClick: (row) => navigate(`${row.task_list_id}/view`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "tasks",
|
||||||
label: "Edit Info",
|
|
||||||
icon: <Pencil className="size-4" />,
|
|
||||||
onClick: (row) => navigate(`${row.task_list_id}/edit`),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "view",
|
|
||||||
label: "View Tasks",
|
label: "View Tasks",
|
||||||
icon: <NotebookPen className="size-4" />,
|
icon: <NotebookPen className="size-4" />,
|
||||||
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
|
onClick: (row) => navigate(`${row.task_list_id}/tasks`),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||||
|
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @param {Object} deps
|
||||||
@@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react";
|
|||||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
* @returns {Array} rowActions
|
* @returns {Array} rowActions
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ navigate, onRestore }) {
|
export function buildRowActions({ navigate, onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -20,5 +20,12 @@ export function buildRowActions({ navigate, onRestore }) {
|
|||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
hidden: (row) => row.is_active,
|
hidden: (row) => row.is_active,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="size-4" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Download, RotateCcw } from "lucide-react";
|
import { Download, RotateCcw, Trash2 } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({
|
export function buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onBulkRestore,
|
onBulkRestore,
|
||||||
|
onBulkDelete,
|
||||||
getTableInstance,
|
getTableInstance,
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
@@ -28,5 +29,16 @@ export function buildSelectionActions({
|
|||||||
onBulkRestore?.(ids);
|
onBulkRestore?.(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete Selected",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -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 }) {
|
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "view",
|
||||||
label: "View Info",
|
label: "View Info",
|
||||||
icon: <Eye className="size-4" />,
|
icon: <Eye className="size-4" />,
|
||||||
onClick: (row) => navigate(`${row.task_id}/view`),
|
onClick: (row) => navigate(`${row.task_id}/view`),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "edit",
|
|
||||||
label: "Edit Info",
|
|
||||||
icon: <Pencil className="size-4" />,
|
|
||||||
onClick: (row) => navigate(`${row.task_id}/edit`),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "completions",
|
key: "completions",
|
||||||
label: "Completions",
|
label: "Completions",
|
||||||
|
|||||||
@@ -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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -16,5 +16,12 @@ export function buildRowActions({ navigate, onRestore }) {
|
|||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
separator: true,
|
separator: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Download, ArchiveRestore } from "lucide-react";
|
import { Download, ArchiveRestore, Trash2 } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildSelectionActions({
|
export function buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onRestore,
|
onRestore,
|
||||||
onRestoreMany,
|
onRestoreMany,
|
||||||
|
onDelete,
|
||||||
|
onDeleteMany,
|
||||||
getTableInstance,
|
getTableInstance,
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
@@ -29,5 +31,15 @@ export function buildSelectionActions({
|
|||||||
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
|
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||||
|
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @param {Object} deps
|
||||||
@@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react";
|
|||||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
* @returns {Array} rowActions
|
* @returns {Array} rowActions
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ navigate, onRestore }) {
|
export function buildRowActions({ navigate, onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -21,5 +21,12 @@ export function buildRowActions({ navigate, onRestore }) {
|
|||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
hidden: (row) => row.is_active,
|
hidden: (row) => row.is_active,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// config/selection.config.jsx
|
// config/selection.config.jsx
|
||||||
import { Download, ArchiveRestore } from "lucide-react";
|
import { Download, ArchiveRestore, Trash2 } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
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.archiveUser Archive handler from useManagement
|
||||||
* @param {Function} deps.deleteUser Delete 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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -30,5 +30,18 @@ export function buildSelectionActions({ exportConfig, restoreGroup, restoreGroup
|
|||||||
: restoreGroups(ids); // opens bulk dialog
|
: restoreGroups(ids); // opens bulk dialog
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ export function buildRowActions({ navigate, onEdit, onArchive }) {
|
|||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onArchive(row),
|
onClick: (row) => onArchive(row),
|
||||||
hidden: (row) => !row.is_active,
|
hidden: (row) => !row.is_active,
|
||||||
|
disabled: (row) => row.group_code === "NOGRP",
|
||||||
separator: true,
|
separator: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export function buildSelectionActions({ exportConfig, archiveGroup, archiveGroup
|
|||||||
? archiveGroup(rows[0]) // opens single dialog
|
? archiveGroup(rows[0]) // opens single dialog
|
||||||
: archiveGroups(ids); // opens bulk 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"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||||
|
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @param {Object} deps
|
||||||
@@ -11,7 +11,7 @@ import { RotateCcw } from "lucide-react";
|
|||||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
* @returns {Array} rowActions
|
* @returns {Array} rowActions
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ navigate, onRestore }) {
|
export function buildRowActions({ navigate, onRestore, onDelete }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "restore",
|
key: "restore",
|
||||||
@@ -21,5 +21,12 @@ export function buildRowActions({ navigate, onRestore }) {
|
|||||||
onClick: (row) => onRestore(row),
|
onClick: (row) => onRestore(row),
|
||||||
hidden: (row) => row.is_active,
|
hidden: (row) => row.is_active,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// config/selection.config.jsx
|
// config/selection.config.jsx
|
||||||
import { Download, ArchiveRestore } from "lucide-react";
|
import { Download, ArchiveRestore, Trash2 } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
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.archiveUser Archive handler from useManagement
|
||||||
* @param {Function} deps.deleteUser Delete 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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -30,5 +30,18 @@ export function buildSelectionActions({ exportConfig, restoreUser, restoreUsers,
|
|||||||
: restoreUsers(ids); // opens bulk dialog
|
: restoreUsers(ids); // opens bulk dialog
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -26,11 +26,8 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
|
|||||||
key: "unban-selected",
|
key: "unban-selected",
|
||||||
label: "Unban",
|
label: "Unban",
|
||||||
icon: <ShieldCheck className="h-3.5 w-3.5" />,
|
icon: <ShieldCheck className="h-3.5 w-3.5" />,
|
||||||
onClick: (rows) => {
|
onClick: (rows) => unbanUsers(rows.map((r) => r.user_id)),
|
||||||
const ids = rows.filter((r) => r.is_banned).map((r) => r.user_id);
|
disabled: (rows) => !rows.every((r) => r.is_banned),
|
||||||
if (ids.length) unbanUsers(ids);
|
|
||||||
},
|
|
||||||
hidden: (rows) => rows.every((r) => !r.is_banned),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archive-selected",
|
key: "archive-selected",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
@@ -69,10 +69,16 @@ export default function AdvertisementList() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
||||||
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="size-4" />
|
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
|
||||||
New advertisement
|
<Archive className="size-4" />
|
||||||
</Button>
|
Archived
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New advertisement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Stat cards ─────────────────────────────────────────────── */}
|
{/* ── Stat cards ─────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -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: <House className="size-4" />, to: `/admin` },
|
||||||
|
{ label: "Advertisements", to: `/admin/advertisements` },
|
||||||
|
{ label: "Archived" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-muted h-full">
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
|
<div className="flex flex-col gap-2 my-6">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<ArchivedAdvertisementsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</Label>
|
|
||||||
{children}
|
|
||||||
{hint && !error && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 1 — Category ────────────────────────────────────────────────────────
|
|
||||||
function StepCategory({ control, error }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
What is this email for? This just helps organize templates in the list — it doesn't change how or when the email is sent.
|
|
||||||
</p>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="category"
|
|
||||||
render={({ field }) => (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
|
|
||||||
const Icon = cat.icon;
|
|
||||||
const selected = field.value === cat.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => field.onChange(cat.value)}
|
|
||||||
className={cn(
|
|
||||||
"text-left rounded-lg border-2 p-4 transition-all flex items-start gap-3",
|
|
||||||
selected ? "border-foreground bg-muted" : "border-border hover:border-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn("w-9 h-9 rounded-lg border flex items-center justify-center shrink-0", cat.badgeClass)}>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold">{cat.label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{cat.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 2 — Details ─────────────────────────────────────────────────────────
|
|
||||||
function StepDetails({ register, errors, typeValue }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Field
|
|
||||||
label="Type" required error={errors.type?.message}
|
|
||||||
hint="Uppercase, no spaces. This is the key your code passes to sendEmail({ type }) — cannot be changed after creation."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
{...register("type", { setValueAs: (v) => v.toUpperCase() })}
|
|
||||||
placeholder="e.g. INVOICE_RECEIPT"
|
|
||||||
style={{ textTransform: "uppercase" }}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Label" required error={errors.label?.message} hint="A friendly name shown in the admin list.">
|
|
||||||
<Input {...register("label")} placeholder="e.g. Invoice Receipt" />
|
|
||||||
</Field>
|
|
||||||
{typeValue && (
|
|
||||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
|
||||||
Custom templates aren't triggered automatically — a developer needs to call{" "}
|
|
||||||
<code className="bg-muted px-1 rounded">sendEmail({"{"} type: "{typeValue}", data {"}"})</code> from code.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 3 — Content ─────────────────────────────────────────────────────────
|
|
||||||
function StepContent({ register, errors, bodyMarkdown }) {
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Field label="Subject" required error={errors.subject?.message}>
|
|
||||||
<Input {...register("subject")} placeholder="e.g. Your Invoice - STARR System" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Body <span className="text-destructive">*</span></Label>
|
|
||||||
<div className="flex items-center rounded-md border p-0.5">
|
|
||||||
<Button type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(false)}>
|
|
||||||
<Code2 className="h-3.5 w-3.5 mr-1.5" /> Markdown
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" variant={showPreview ? "secondary" : "ghost"} className="h-7 px-2" onClick={() => setShowPreview(true)}>
|
|
||||||
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Write this in <strong>Markdown</strong> — 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{" "}
|
|
||||||
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.
|
|
||||||
</p>
|
|
||||||
{showPreview ? (
|
|
||||||
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
|
|
||||||
{bodyMarkdown?.trim() ? <ReactMarkdown>{bodyMarkdown}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Textarea {...register("body_markdown")} rows={12} className="font-mono text-xs" placeholder={"Dear {{name}},\n\nWelcome to **STARR System**!"} />
|
|
||||||
)}
|
|
||||||
{errors.body_markdown?.message && <p className="text-xs text-destructive">{errors.body_markdown.message}</p>}
|
|
||||||
|
|
||||||
<MarkdownCheatsheet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 4 — Review ──────────────────────────────────────────────────────────
|
|
||||||
function SummaryRow({ label, value }) {
|
|
||||||
if (!value) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between py-1.5 text-sm gap-4">
|
|
||||||
<span className="text-muted-foreground min-w-[100px] shrink-0">{label}</span>
|
|
||||||
<span className="text-foreground text-right break-words">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepReview({ data }) {
|
|
||||||
const cat = getEmailTemplateCategory(data.category);
|
|
||||||
const CatIcon = cat.icon;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<ClipboardCheck className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Template Details</span>
|
|
||||||
<Badge variant="outline" className={cn("ml-auto gap-1 text-xs", cat.badgeClass)}>
|
|
||||||
<CatIcon className="h-3 w-3" /> {cat.label}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<SummaryRow label="Type" value={data.type} />
|
|
||||||
<SummaryRow label="Label" value={data.label} />
|
|
||||||
<SummaryRow label="Subject" value={data.subject} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-border rounded-lg p-4">
|
|
||||||
<p className="text-sm font-medium mb-2">Body Preview</p>
|
|
||||||
<div className="rounded-md border bg-background p-4 text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
|
|
||||||
{data.body_markdown?.trim() ? <ReactMarkdown>{data.body_markdown}</ReactMarkdown> : <p className="text-muted-foreground">No content.</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
function AddEmailTemplateInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { createTemplate, loading } = useAdminEmailTemplates();
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
trigger,
|
|
||||||
watch,
|
|
||||||
getValues,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm({
|
|
||||||
resolver: zodResolver(emailTemplateSchema),
|
|
||||||
defaultValues: DEFAULT_VALUES,
|
|
||||||
mode: "onTouched",
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeValue = watch("type");
|
|
||||||
const bodyMarkdown = watch("body_markdown");
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
const valid = await trigger(STEPS[step].fields.length ? STEPS[step].fields : undefined);
|
|
||||||
if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Called manually — no <form> tag so no accidental submit
|
|
||||||
const handleCreate = (publish) => handleSubmit(async (data) => {
|
|
||||||
// body_markdown is what the admin wrote; html_body is what actually
|
|
||||||
// gets stored/sent — mandatory HTML, converted right before submit.
|
|
||||||
const result = await createTemplate({ ...data, html_body: markdownToHtml(data.body_markdown), publish });
|
|
||||||
if (result) navigate("/admin/email-templates");
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
// ← plain div, no <form> — prevents any accidental submit on button clicks
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Add Email Template - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="max-w-2xl mx-auto w-full space-y-6">
|
|
||||||
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Email Templates", to: "/admin/email-templates" },
|
|
||||||
{ label: "Add Template" },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">Add Email Template</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Define a new email type — category, details, and body content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stepper */}
|
|
||||||
<div className="flex items-center gap-0">
|
|
||||||
{STEPS.map((s, i) => {
|
|
||||||
const Icon = s.icon;
|
|
||||||
const isActive = step === i;
|
|
||||||
const isDone = step > i;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<div className={cn(
|
|
||||||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
|
|
||||||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
|
||||||
isActive && "border-primary bg-primary text-primary-foreground",
|
|
||||||
!isActive && !isDone && "border-border bg-background text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
|
|
||||||
isActive ? "text-foreground" : "text-muted-foreground",
|
|
||||||
isDone ? "text-emerald-600" : ""
|
|
||||||
)}>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{i < STEPS.length - 1 && (
|
|
||||||
<div className={cn(
|
|
||||||
"flex-1 h-px mx-2 mb-4 transition-colors",
|
|
||||||
step > i ? "bg-emerald-600" : "bg-border"
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div className="border border-border rounded-xl p-5 bg-card min-h-[320px]">
|
|
||||||
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
|
|
||||||
{step === 0 && <StepCategory control={control} error={errors.category?.message} />}
|
|
||||||
{step === 1 && <StepDetails register={register} errors={errors} typeValue={typeValue} />}
|
|
||||||
{step === 2 && <StepContent register={register} errors={errors} bodyMarkdown={bodyMarkdown} />}
|
|
||||||
{step === 3 && <StepReview data={getValues()} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={step === 0 ? () => navigate(-1) : () => setStep((s) => s - 1)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
||||||
{step === 0 ? "Cancel" : "Back"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{step < STEPS.length - 1 ? (
|
|
||||||
<Button type="button" onClick={handleNext}>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
type="button" // ← type="button", not "submit"
|
|
||||||
variant="outline"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => handleCreate(false)} // ← called manually
|
|
||||||
>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => handleCreate(true)}
|
|
||||||
>
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
|
|
||||||
Send Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEmailTemplate() {
|
|
||||||
return (
|
|
||||||
<AdminEmailTemplateProvider>
|
|
||||||
<AddEmailTemplateInner />
|
|
||||||
</AdminEmailTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { ArrowLeft, House, Lock, Eye, Code2, Send, Clock3 } from "lucide-react";
|
|
||||||
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 { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminEmailTemplateProvider,
|
|
||||||
useAdminEmailTemplates,
|
|
||||||
} from "@/contexts/AdminEmailTemplateContext";
|
|
||||||
import { EMAIL_TEMPLATE_PLACEHOLDERS } from "@/data/emailTemplatePlaceholders.data";
|
|
||||||
import { EMAIL_TEMPLATE_CATEGORIES } from "@/data/emailTemplateCategories.data";
|
|
||||||
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
|
|
||||||
import { markdownToHtml } from "@/utils/markdownToHtml.util";
|
|
||||||
import { MarkdownCheatsheet } from "@/components/generic/MarkdownCheatsheet";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
||||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({ message }) {
|
|
||||||
if (!message) return null;
|
|
||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditEmailTemplateInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
const { template, loading, fetchTemplate, updateTemplate } = useAdminEmailTemplates();
|
|
||||||
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [category, setCategory] = useState("other");
|
|
||||||
const [subject, setSubject] = useState("");
|
|
||||||
const [bodyValue, setBodyValue] = useState(""); // Markdown source (markdown mode) or raw HTML (legacy mode)
|
|
||||||
const [errors, setErrors] = useState({});
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) fetchTemplate(id);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (template) {
|
|
||||||
setLabel(template.label ?? "");
|
|
||||||
setCategory(template.category ?? "other");
|
|
||||||
// Prefer whatever's pending (unsent) over the live version, so
|
|
||||||
// reopening a template with pending changes resumes editing them.
|
|
||||||
setSubject(template.draft_subject ?? template.subject ?? "");
|
|
||||||
const markdown = template.draft_body_markdown ?? template.body_markdown;
|
|
||||||
setBodyValue(markdown ?? template.draft_html_body ?? template.html_body ?? "");
|
|
||||||
}
|
|
||||||
}, [template]);
|
|
||||||
|
|
||||||
const isSystem = template?.is_system;
|
|
||||||
const status = STATUS_META[template?.status] ?? STATUS_META.draft;
|
|
||||||
const pending = hasPendingChanges(template);
|
|
||||||
const knownPlaceholders = EMAIL_TEMPLATE_PLACEHOLDERS[template?.type] ?? null;
|
|
||||||
|
|
||||||
// Templates authored via the Markdown editor have a recorded Markdown
|
|
||||||
// source; templates from before that feature (all 8 system templates
|
|
||||||
// included) don't — those keep editing html_body/draft_html_body directly.
|
|
||||||
const isMarkdownMode = (template?.draft_body_markdown ?? template?.body_markdown) != null;
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const e = {};
|
|
||||||
if (!label.trim()) e.label = "Label is required.";
|
|
||||||
if (!subject.trim()) e.subject = "Subject is required.";
|
|
||||||
if (!bodyValue.trim()) e.body = isMarkdownMode ? "Body is required." : "HTML body is required.";
|
|
||||||
setErrors(e);
|
|
||||||
return !Object.keys(e).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (publish) => {
|
|
||||||
if (!validate()) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
label: label.trim(),
|
|
||||||
category,
|
|
||||||
subject: subject.trim(),
|
|
||||||
publish,
|
|
||||||
};
|
|
||||||
if (isMarkdownMode) {
|
|
||||||
payload.body_markdown = bodyValue;
|
|
||||||
payload.html_body = markdownToHtml(bodyValue);
|
|
||||||
} else {
|
|
||||||
payload.html_body = bodyValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateTemplate(id, payload);
|
|
||||||
if (result) navigate("/admin/email-templates");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Edit Email Template - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Email Templates", to: "/admin/email-templates" },
|
|
||||||
{ label: template?.label ?? "Edit" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<h1 className="text-xl font-semibold">Edit Email Template</h1>
|
|
||||||
{template && (
|
|
||||||
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
|
||||||
<Send className="h-3 w-3" /> {status.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Update this email's category, subject and body.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pending && (
|
|
||||||
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
|
||||||
This template has <strong>pending changes</strong> that haven't gone out yet — the version
|
|
||||||
currently emailed to users is the last one you sent. Press <strong>Send</strong> below to
|
|
||||||
publish these edits, or <strong>Save as Draft</strong> to keep working without publishing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSystem && (
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This is a <strong>system</strong> template — code sends it by referencing this exact type,
|
|
||||||
so the type is locked. Category, label, subject and body are still fully editable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
|
|
||||||
<SectionCard title="Template Details">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<Input value={template?.type ?? ""} disabled />
|
|
||||||
<p className="text-xs text-muted-foreground">Cannot be changed after creation.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Invoice Receipt" />
|
|
||||||
<FieldError message={errors.label} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Category</Label>
|
|
||||||
<Select value={category} onValueChange={setCategory}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => (
|
|
||||||
<SelectItem key={cat.value} value={cat.value}>{cat.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Organizational only — doesn't affect how or when this email is sent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="subject">Subject <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Your Invoice - STARR System" />
|
|
||||||
<FieldError message={errors.subject} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard>
|
|
||||||
<div className="flex items-center justify-between border-b pb-3">
|
|
||||||
<p className="text-sm font-semibold">{isMarkdownMode ? "Body" : "HTML Body"}</p>
|
|
||||||
<div className="flex items-center rounded-md border p-0.5">
|
|
||||||
<Button
|
|
||||||
type="button" size="sm" variant={!showPreview ? "secondary" : "ghost"}
|
|
||||||
className="h-7 px-2" onClick={() => setShowPreview(false)}
|
|
||||||
>
|
|
||||||
<Code2 className="h-3.5 w-3.5 mr-1.5" /> {isMarkdownMode ? "Markdown" : "HTML"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button" size="sm" variant={showPreview ? "secondary" : "ghost"}
|
|
||||||
className="h-7 px-2" onClick={() => setShowPreview(true)}
|
|
||||||
>
|
|
||||||
<Eye className="h-3.5 w-3.5 mr-1.5" /> Preview
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
|
||||||
{isMarkdownMode ? (
|
|
||||||
<>
|
|
||||||
Write this in <strong>Markdown</strong> — 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.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
This template predates Markdown support, so it's edited as raw HTML directly — there's
|
|
||||||
no visual/drag-and-drop builder. Header, footer and signature are fixed and added
|
|
||||||
automatically; this box is just the message content in between.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{(knownPlaceholders !== null) && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
|
|
||||||
{knownPlaceholders.length ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{knownPlaceholders.map((ph) => (
|
|
||||||
<Badge key={ph} variant="outline" className="font-mono text-[10px]">
|
|
||||||
{`{{${ph}}}`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">This template has no dynamic placeholders.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPreview ? (
|
|
||||||
isMarkdownMode ? (
|
|
||||||
<div className="rounded-md border bg-background p-4 min-h-[220px] text-sm" style={{ fontFamily: "Arial, sans-serif" }}>
|
|
||||||
{bodyValue.trim() ? <ReactMarkdown>{bodyValue}</ReactMarkdown> : <p className="text-muted-foreground">Nothing to preview yet.</p>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-md border bg-background p-4 min-h-[220px] text-sm"
|
|
||||||
style={{ fontFamily: "Arial, sans-serif" }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: bodyValue || "<p class='text-muted-foreground'>Nothing to preview yet.</p>" }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Textarea
|
|
||||||
id="body"
|
|
||||||
value={bodyValue}
|
|
||||||
onChange={(e) => setBodyValue(e.target.value)}
|
|
||||||
rows={14}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
placeholder={isMarkdownMode ? "Dear {{name}},\n\nWelcome to **STARR System**!" : "<p>Dear {{name}},</p>"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<FieldError message={errors.body} />
|
|
||||||
|
|
||||||
{isMarkdownMode && <MarkdownCheatsheet />}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditEmailTemplate() {
|
|
||||||
return (
|
|
||||||
<AdminEmailTemplateProvider>
|
|
||||||
<EditEmailTemplateInner />
|
|
||||||
</AdminEmailTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { House, X, Send } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminEmailBroadcastProvider,
|
|
||||||
useAdminEmailBroadcasts,
|
|
||||||
} from "@/contexts/AdminEmailBroadcastContext";
|
|
||||||
import { TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
|
||||||
import { EMAIL_BROADCAST_STATUS_MAP } from "@/data/emailBroadcastStatus.data";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const POLL_MS = 3000;
|
|
||||||
|
|
||||||
function ProgressBar({ sent, failed, total }) {
|
|
||||||
const donePct = total ? Math.min(100, ((sent + failed) / total) * 100) : 0;
|
|
||||||
const failedPct = total ? Math.min(100, (failed / total) * 100) : 0;
|
|
||||||
return (
|
|
||||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
|
|
||||||
<div className="h-full bg-emerald-500" style={{ width: `${donePct - failedPct}%` }} />
|
|
||||||
<div className="h-full bg-destructive" style={{ width: `${failedPct}%` }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BroadcastRow({ item, onCancel }) {
|
|
||||||
const status = EMAIL_BROADCAST_STATUS_MAP[item.status] ?? EMAIL_BROADCAST_STATUS_MAP.queued;
|
|
||||||
const target = TARGET_TYPE_MAP[item.target_type];
|
|
||||||
const cancelable = item.status === "queued" || item.status === "sending";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold truncate">{item.template?.label ?? "(deleted template)"}</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{target?.label ?? item.target_type}
|
|
||||||
{item.target_id && <span className="font-mono ml-1">#{item.target_id}</span>}
|
|
||||||
{" · "}
|
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Badge variant="outline" className={cn("text-[11px]", status.badgeClass)}>{status.label}</Badge>
|
|
||||||
{cancelable && (
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onCancel(item)} title="Cancel">
|
|
||||||
<X className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProgressBar sent={item.sent_count} failed={item.failed_count} total={item.total_recipients} />
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{item.sent_count} sent
|
|
||||||
{item.failed_count > 0 && <span className="text-destructive"> · {item.failed_count} failed</span>}
|
|
||||||
{" "}/ {item.total_recipients} total
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmailBroadcastsInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { broadcasts, loading, fetchBroadcasts, fetchBroadcastsQuiet, cancelBroadcast } = useAdminEmailBroadcasts();
|
|
||||||
const pollRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => { fetchBroadcasts(); }, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasActive = broadcasts.some((b) => b.status === "queued" || b.status === "sending");
|
|
||||||
if (hasActive && !pollRef.current) {
|
|
||||||
pollRef.current = setInterval(fetchBroadcastsQuiet, POLL_MS);
|
|
||||||
} else if (!hasActive && pollRef.current) {
|
|
||||||
clearInterval(pollRef.current);
|
|
||||||
pollRef.current = null;
|
|
||||||
}
|
|
||||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
|
||||||
}, [broadcasts, fetchBroadcastsQuiet]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Sent Email History - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-3xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Email Templates", to: "/admin/email-templates" },
|
|
||||||
{ label: "Sent History" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-xl font-semibold">Sent Email History</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Every broadcast queued from an email template, and how far delivery has gotten.
|
|
||||||
Sending is paced in the background — this page auto-refreshes while anything is in progress.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mb-5" />
|
|
||||||
|
|
||||||
{loading && !broadcasts.length ? (
|
|
||||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
|
||||||
) : !broadcasts.length ? (
|
|
||||||
<div className="text-center py-12 space-y-3">
|
|
||||||
<Send className="h-6 w-6 text-muted-foreground mx-auto" />
|
|
||||||
<p className="text-sm text-muted-foreground">No broadcasts sent yet.</p>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-templates")}>
|
|
||||||
Back to Email Templates
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{broadcasts.map((item) => (
|
|
||||||
<BroadcastRow key={item.email_broadcast_id} item={item} onCancel={(b) => cancelBroadcast(b.email_broadcast_id)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmailBroadcasts() {
|
|
||||||
return (
|
|
||||||
<AdminEmailBroadcastProvider>
|
|
||||||
<EmailBroadcastsInner />
|
|
||||||
</AdminEmailBroadcastProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { House, Plus, Pencil, Trash2, Mail, Lock, Send, Clock3, History } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminEmailTemplateProvider,
|
|
||||||
useAdminEmailTemplates,
|
|
||||||
} from "@/contexts/AdminEmailTemplateContext";
|
|
||||||
import { EMAIL_TEMPLATE_CATEGORIES, getEmailTemplateCategory, isBroadcastable } from "@/data/emailTemplateCategories.data";
|
|
||||||
import { STATUS_META, hasPendingChanges } from "@/data/emailTemplateStatus.data";
|
|
||||||
import { AdminEmailBroadcastProvider } from "@/contexts/AdminEmailBroadcastContext";
|
|
||||||
import { SendEmailBroadcastDialog } from "@/components/generic/SendEmailBroadcastDialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function TemplateCard({ item, onEdit, onDelete, onSend }) {
|
|
||||||
const cat = getEmailTemplateCategory(item.category);
|
|
||||||
const CatIcon = cat.icon;
|
|
||||||
const status = STATUS_META[item.status] ?? STATUS_META.draft;
|
|
||||||
const pending = hasPendingChanges(item);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 flex flex-col gap-4 h-full">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
|
|
||||||
<Mail className="h-4.5 w-4.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{!item.is_system && (
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
|
||||||
<p className="text-sm font-semibold truncate">{item.label}</p>
|
|
||||||
{item.is_system && (
|
|
||||||
<Badge variant="secondary" className="gap-1 shrink-0">
|
|
||||||
<Lock className="h-2.5 w-2.5" /> System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.type}</code>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
|
||||||
<span className="text-foreground">{item.subject || item.draft_subject || "No subject yet"}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<Badge variant="outline" className={cn("gap-1 text-[11px]", cat.badgeClass)}>
|
|
||||||
<CatIcon className="h-3 w-3" /> {cat.label}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className={cn("gap-1 text-[11px]", status.badgeClass)}>
|
|
||||||
<Send className="h-3 w-3" /> {status.label}
|
|
||||||
</Badge>
|
|
||||||
{pending && (
|
|
||||||
<Badge variant="outline" className="gap-1 text-[11px] bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700">
|
|
||||||
<Clock3 className="h-3 w-3" /> Pending changes
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isBroadcastable(item) && (
|
|
||||||
<Button type="button" size="sm" variant="outline" className="gap-1.5" onClick={() => onSend(item)}>
|
|
||||||
<Send className="h-3.5 w-3.5" /> Send to Recipients
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmailTemplatesInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminEmailTemplates();
|
|
||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
const [activeCategory, setActiveCategory] = useState("all");
|
|
||||||
const [sendTarget, setSendTarget] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => { fetchTemplates(); }, []);
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleting(true);
|
|
||||||
await deleteTemplate(deleteTarget.email_template_id);
|
|
||||||
setDeleting(false);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = useMemo(
|
|
||||||
() => activeCategory === "all" ? templates : templates.filter((t) => t.category === activeCategory),
|
|
||||||
[templates, activeCategory]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Email Templates - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-6xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Email Templates" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Email Templates</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Subject lines and message content for every automated email STARR sends.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Send className="h-3 w-3 text-emerald-600" />
|
|
||||||
{templates.filter((t) => t.status === "sent").length} sent
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
{templates.filter((t) => t.status === "draft").length} draft
|
|
||||||
</span>
|
|
||||||
{templates.some(hasPendingChanges) && (
|
|
||||||
<span className="flex items-center gap-1 text-amber-600">
|
|
||||||
<Clock3 className="h-3 w-3" />
|
|
||||||
{templates.filter(hasPendingChanges).length} with pending changes
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => navigate("/admin/email-broadcasts")}>
|
|
||||||
<History className="h-4 w-4 mr-2" />
|
|
||||||
Sent History
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => navigate("/admin/email-templates/add")}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
|
||||||
<p>
|
|
||||||
<strong>System</strong> templates are sent automatically by platform code and cannot be
|
|
||||||
deleted or have their type changed — the subject and body stay fully editable.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Draft vs. Sent:</strong> a <strong>Sent</strong> template is the version actually used
|
|
||||||
for real emails right now. Editing a Sent template doesn't change what goes out immediately —
|
|
||||||
it's held as a pending change until you press <strong>Send</strong> again to publish it. A
|
|
||||||
brand-new <strong>Draft</strong> isn't used for anything until it's sent for the first time.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Limitations:</strong> the page layout (header, footer, signature) is fixed and cannot
|
|
||||||
be customized from here — you can only edit the subject and the body content in between.
|
|
||||||
Only plain HTML is supported in the body (no visual/drag-and-drop builder) — no scripts and
|
|
||||||
no conditional logic, just straight <code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens
|
|
||||||
that get swapped for real values when the email is sent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
|
||||||
<Button
|
|
||||||
type="button" size="sm" variant={activeCategory === "all" ? "secondary" : "outline"}
|
|
||||||
onClick={() => setActiveCategory("all")}
|
|
||||||
>
|
|
||||||
All ({templates.length})
|
|
||||||
</Button>
|
|
||||||
{EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
|
|
||||||
const Icon = cat.icon;
|
|
||||||
const count = templates.filter((t) => t.category === cat.value).length;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={cat.value}
|
|
||||||
type="button" size="sm"
|
|
||||||
variant={activeCategory === cat.value ? "secondary" : "outline"}
|
|
||||||
onClick={() => setActiveCategory(cat.value)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" /> {cat.label} ({count})
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mb-5" />
|
|
||||||
|
|
||||||
{loading && !templates.length ? (
|
|
||||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
|
||||||
) : !filtered.length ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">No email templates found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filtered.map((item) => (
|
|
||||||
<TemplateCard
|
|
||||||
key={item.email_template_id}
|
|
||||||
item={item}
|
|
||||||
onEdit={(t) => navigate(`/admin/email-templates/${t.email_template_id}/edit`)}
|
|
||||||
onDelete={(t) => setDeleteTarget(t)}
|
|
||||||
onSend={(t) => setSendTarget(t)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Email Template</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
|
|
||||||
This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
|
||||||
{deleting && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Send to Recipients dialog */}
|
|
||||||
<SendEmailBroadcastDialog
|
|
||||||
open={!!sendTarget}
|
|
||||||
onOpenChange={(open) => { if (!open) setSendTarget(null); }}
|
|
||||||
template={sendTarget}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmailTemplates() {
|
|
||||||
return (
|
|
||||||
<AdminEmailTemplateProvider>
|
|
||||||
<AdminEmailBroadcastProvider>
|
|
||||||
<EmailTemplatesInner />
|
|
||||||
</AdminEmailBroadcastProvider>
|
|
||||||
</AdminEmailTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import ArchivedNotificationBroadcastsTable from "../../components/notifications/ArchivedNotificationBroadcastsTable";
|
||||||
|
|
||||||
|
export default function ArchivedNotificationBroadcastList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
|
{ label: "Notifications", to: `/admin/notifications` },
|
||||||
|
{ label: "Archived" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-muted h-full">
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
|
<div className="flex flex-col gap-2 my-6">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<ArchivedNotificationBroadcastsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings, FileText } from "lucide-react";
|
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings, FileText, Archive } from "lucide-react";
|
||||||
|
|
||||||
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
@@ -80,6 +80,10 @@ export default function NotificationBroadcastList() {
|
|||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => navigate("/admin/notifications/archived")}>
|
||||||
|
<Archive className="size-4" />
|
||||||
|
Archived
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate("/admin/notifications/add")}>
|
<Button onClick={() => navigate("/admin/notifications/add")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New notification
|
New notification
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
|
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
|
||||||
|
import TaskQueueStep from './TaskQueueStep';
|
||||||
import api from '@/utils/api.util';
|
import api from '@/utils/api.util';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -12,13 +13,14 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ClipboardList } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ListChecks, ClipboardList } from 'lucide-react';
|
||||||
|
|
||||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ id: 0, label: 'Details', icon: FileText },
|
{ id: 0, label: 'Details', icon: FileText },
|
||||||
{ id: 1, label: 'Assign Groups', icon: Users },
|
{ id: 1, label: 'Assign Groups', icon: Users },
|
||||||
{ id: 2, label: 'Review', icon: ClipboardList },
|
{ id: 2, label: 'Tasks', icon: ListChecks },
|
||||||
|
{ id: 3, label: 'Review', icon: ClipboardList },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Summary row ──────────────────────────────────────────────────────────────
|
// ─── Summary row ──────────────────────────────────────────────────────────────
|
||||||
@@ -34,12 +36,20 @@ function SummaryRow({ label, value }) {
|
|||||||
|
|
||||||
export default function CreateTaskList() {
|
export default function CreateTaskList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createTaskList, assignGroups, loading } = useAdminTask();
|
const {
|
||||||
|
createTaskList, assignGroups, createTask,
|
||||||
|
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||||
|
loading,
|
||||||
|
} = useAdminTask();
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState({ name: '', description: '' });
|
const [form, setForm] = useState({ name: '', description: '' });
|
||||||
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||||
const [allGroups, setAllGroups] = useState([]);
|
const [allGroups, setAllGroups] = useState([]);
|
||||||
|
const [queuedTasks, setQueuedTasks] = useState([]);
|
||||||
|
const [courses, setCourses] = useState([]);
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
// Fetch groups for the review step's summary (names, not just ids)
|
// Fetch groups for the review step's summary (names, not just ids)
|
||||||
@@ -49,6 +59,13 @@ export default function CreateTaskList() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch flat content lists for the Tasks step's requirement builder
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||||
|
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||||
|
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const validateDetails = () => {
|
const validateDetails = () => {
|
||||||
const e = {};
|
const e = {};
|
||||||
if (!form.name.trim()) e.name = 'Task list name is required.';
|
if (!form.name.trim()) e.name = 'Task list name is required.';
|
||||||
@@ -81,6 +98,21 @@ export default function CreateTaskList() {
|
|||||||
await assignGroups(created.task_list_id, selectedGroupIds);
|
await assignGroups(created.task_list_id, selectedGroupIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create any queued tasks under the new task list — non-blocking: navigate regardless
|
||||||
|
for (const t of queuedTasks) {
|
||||||
|
await createTask(created.task_list_id, {
|
||||||
|
name: t.name.trim(),
|
||||||
|
description: t.description?.trim() || null,
|
||||||
|
deadline: t.deadline || null,
|
||||||
|
// strip duration_seconds — it's only used for local validation
|
||||||
|
requirements: t.requirements.map((r) => {
|
||||||
|
const req = { ...r };
|
||||||
|
delete req.duration_seconds;
|
||||||
|
return req;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,8 +228,24 @@ export default function CreateTaskList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 3: Review ── */}
|
{/* ── Step 3: Tasks ── */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
|
Optionally add the tasks users will need to complete for this task list.
|
||||||
|
</p>
|
||||||
|
<TaskQueueStep
|
||||||
|
tasks={queuedTasks}
|
||||||
|
onChange={setQueuedTasks}
|
||||||
|
courses={courses}
|
||||||
|
units={units}
|
||||||
|
lessons={lessons}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 4: Review ── */}
|
||||||
|
{step === 3 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -223,6 +271,31 @@ export default function CreateTaskList() {
|
|||||||
<p className="text-sm text-muted-foreground">No groups assigned — task list will not be visible to any users yet.</p>
|
<p className="text-sm text-muted-foreground">No groups assigned — task list will not be visible to any users yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-border rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Tasks</span>
|
||||||
|
<Badge variant="secondary" className="ml-auto text-xs">{queuedTasks.length}</Badge>
|
||||||
|
</div>
|
||||||
|
{queuedTasks.length > 0 ? (
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{queuedTasks.map((t, i) => (
|
||||||
|
<div key={t._key} className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">{i + 1}</Badge>
|
||||||
|
<span className="truncate flex-1">{t.name}</span>
|
||||||
|
{t.requirements.length > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{t.requirements.length} requirement(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No tasks added yet — you can add them later from the task list's Tasks tab.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { format, parseISO, isValid } from 'date-fns';
|
||||||
|
import { Plus, Pencil, Trash2, ListChecks, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
import RequirementBuilder from './task/RequirementBuilder';
|
||||||
|
import { taskSchema } from './task/task.schema';
|
||||||
|
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
const EMPTY_DRAFT = { name: '', description: '', deadline: '', requirements: [] };
|
||||||
|
|
||||||
|
function formattedDeadline(deadline) {
|
||||||
|
if (!deadline) return null;
|
||||||
|
const d = parseISO(deadline);
|
||||||
|
return isValid(d) ? format(d, 'MMM d, yyyy h:mm a') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queue tasks locally during Create Task List; each is created via createTask
|
||||||
|
// right after the task list itself is created (see CreateTaskList.handleCreate)
|
||||||
|
export default function TaskQueueStep({ tasks, onChange, courses, units, lessons }) {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editKey, setEditKey] = useState(null); // null = adding new
|
||||||
|
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditKey(null);
|
||||||
|
setDraft(EMPTY_DRAFT);
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (t) => {
|
||||||
|
setEditKey(t._key);
|
||||||
|
setDraft({ name: t.name, description: t.description, deadline: t.deadline, requirements: t.requirements });
|
||||||
|
setErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTask = (key) => onChange(tasks.filter((t) => t._key !== key));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const result = taskSchema.safeParse(draft);
|
||||||
|
if (!result.success) {
|
||||||
|
const e = {};
|
||||||
|
const issues = result.error.issues;
|
||||||
|
const nameIssue = issues.find((i) => i.path[0] === 'name');
|
||||||
|
if (nameIssue) e.name = nameIssue.message;
|
||||||
|
if (issues.some((i) => i.path[0] === 'requirements')) {
|
||||||
|
e.requirements = 'Some requirements have issues — check above.';
|
||||||
|
}
|
||||||
|
setErrors(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
|
||||||
|
const normalizedDraft = { ...draft, requirements: result.data.requirements };
|
||||||
|
|
||||||
|
if (editKey) {
|
||||||
|
onChange(tasks.map((t) => (t._key === editKey ? { ...t, ...normalizedDraft } : t)));
|
||||||
|
} else {
|
||||||
|
onChange([...tasks, { _key: crypto.randomUUID(), ...normalizedDraft }]);
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tasks.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
|
||||||
|
No tasks added yet. You can add tasks now or later from the task list's Tasks tab.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tasks.map((t, idx) => (
|
||||||
|
<Card key={t._key}>
|
||||||
|
<CardContent className="py-3 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">{idx + 1}</Badge>
|
||||||
|
<span className="text-sm font-medium truncate">{t.name}</span>
|
||||||
|
</div>
|
||||||
|
{t.description && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate pl-7">{t.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 pl-7 text-xs text-muted-foreground">
|
||||||
|
{formattedDeadline(t.deadline) && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />{formattedDeadline(t.deadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.requirements.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ListChecks className="h-3 w-3" />{t.requirements.length} requirement(s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(t)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeTask(t._key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={openAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editKey ? 'Edit Task' : 'Add Task'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="queuedTaskName">Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="queuedTaskName"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="e.g. Complete orientation video"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-xs text-destructive">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="queuedTaskDescription">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="queuedTaskDescription"
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
|
placeholder="Optional task description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Deadline</Label>
|
||||||
|
<DeadlinePicker
|
||||||
|
value={draft.deadline}
|
||||||
|
onChange={(iso) => setDraft({ ...draft, deadline: iso })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Requirements</Label>
|
||||||
|
<RequirementBuilder
|
||||||
|
value={draft.requirements}
|
||||||
|
onChange={(reqs) => setDraft({ ...draft, requirements: reqs })}
|
||||||
|
courses={courses}
|
||||||
|
units={units}
|
||||||
|
lessons={lessons}
|
||||||
|
/>
|
||||||
|
{errors.requirements && (
|
||||||
|
<p className="text-xs text-destructive pt-1">{errors.requirements}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="button" onClick={handleSave}>{editKey ? 'Save Task' : 'Add Task'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { z } from 'zod';
|
|
||||||
import { format, parseISO, isValid } from 'date-fns';
|
import { format, parseISO, isValid } from 'date-fns';
|
||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import RequirementBuilder from './RequirementBuilder';
|
import RequirementBuilder from './RequirementBuilder';
|
||||||
|
import { taskSchema, REQUIREMENT_TYPE_META, requirementSummaryText } from './task.schema';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,48 +14,9 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Upload, BookOpen, Layers, Clock } from 'lucide-react';
|
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, ListChecks, ClipboardList, Link as LinkIcon, Clock } from 'lucide-react';
|
||||||
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
import DeadlinePicker from '@/components/generic/DeadlinePicker';
|
||||||
|
|
||||||
// ── Requirement validation schema ─────────────────────────────────────────────
|
|
||||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
|
||||||
|
|
||||||
const requirementSchema = z.object({
|
|
||||||
type: z.string(),
|
|
||||||
reference_id: z.string().optional(),
|
|
||||||
duration_seconds: z.number().optional(),
|
|
||||||
}).passthrough().superRefine((req, ctx) => {
|
|
||||||
if (!READ_TYPES.includes(req.type)) return;
|
|
||||||
if (!req.reference_id) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
|
|
||||||
} else if ((req.duration_seconds ?? -1) === 0) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const taskSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Task name is required.'),
|
|
||||||
requirements: z.array(requirementSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Requirement type labels/icons for the review step ─────────────────────────
|
|
||||||
const REQUIREMENT_TYPE_META = {
|
|
||||||
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
|
||||||
upload_file: { label: 'Upload a File', icon: Upload },
|
|
||||||
read_course: { label: 'Read a Course', icon: BookOpen },
|
|
||||||
read_unit: { label: 'Read a Unit', icon: Layers },
|
|
||||||
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
|
||||||
};
|
|
||||||
|
|
||||||
function requirementSummaryText(req) {
|
|
||||||
if (req.type === 'visit_link') return req.link_url || '—';
|
|
||||||
if (req.type === 'upload_file') {
|
|
||||||
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
|
|
||||||
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
|
|
||||||
}
|
|
||||||
return req.reference_label || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ id: 0, label: 'Task Details', icon: FileText },
|
{ id: 0, label: 'Task Details', icon: FileText },
|
||||||
@@ -140,8 +101,9 @@ export default function CreateTask() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
|
// use result.data — it carries the schema's transforms (e.g. link_url scheme defaulting)
|
||||||
// strip duration_seconds — it's only used for local validation
|
// strip duration_seconds — it's only used for local validation
|
||||||
requirements: form.requirements.map((r) => {
|
requirements: result.data.requirements.map((r) => {
|
||||||
const req = { ...r };
|
const req = { ...r };
|
||||||
delete req.duration_seconds;
|
delete req.duration_seconds;
|
||||||
return req;
|
return req;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
|
|
||||||
import RequirementBuilder from './RequirementBuilder';
|
import RequirementBuilder from './RequirementBuilder';
|
||||||
|
import { taskSchema } from './task.schema';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -20,27 +20,6 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { ArrowLeft, TriangleAlert } from 'lucide-react';
|
import { ArrowLeft, TriangleAlert } from 'lucide-react';
|
||||||
|
|
||||||
// ── Requirement validation schema ─────────────────────────────────────────────
|
|
||||||
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
|
||||||
|
|
||||||
const requirementSchema = z.object({
|
|
||||||
type: z.string(),
|
|
||||||
reference_id: z.string().optional(),
|
|
||||||
duration_seconds: z.number().optional(),
|
|
||||||
}).passthrough().superRefine((req, ctx) => {
|
|
||||||
if (!READ_TYPES.includes(req.type)) return;
|
|
||||||
if (!req.reference_id) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
|
|
||||||
} else if ((req.duration_seconds ?? -1) === 0) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const taskSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Task name is required.'),
|
|
||||||
requirements: z.array(requirementSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
@@ -106,13 +85,17 @@ export default function EditTask() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
|
// re-parse to pick up the schema's transforms (e.g. link_url scheme defaulting)
|
||||||
|
const result = taskSchema.safeParse(form);
|
||||||
|
const requirements = (result.success ? result.data.requirements : form.requirements)
|
||||||
|
.map(({ duration_seconds, ...req }) => req);
|
||||||
|
|
||||||
const updated = await updateTask(taskListId, taskId, {
|
const updated = await updateTask(taskListId, taskId, {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
deadline: form.deadline || null,
|
deadline: form.deadline || null,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
// strip duration_seconds — it's only used for local validation
|
requirements,
|
||||||
requirements: form.requirements.map(({ duration_seconds, ...req }) => req),
|
|
||||||
});
|
});
|
||||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { FileText, Link as LinkIcon, Upload, BookOpen, Layers } from 'lucide-react';
|
||||||
|
|
||||||
|
// ── Requirement validation ────────────────────────────────────────────────────
|
||||||
|
const READ_TYPES = ['read_course', 'read_unit', 'read_lesson'];
|
||||||
|
|
||||||
|
// Users commonly type bare domains ("google.com") — default the scheme to https
|
||||||
|
// so the link is actually clickable/navigable once the task is saved.
|
||||||
|
function normalizeLinkUrl(url) {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requirementSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
reference_id: z.string().optional(),
|
||||||
|
duration_seconds: z.number().optional(),
|
||||||
|
}).passthrough().superRefine((req, ctx) => {
|
||||||
|
if (!READ_TYPES.includes(req.type)) return;
|
||||||
|
if (!req.reference_id) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select content', path: ['reference_id'] });
|
||||||
|
} else if ((req.duration_seconds ?? -1) === 0) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This content has no content detected yet', path: ['duration_seconds'] });
|
||||||
|
}
|
||||||
|
}).transform((req) => (
|
||||||
|
req.type === 'visit_link' && req.link_url
|
||||||
|
? { ...req, link_url: normalizeLinkUrl(req.link_url) }
|
||||||
|
: req
|
||||||
|
));
|
||||||
|
|
||||||
|
export const taskSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Task name is required.'),
|
||||||
|
requirements: z.array(requirementSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Requirement type labels/icons for review/summary displays ────────────────
|
||||||
|
export const REQUIREMENT_TYPE_META = {
|
||||||
|
visit_link: { label: 'Visit a Link', icon: LinkIcon },
|
||||||
|
upload_file: { label: 'Upload a File', icon: Upload },
|
||||||
|
read_course: { label: 'Read a Course', icon: BookOpen },
|
||||||
|
read_unit: { label: 'Read a Unit', icon: Layers },
|
||||||
|
read_lesson: { label: 'Read a Lesson', icon: FileText },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function requirementSummaryText(req) {
|
||||||
|
if (req.type === 'visit_link') return req.link_url || '—';
|
||||||
|
if (req.type === 'upload_file') {
|
||||||
|
const types = (req.allowed_file_types ?? []).join(', ').toUpperCase();
|
||||||
|
return `${types || 'Any type'} · max ${req.max_file_count ?? 1} file(s)`;
|
||||||
|
}
|
||||||
|
return req.reference_label || '—';
|
||||||
|
}
|
||||||
@@ -101,17 +101,12 @@ import AdvertisementList from '../pages/advertisements/AdvertisementList'
|
|||||||
import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
|
import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
|
||||||
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
|
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
|
||||||
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
||||||
|
import ArchivedAdvertisementList from '../pages/advertisements/ArchivedAdvertisementList'
|
||||||
|
|
||||||
// Achievements
|
// Achievements
|
||||||
import Achievements from '../pages/achievements/Achievements'
|
import Achievements from '../pages/achievements/Achievements'
|
||||||
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
|
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
|
||||||
|
|
||||||
// Email Templates
|
|
||||||
import EmailTemplates from '../pages/email_templates/EmailTemplates'
|
|
||||||
import AddEmailTemplate from '../pages/email_templates/AddEmailTemplate'
|
|
||||||
import EditEmailTemplate from '../pages/email_templates/EditEmailTemplate'
|
|
||||||
import EmailBroadcasts from '../pages/email_templates/EmailBroadcasts'
|
|
||||||
|
|
||||||
// Notification Broadcasts
|
// Notification Broadcasts
|
||||||
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
|
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
|
||||||
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
|
import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroadcast'
|
||||||
@@ -120,6 +115,7 @@ import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBr
|
|||||||
import NotificationSettings from '../pages/notifications/NotificationSettings'
|
import NotificationSettings from '../pages/notifications/NotificationSettings'
|
||||||
import NotificationTemplates from '../pages/notifications/NotificationTemplates'
|
import NotificationTemplates from '../pages/notifications/NotificationTemplates'
|
||||||
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
|
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
|
||||||
|
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
import ActivityFeed from '../pages/activity/ActivityFeed'
|
import ActivityFeed from '../pages/activity/ActivityFeed'
|
||||||
@@ -311,6 +307,7 @@ export const AdminRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <AdvertisementList /> },
|
{ index: true, element: <AdvertisementList /> },
|
||||||
|
{ path: 'archived', element: <ArchivedAdvertisementList /> },
|
||||||
{ path: 'add', element: <AddAdvertisement /> },
|
{ path: 'add', element: <AddAdvertisement /> },
|
||||||
{ path: ':advertisementId/view', element: <ViewAdvertisement /> },
|
{ path: ':advertisementId/view', element: <ViewAdvertisement /> },
|
||||||
{ path: ':advertisementId/edit', element: <EditAdvertisement /> },
|
{ path: ':advertisementId/edit', element: <EditAdvertisement /> },
|
||||||
@@ -329,19 +326,6 @@ export const AdminRoutes = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email Templates
|
|
||||||
|
|
||||||
{
|
|
||||||
path: 'email-templates',
|
|
||||||
element: <Outlet />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <EmailTemplates /> },
|
|
||||||
{ path: 'add', element: <AddEmailTemplate /> },
|
|
||||||
{ path: ':id/edit', element: <EditEmailTemplate /> },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ path: 'email-broadcasts', element: <EmailBroadcasts /> },
|
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -349,6 +333,7 @@ export const AdminRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <NotificationBroadcastList /> },
|
{ index: true, element: <NotificationBroadcastList /> },
|
||||||
|
{ path: 'archived', element: <ArchivedNotificationBroadcastList /> },
|
||||||
{ path: 'add', element: <AddNotificationBroadcast /> },
|
{ path: 'add', element: <AddNotificationBroadcast /> },
|
||||||
{ path: 'settings', element: <NotificationSettings /> },
|
{ path: 'settings', element: <NotificationSettings /> },
|
||||||
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
|
{ path: ':broadcastId/view', element: <ViewNotificationBroadcast /> },
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
|
|
||||||
// Same renderer used for the live Preview tab (via <ReactMarkdown>), so the
|
|
||||||
// stored html_body always matches what the admin saw in preview.
|
|
||||||
export function markdownToHtml(markdown) {
|
|
||||||
const trimmed = (markdown ?? "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
return renderToStaticMarkup(React.createElement(ReactMarkdown, null, trimmed));
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user