mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
|
||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2, Archive } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
@@ -69,10 +69,16 @@ export default function AdvertisementList() {
|
||||
<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>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
|
||||
<Archive className="size-4" />
|
||||
Archived
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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 { 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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -80,6 +80,10 @@ export default function NotificationBroadcastList() {
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate("/admin/notifications/archived")}>
|
||||
<Archive className="size-4" />
|
||||
Archived
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/admin/notifications/add")}>
|
||||
<Plus className="size-4" />
|
||||
New notification
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import GroupMultiSelect from '@/components/generic/GroupMultiSelect';
|
||||
import TaskQueueStep from './TaskQueueStep';
|
||||
import api from '@/utils/api.util';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,13 +13,14 @@ 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 { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ClipboardList } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ListChecks, 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 },
|
||||
{ id: 2, label: 'Tasks', icon: ListChecks },
|
||||
{ id: 3, label: 'Review', icon: ClipboardList },
|
||||
];
|
||||
|
||||
// ─── Summary row ──────────────────────────────────────────────────────────────
|
||||
@@ -34,12 +36,20 @@ function SummaryRow({ label, value }) {
|
||||
|
||||
export default function CreateTaskList() {
|
||||
const navigate = useNavigate();
|
||||
const { createTaskList, assignGroups, loading } = useAdminTask();
|
||||
const {
|
||||
createTaskList, assignGroups, createTask,
|
||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||
loading,
|
||||
} = useAdminTask();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState({ name: '', description: '' });
|
||||
const [selectedGroupIds, setSelectedGroupIds] = 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({});
|
||||
|
||||
// Fetch groups for the review step's summary (names, not just ids)
|
||||
@@ -49,6 +59,13 @@ export default function CreateTaskList() {
|
||||
.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 e = {};
|
||||
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);
|
||||
}
|
||||
|
||||
// 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`);
|
||||
};
|
||||
|
||||
@@ -196,8 +228,24 @@ export default function CreateTaskList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Review ── */}
|
||||
{/* ── Step 3: Tasks ── */}
|
||||
{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="border border-border rounded-lg p-4 space-y-1">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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 { useNavigate, useParams } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
import { format, parseISO, isValid } from 'date-fns';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import RequirementBuilder from './RequirementBuilder';
|
||||
import { taskSchema, REQUIREMENT_TYPE_META, requirementSummaryText } from './task.schema';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -14,48 +14,9 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────────────────────
|
||||
const STEPS = [
|
||||
{ id: 0, label: 'Task Details', icon: FileText },
|
||||
@@ -140,8 +101,9 @@ export default function CreateTask() {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || 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
|
||||
requirements: form.requirements.map((r) => {
|
||||
requirements: result.data.requirements.map((r) => {
|
||||
const req = { ...r };
|
||||
delete req.duration_seconds;
|
||||
return req;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
|
||||
import RequirementBuilder from './RequirementBuilder';
|
||||
import { taskSchema } from './task.schema';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -20,27 +20,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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 = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
@@ -106,13 +85,17 @@ export default function EditTask() {
|
||||
};
|
||||
|
||||
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, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
deadline: form.deadline || null,
|
||||
status: form.status,
|
||||
// strip duration_seconds — it's only used for local validation
|
||||
requirements: form.requirements.map(({ duration_seconds, ...req }) => req),
|
||||
requirements,
|
||||
});
|
||||
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 || '—';
|
||||
}
|
||||
Reference in New Issue
Block a user