+
+
+
+
, to: "/admin" },
+ { label: "Email Templates", to: "/admin/email-templates" },
+ { label: template?.label ?? "Edit" },
+ ]} />
+
+
+
+
+
+
+
Edit Email Template
+ {template && (
+
+ {status.label}
+
+ )}
+
+
Update this email's category, subject and body.
+
+
+
+ {pending && (
+
+
+
+ This template has pending changes that haven't gone out yet — the version
+ currently emailed to users is the last one you sent. Press Send below to
+ publish these edits, or Save as Draft to keep working without publishing.
+
+
+ )}
+
+ {isSystem && (
+
+
+
+ This is a system template — code sends it by referencing this exact type,
+ so the type is locked. Category, label, subject and body are still fully editable.
+
+
+ )}
+
+
+
+
+
+
+
+
Cannot be changed after creation.
+
+
+
+
+ setLabel(e.target.value)} placeholder="e.g. Invoice Receipt" />
+
+
+
+
+
+
+
+ Organizational only — doesn't affect how or when this email is sent.
+
+
+
+
+
+ setSubject(e.target.value)} placeholder="e.g. Your Invoice - STARR System" />
+
+
+
+
+
+
+
{isMarkdownMode ? "Body" : "HTML Body"}
+
+
+
+
+
+
+
+ {isMarkdownMode ? (
+ <>
+ Write this in Markdown — it's converted to HTML automatically when
+ you save (mandatory storage format; only HTML is ever sent). Header, footer and
+ signature are fixed and added automatically; this box is just the message content in between.
+ >
+ ) : (
+ <>
+ 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.
+ >
+ )}
+
+
+ {(knownPlaceholders !== null) && (
+
+
Available placeholders
+ {knownPlaceholders.length ? (
+
+ {knownPlaceholders.map((ph) => (
+
+ {`{{${ph}}}`}
+
+ ))}
+
+ ) : (
+
This template has no dynamic placeholders.
+ )}
+
+ )}
+
+ {showPreview ? (
+ isMarkdownMode ? (
+
+ {bodyValue.trim() ?
{bodyValue} :
Nothing to preview yet.
}
+
+ ) : (
+ Nothing to preview yet." }}
+ />
+ )
+ ) : (
+
+
+
+
+ );
+}
+
+export default function EditEmailTemplate() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/email_templates/EmailBroadcasts.jsx b/src/modules/admin/pages/email_templates/EmailBroadcasts.jsx
new file mode 100644
index 0000000..eeb2168
--- /dev/null
+++ b/src/modules/admin/pages/email_templates/EmailBroadcasts.jsx
@@ -0,0 +1,140 @@
+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 (
+
+ );
+}
+
+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 (
+
+
+
+
{item.template?.label ?? "(deleted template)"}
+
+ {target?.label ?? item.target_type}
+ {item.target_id && #{item.target_id}}
+ {" · "}
+ {new Date(item.createdAt).toLocaleString()}
+
+
+
+ {status.label}
+ {cancelable && (
+
+ )}
+
+
+
+
+
+
+ {item.sent_count} sent
+ {item.failed_count > 0 && · {item.failed_count} failed}
+ {" "}/ {item.total_recipients} total
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Email Templates", to: "/admin/email-templates" },
+ { label: "Sent History" },
+ ]} />
+
+
+
+
Sent Email History
+
+ 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.
+
+
+
+
+
+ {loading && !broadcasts.length ? (
+
+ ) : !broadcasts.length ? (
+
+
+
No broadcasts sent yet.
+
+
+ ) : (
+
+ {broadcasts.map((item) => (
+ cancelBroadcast(b.email_broadcast_id)} />
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export default function EmailBroadcasts() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/email_templates/EmailTemplates.jsx b/src/modules/admin/pages/email_templates/EmailTemplates.jsx
new file mode 100644
index 0000000..d8677a1
--- /dev/null
+++ b/src/modules/admin/pages/email_templates/EmailTemplates.jsx
@@ -0,0 +1,272 @@
+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 (
+
+
+
+
+
+
+
+ {!item.is_system && (
+
+ )}
+
+
+
+
+
+
{item.label}
+ {item.is_system && (
+
+ System
+
+ )}
+
+
{item.type}
+
+ {item.subject || item.draft_subject || "No subject yet"}
+
+
+
+
+
+ {cat.label}
+
+
+ {status.label}
+
+ {pending && (
+
+ Pending changes
+
+ )}
+
+
+ {isBroadcastable(item) && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Email Templates" },
+ ]} />
+
+
+
+
+
Email Templates
+
+ Subject lines and message content for every automated email STARR sends.
+
+
+
+
+ {templates.filter((t) => t.status === "sent").length} sent
+
+
+
+ {templates.filter((t) => t.status === "draft").length} draft
+
+ {templates.some(hasPendingChanges) && (
+
+
+ {templates.filter(hasPendingChanges).length} with pending changes
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ System templates are sent automatically by platform code and cannot be
+ deleted or have their type changed — the subject and body stay fully editable.
+
+
+ Draft vs. Sent: a Sent 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 Send again to publish it. A
+ brand-new Draft isn't used for anything until it's sent for the first time.
+
+
+ Limitations: 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 {"{{placeholder}}"} tokens
+ that get swapped for real values when the email is sent.
+
+
+
+
+
+
+ {EMAIL_TEMPLATE_CATEGORIES.map((cat) => {
+ const Icon = cat.icon;
+ const count = templates.filter((t) => t.category === cat.value).length;
+ return (
+
+ );
+ })}
+
+
+
+
+ {loading && !templates.length ? (
+
+ ) : !filtered.length ? (
+
No email templates found.
+ ) : (
+
+ {filtered.map((item) => (
+ navigate(`/admin/email-templates/${t.email_template_id}/edit`)}
+ onDelete={(t) => setDeleteTarget(t)}
+ onSend={(t) => setSendTarget(t)}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* Delete confirmation dialog */}
+
+
+ {/* Send to Recipients dialog */}
+ { if (!open) setSendTarget(null); }}
+ template={sendTarget}
+ />
+
+ );
+}
+
+export default function EmailTemplates() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
new file mode 100644
index 0000000..83d3e91
--- /dev/null
+++ b/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
@@ -0,0 +1,182 @@
+// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
+
+import { useNavigate } from "react-router-dom";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { House } from "lucide-react";
+
+import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
+import { useAuth } from "@/contexts/AuthContext";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
+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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+
+import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
+
+// ─── Schema ─────────────────────────────────────────────────────────────────
+
+const schema = z.object({
+ title: z.string().min(1, "Title is required."),
+ message: z.string().min(1, "Message is required."),
+ target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
+ target_id: z.string().nullable().optional(),
+}).superRefine((data, ctx) => {
+ if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Please select a specific target.",
+ path: ["target_id"],
+ });
+ }
+});
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function FieldError({ message }) {
+ if (!message) return null;
+ return
{message}
;
+}
+
+function SectionCard({ title, description, children }) {
+ return (
+
+ {(title || description) && (
+
+ {title &&
{title}
}
+ {description &&
{description}
}
+
+ )}
+ {children}
+
+ );
+}
+
+// ─── Page ───────────────────────────────────────────────────────────────────
+
+export default function AddNotificationBroadcast() {
+ const navigate = useNavigate();
+ const { createBroadcast, loading } = useNotificationBroadcasts();
+ const { user } = useAuth();
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ title: "",
+ message: "",
+ target_type: undefined,
+ target_id: null,
+ },
+ });
+
+ const targetType = watch("target_type");
+ const targetId = watch("target_id");
+ const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
+
+ const breadcrumbItems = [
+ { label: "Home", icon:
, to: "/admin" },
+ { label: "Notifications", to: "/admin/notifications" },
+ { label: "New" },
+ ];
+
+ const onSubmit = async (values) => {
+ const payload = {
+ ...values,
+ target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
+ createdBy: user?.user_id ?? null,
+ };
+
+ const res = await createBroadcast(payload);
+ if (res) navigate("/admin/notifications");
+ };
+
+ return (
+
+
+
+
+
+
New notification
+
Compose an announcement. It's saved as a draft until you send it.
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx
new file mode 100644
index 0000000..1a3b83d
--- /dev/null
+++ b/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx
@@ -0,0 +1,202 @@
+// modules/admin/pages/notifications/EditNotificationBroadcast.jsx
+
+import { useEffect } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { House } from "lucide-react";
+
+import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
+import { useAuth } from "@/contexts/AuthContext";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
+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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+
+import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
+
+// ─── Schema ─────────────────────────────────────────────────────────────────
+
+const schema = z.object({
+ title: z.string().min(1, "Title is required."),
+ message: z.string().min(1, "Message is required."),
+ target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
+ target_id: z.string().nullable().optional(),
+}).superRefine((data, ctx) => {
+ if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Please select a specific target.",
+ path: ["target_id"],
+ });
+ }
+});
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function FieldError({ message }) {
+ if (!message) return null;
+ return
{message}
;
+}
+
+function SectionCard({ title, description, children }) {
+ return (
+
+ {(title || description) && (
+
+ {title &&
{title}
}
+ {description &&
{description}
}
+
+ )}
+ {children}
+
+ );
+}
+
+// ─── Page ───────────────────────────────────────────────────────────────────
+
+export default function EditNotificationBroadcast() {
+ const navigate = useNavigate();
+ const { broadcastId } = useParams();
+ const { fetchBroadcast, updateBroadcast, loading } = useNotificationBroadcasts();
+ const { user } = useAuth();
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ title: "",
+ message: "",
+ target_type: undefined,
+ target_id: null,
+ },
+ });
+
+ const targetType = watch("target_type");
+ const targetId = watch("target_id");
+ const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
+
+ const breadcrumbItems = [
+ { label: "Home", icon:
, to: "/admin" },
+ { label: "Notifications", to: "/admin/notifications" },
+ { label: "Edit" },
+ ];
+
+ // ─── Load existing broadcast data ─────────────────────────────────────────
+ useEffect(() => {
+ (async () => {
+ const res = await fetchBroadcast(broadcastId);
+ const b = res?.data?.data ?? null;
+ if (!b) return;
+
+ reset({
+ title: b.title ?? "",
+ message: b.message ?? "",
+ target_type: b.target_type ?? undefined,
+ target_id: b.target_id ?? null,
+ });
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [broadcastId]);
+
+ const onSubmit = async (values) => {
+ const payload = {
+ ...values,
+ target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
+ updatedBy: user?.user_id ?? null,
+ };
+
+ const res = await updateBroadcast(broadcastId, payload);
+ if (res) navigate("/admin/notifications");
+ };
+
+ return (
+
+
+
+
+
+
Edit notification
+
Only draft notifications can be edited.
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
new file mode 100644
index 0000000..bd8b6a6
--- /dev/null
+++ b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
@@ -0,0 +1,276 @@
+// modules/admin/pages/notifications/NotificationBroadcastList.jsx
+
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Settings } from "lucide-react";
+
+import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { TablePagination } from "@/components/generic/Table/TablePagination";
+import { useDateFormat } from "@/hooks/useDateFormat";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+
+import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
+
+export default function NotificationBroadcastList() {
+ const navigate = useNavigate();
+ const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
+
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [search, setSearch] = useState("");
+ const [limit, setLimit] = useState(12);
+
+ function buildFilters() {
+ const filters = [];
+ if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
+ if (search.trim()) filters.push({ id: "title", value: search.trim() });
+ return filters;
+ }
+
+ useEffect(() => {
+ fetchBroadcasts({ page: 1, limit, filters: buildFilters() });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [statusFilter, search, limit]);
+
+ const items = [
+ { label: "Home", icon:
, to: "/admin" },
+ { label: "Notifications" },
+ ];
+
+ const total = pagination?.totalRecords ?? broadcasts.length;
+ const draftCount = broadcasts.filter((b) => b.status === "draft").length;
+ const sentCount = broadcasts.filter((b) => b.status === "sent").length;
+
+ async function handleSend(broadcastId) {
+ await sendBroadcast(broadcastId);
+ }
+
+ async function handleArchive(broadcastId) {
+ await archiveBroadcast(broadcastId);
+ }
+
+ return (
+
+
+
+
+
+
+ {/* ── Header ─────────────────────────────────────────────────── */}
+
+
+
Notifications
+
Compose and send announcements to admins and users
+
+
+
+
+
+
+
+ {/* ── Stat cards ─────────────────────────────────────────────── */}
+
+
+
+
+
+
+ {/* ── Filters ────────────────────────────────────────────────── */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {/* ── Grid ───────────────────────────────────────────────────── */}
+ {loading ? (
+
+
+
+ ) : broadcasts.length === 0 ? (
+
navigate("/admin/notifications/add")} />
+ ) : (
+ <>
+
+ {broadcasts.map((b) => (
+ navigate(`/admin/notifications/${b.broadcast_id}/view`)}
+ onEdit={() => navigate(`/admin/notifications/${b.broadcast_id}/edit`)}
+ onSend={() => handleSend(b.broadcast_id)}
+ onArchive={() => handleArchive(b.broadcast_id)}
+ />
+ ))}
+
+
+
+
fetchBroadcasts({ page, limit, filters: buildFilters() })}
+ onPageSizeChange={(size) => setLimit(size)}
+ />
+
+ >
+ )}
+
+
+
+ );
+}
+
+// ─── Stat card ──────────────────────────────────────────────────────────────
+
+function StatCard({ label, value, tone = "default" }) {
+ const toneClass = {
+ default: "text-foreground",
+ success: "text-green-600 dark:text-green-400",
+ muted: "text-muted-foreground",
+ }[tone];
+
+ return (
+
+ );
+}
+
+// ─── Broadcast card ─────────────────────────────────────────────────────────
+
+function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
+ const { fmtDateTime } = useDateFormat();
+ const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
+ const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
+ const TargetIcon = targetMeta.icon ?? Users;
+ const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
+ const isDraft = broadcast.status === "draft";
+
+ return (
+
+
+
+
+
+ {isDraft && (
+
+
+
+
+
+
+ Send this notification?
+
+ "{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
+
+
+
+ Cancel
+ Send
+
+
+
+ )}
+ {isDraft && (
+
+ )}
+
+
+
+
+
+
+ Archive this notification?
+
+ "{broadcast.title || "This notification"}" will be moved to archived notifications. You can restore it later.
+
+
+
+ Cancel
+ Archive
+
+
+
+
+
+
+ );
+}
+
+// ─── Empty state ────────────────────────────────────────────────────────────
+
+function EmptyState({ onCreate }) {
+ return (
+
+
+
+
No notifications yet
+
Compose your first announcement to admins or users.
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/NotificationSettings.jsx b/src/modules/admin/pages/notifications/NotificationSettings.jsx
new file mode 100644
index 0000000..9814fc7
--- /dev/null
+++ b/src/modules/admin/pages/notifications/NotificationSettings.jsx
@@ -0,0 +1,162 @@
+// modules/admin/pages/notifications/NotificationSettings.jsx
+
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { House, ArrowLeft, Clock } from "lucide-react";
+import { toast } from "sonner";
+
+import api from "@/utils/api.util";
+import { useAuth } from "@/contexts/AuthContext";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { useDateFormat } from "@/hooks/useDateFormat";
+import { Button } from "@/components/ui/button";
+import { Switch } from "@/components/ui/switch";
+import { Spinner } from "@/components/ui/spinner";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+
+import { CRON_PRESET_OPTIONS, JOB_LABELS } from "@/data/cronPresets.data";
+
+function SectionCard({ children }) {
+ return
{children}
;
+}
+
+export default function NotificationSettings() {
+ const navigate = useNavigate();
+ const { user } = useAuth();
+ const { fmtDateTime } = useDateFormat();
+
+ const [settings, setSettings] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [savingJob, setSavingJob] = useState(null);
+
+ const breadcrumbItems = [
+ { label: "Home", icon:
, to: "/admin" },
+ { label: "Notifications", to: "/admin/notifications" },
+ { label: "Settings" },
+ ];
+
+ async function fetchSettings() {
+ setLoading(true);
+ try {
+ const { data } = await api.get("/admin/notification-settings");
+ setSettings(data?.data ?? []);
+ } catch (err) {
+ toast.error(err?.response?.data?.message ?? "Failed to load notification settings.");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => { fetchSettings(); }, []);
+
+ async function handleToggle(jobName, enabled) {
+ setSavingJob(jobName);
+ try {
+ const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
+ enabled,
+ updatedBy: user?.user_id ?? null,
+ });
+ const updated = data?.data?.data;
+ setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
+ toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`);
+ } catch (err) {
+ toast.error(err?.response?.data?.message ?? "Failed to update setting.");
+ } finally {
+ setSavingJob(null);
+ }
+ }
+
+ async function handlePresetChange(jobName, preset) {
+ setSavingJob(jobName);
+ try {
+ const { data } = await api.patch(`/admin/notification-settings/${jobName}`, {
+ preset,
+ updatedBy: user?.user_id ?? null,
+ });
+ const updated = data?.data?.data;
+ setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
+ toast.success("Schedule updated — took effect immediately, no restart needed.");
+ } catch (err) {
+ toast.error(err?.response?.data?.message ?? "Failed to update schedule.");
+ } finally {
+ setSavingJob(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
Notification Settings
+
+ Toggle and reschedule automatic notifications without a deploy.
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {settings.map((s) => {
+ const meta = JOB_LABELS[s.job_name] ?? {};
+ const isSaving = savingJob === s.job_name;
+ return (
+
+
+
+
{meta.label ?? s.label ?? s.job_name}
+
{meta.description ?? s.description}
+ {s.updatedAt && (
+
+
+ Last updated {fmtDateTime(s.updatedAt)}
+
+ )}
+
+
handleToggle(s.job_name, v)}
+ />
+
+
+
+ Runs:
+
+ {isSaving && }
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
new file mode 100644
index 0000000..060683b
--- /dev/null
+++ b/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
@@ -0,0 +1,233 @@
+// modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
+
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { Edit, ArrowLeft, Send, Users, FileText, BadgeCheck, Megaphone } from "lucide-react";
+
+import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
+import { useDateFormat } from "@/hooks/useDateFormat";
+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 {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+
+import { TARGET_TYPE_MAP, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
+
+// ─── Shared helpers ─────────────────────────────────────────────────────────
+
+function SectionCard({ icon: Icon, title, children }) {
+ return (
+
+
+ {Icon && }
+
{title}
+
+
+ {children}
+
+ );
+}
+
+function Field({ label, children }) {
+ return (
+
+ {label}
+ {children ?? —}
+
+ );
+}
+
+// ─── Tab: Content ───────────────────────────────────────────────────────────
+
+function ContentTab({ broadcast }) {
+ return (
+
+ {broadcast.title || "—"}
+
+ {broadcast.message || "—"}
+
+
+ );
+}
+
+// ─── Tab: Delivery ───────────────────────────────────────────────────────────
+
+function DeliveryTab({ broadcast, targetText, fmtDateTime }) {
+ return (
+
+
+ {targetText}
+ {broadcast.recipient_count ?? 0}
+ {broadcast.sent_at ? fmtDateTime(broadcast.sent_at) : "Not sent yet"}
+
+
+ );
+}
+
+// ─── Tab: Audit ───────────────────────────────────────────────────────────────
+
+function AuditTab({ broadcast, fmtDateTime }) {
+ return (
+
+
+ {broadcast.creator?.full_name || "—"}
+ {fmtDateTime(broadcast.createdAt)}
+ {broadcast.updater?.full_name || "—"}
+ {fmtDateTime(broadcast.updatedAt)}
+
+
+ );
+}
+
+// ─── Tabs config ───────────────────────────────────────────────────────────────
+
+const TABS = [
+ { key: "content", label: "Content", icon: FileText },
+ { key: "delivery", label: "Delivery", icon: Send },
+ { key: "audit", label: "Audit", icon: BadgeCheck },
+];
+
+// ─── Page ───────────────────────────────────────────────────────────────────
+
+export default function ViewNotificationBroadcast() {
+ const navigate = useNavigate();
+ const { broadcastId } = useParams();
+ const { fetchBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
+ const { fmtDateTime } = useDateFormat();
+
+ const [broadcast, setBroadcast] = useState(null);
+ const [activeTab, setActiveTab] = useState("content");
+
+ useEffect(() => {
+ (async () => {
+ const res = await fetchBroadcast(broadcastId);
+ setBroadcast(res?.data?.data ?? null);
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [broadcastId]);
+
+ if (loading && !broadcast) {
+ return (
+
+
+
+ );
+ }
+
+ if (!broadcast) {
+ return (
+
+
Notification not found.
+
+ );
+ }
+
+ const statusMeta = BROADCAST_STATUS_MAP[broadcast.status] ?? {};
+ const targetMeta = TARGET_TYPE_MAP[broadcast.target_type] ?? {};
+ const TargetIcon = targetMeta.icon ?? Users;
+ const targetText = broadcast.target_label ? `${targetMeta.label}: ${broadcast.target_label}` : (targetMeta.label ?? broadcast.target_type);
+ const isDraft = broadcast.status === "draft";
+
+ async function handleSend() {
+ const res = await sendBroadcast(broadcastId);
+ if (res) {
+ const refreshed = await fetchBroadcast(broadcastId);
+ setBroadcast(refreshed?.data?.data ?? broadcast);
+ }
+ }
+
+ return (
+
+
+ {/* ── Sticky header ── */}
+
+
+
+
+
+
+
+ {broadcast.title || "Untitled notification"}
+
+
+
+ {statusMeta.label ?? broadcast.status}
+
+
+
+ {targetText}
+
+
+
+
+ {isDraft && (
+
+
+
+
+
+
+
+ Send this notification?
+
+ "{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
+
+
+
+ Cancel
+ Send
+
+
+
+
+
+ )}
+
+
+ {/* Underline tabs */}
+
+ {TABS.map(({ key, label, icon: Icon }) => (
+
+ ))}
+
+
+
+
+ {/* ── Content ── */}
+
+
+ {activeTab === "content" &&
}
+ {activeTab === "delivery" &&
}
+ {activeTab === "audit" &&
}
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/task_list/CreateTaskList.jsx b/src/modules/admin/pages/task_list/CreateTaskList.jsx
index c0a3b8d..4b1c150 100644
--- a/src/modules/admin/pages/task_list/CreateTaskList.jsx
+++ b/src/modules/admin/pages/task_list/CreateTaskList.jsx
@@ -1,34 +1,73 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
+import api from '@/utils/api.util';
+import { cn } from '@/lib/utils';
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, CardHeader, CardTitle } from '@/components/ui/card';
-import { ArrowLeft } from 'lucide-react';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ClipboardList } from 'lucide-react';
+
+// ─── Steps ────────────────────────────────────────────────────────────────────
+const STEPS = [
+ { id: 0, label: 'Details', icon: FileText },
+ { id: 1, label: 'Assign Groups', icon: Users },
+ { id: 2, label: 'Review', icon: ClipboardList },
+];
+
+// ─── Summary row ──────────────────────────────────────────────────────────────
+function SummaryRow({ label, value }) {
+ if (!value) return null;
+ return (
+
+ {label}
+ {value}
+
+ );
+}
export default function CreateTaskList() {
const navigate = useNavigate();
const { createTaskList, assignGroups, loading } = useAdminTask();
+ const [step, setStep] = useState(0);
const [form, setForm] = useState({ name: '', description: '' });
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
+ const [allGroups, setAllGroups] = useState([]);
const [errors, setErrors] = useState({});
- const validate = () => {
+ // Fetch groups for the review step's summary (names, not just ids)
+ useEffect(() => {
+ api.get('/admin/groups', { params: { limit: 500 } })
+ .then((res) => setAllGroups(res.data?.data?.data ?? res.data?.data ?? []))
+ .catch(() => {});
+ }, []);
+
+ const validateDetails = () => {
const e = {};
if (!form.name.trim()) e.name = 'Task list name is required.';
setErrors(e);
return Object.keys(e).length === 0;
};
- const handleSubmit = async (e) => {
- e.preventDefault();
- if (!validate()) return;
+ const handleNext = () => {
+ if (step === 0 && !validateDetails()) return;
+ setStep((s) => s + 1);
+ };
+
+ const handleBack = () => {
+ if (step === 0) navigate('/admin/taskList');
+ else setStep((s) => s - 1);
+ };
+
+ const handleCreate = async () => {
+ if (!validateDetails()) { setStep(0); return; }
const created = await createTaskList({
name: form.name.trim(),
@@ -45,10 +84,17 @@ export default function CreateTaskList() {
navigate(`/admin/taskList/${created.task_list_id}/view`);
};
+ const selectedGroupNames = allGroups
+ .filter((g) => selectedGroupIds.includes(g.group_id))
+ .map((g) => g.name);
+
return (
+ // ← plain div, no