mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
243 lines
12 KiB
React
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>
|
|
);
|
|
}
|