Files
starr-philproperties/src/modules/admin/pages/notifications/NotificationTemplates.jsx
T
kennethobsequio 71f758fe0b merged
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-11 12:12:40 +08:00

243 lines
12 KiB
React

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Pencil, Bell, Lock, Send, Clock3, Plus, Trash2 } 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 {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminNotificationTemplateProvider,
useAdminNotificationTemplates,
} from "@/contexts/AdminNotificationTemplateContext";
import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data/notificationTemplateTypes.data";
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
import { cn } from "@/lib/utils";
function TemplateCard({ item, onEdit, onDelete }) {
const typeMeta = getNotificationTemplateType(item.notify_type);
const TypeIcon = typeMeta?.icon ?? Bell;
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">
<TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="flex items-center gap-0.5">
{!item.is_system && (
<Button type="button" variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => onDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</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.title || item.draft_title || "No title yet"}</span>
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{typeMeta && (
<Badge variant="outline" className="gap-1 text-[11px]">
<typeMeta.icon className="h-3 w-3" /> {typeMeta.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>
</div>
);
}
function NotificationTemplatesInner() {
const navigate = useNavigate();
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminNotificationTemplates();
const [activeType, setActiveType] = useState("all");
const [deleteTarget, setDeleteTarget] = useState(null);
useEffect(() => { fetchTemplates(); }, []);
const handleDelete = async () => {
if (!deleteTarget) return;
await deleteTemplate(deleteTarget.notification_template_id);
setDeleteTarget(null);
};
const filtered = useMemo(
() => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
[templates, activeType]
);
const typesInUse = useMemo(
() => NOTIFICATION_TEMPLATE_TYPES.filter((t) => templates.some((tpl) => tpl.notify_type === t.value)),
[templates]
);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Announcement 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: "Announcements", to: "/admin/announcements" },
{ label: "Templates" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Announcement Templates</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Title and message wording for every automated notification 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>
<Button type="button" onClick={() => navigate("/admin/announcement-templates/add")} className="gap-1.5">
<Plus className="h-4 w-4" /> Add Template
</Button>
</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 locked — code fires them by referencing their exact
type, so only the title and message wording is editable, never the type itself, and they
can't be deleted.
</p>
<p>
<strong>Custom</strong> templates (no lock icon) are reusable title/message presets you
create — pick one from the "Load from template" dropdown when composing a new announcement
to skip retyping recurring wording. You can freely create, edit, and delete these.
</p>
<p>
<strong>Draft vs. Sent</strong> (system templates only): a <strong>Sent</strong> template
is the version actually used for real notifications right now. Editing a Sent template
doesn't change what goes out immediately — it's held as a pending change until you press{" "}
<strong>Publish</strong> again.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-5">
<Button
type="button" size="sm" variant={activeType === "all" ? "secondary" : "outline"}
onClick={() => setActiveType("all")}
>
All ({templates.length})
</Button>
{typesInUse.map((t) => {
const Icon = t.icon;
const count = templates.filter((tpl) => tpl.notify_type === t.value).length;
return (
<Button
key={t.value}
type="button" size="sm"
variant={activeType === t.value ? "secondary" : "outline"}
onClick={() => setActiveType(t.value)}
className="gap-1.5"
>
<Icon className="h-3.5 w-3.5" /> {t.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 notification 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.notification_template_id}
item={item}
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
onDelete={setDeleteTarget}
/>
))}
</div>
)}
</div>
</div>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
<AlertDialogDescription>
"{deleteTarget?.label}" will be permanently removed. It won't affect any announcements already sent.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export default function NotificationTemplates() {
return (
<AdminNotificationTemplateProvider>
<NotificationTemplatesInner />
</AdminNotificationTemplateProvider>
);
}