new email management page
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
updateEmailRequest,
|
||||
deleteEmailRequest,
|
||||
type EmailRequest,
|
||||
type CreateEmailRequestDto,
|
||||
type UpdateEmailRequestDto,
|
||||
} from "@/lib/api/emailRequests";
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
|
||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
|
||||
const [formData, setFormData] = useState<CreateEmailRequestDto>({
|
||||
email: "",
|
||||
status: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||
|
||||
const loadEmails = async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests(token);
|
||||
setEmails(data.data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load email requests");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [token]);
|
||||
|
||||
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { ALL: 0, PENDING: 0, SENT: 0, FAILED: 0, OTHER: 0 } as Record<
|
||||
EmailStatusTab,
|
||||
number
|
||||
>;
|
||||
|
||||
c.ALL = emails.length;
|
||||
|
||||
for (const e of emails) {
|
||||
const st = normalizeStatus(e.status);
|
||||
if (st === "PENDING") c.PENDING += 1;
|
||||
else if (st === "SENT") c.SENT += 1;
|
||||
else if (st === "FAILED") c.FAILED += 1;
|
||||
else c.OTHER += 1;
|
||||
}
|
||||
|
||||
return c;
|
||||
}, [emails]);
|
||||
|
||||
const filteredEmails = useMemo(() => {
|
||||
if (statusTab === "ALL") return emails;
|
||||
|
||||
return emails.filter((e) => {
|
||||
const st = normalizeStatus(e.status);
|
||||
if (statusTab === "OTHER") {
|
||||
return st !== "PENDING" && st !== "SENT" && st !== "FAILED";
|
||||
}
|
||||
return st === statusTab;
|
||||
});
|
||||
}, [emails, statusTab]);
|
||||
|
||||
const tabs: { key: EmailStatusTab; label: string }[] = [
|
||||
{ key: "ALL", label: "All" },
|
||||
{ key: "PENDING", label: "Pending" },
|
||||
{ key: "SENT", label: "Sent" },
|
||||
{ key: "FAILED", label: "Failed" },
|
||||
{ key: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
const handleCreate = () => {
|
||||
setModalMode("create");
|
||||
setFormData({ email: "", status: "" });
|
||||
setSelectedEmail(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (email: EmailRequest) => {
|
||||
setModalMode("edit");
|
||||
setFormData({ email: email.email, status: email.status });
|
||||
setSelectedEmail(email);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token) return;
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(token, id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(token, formData);
|
||||
} else if (selectedEmail) {
|
||||
const updateData: UpdateEmailRequestDto = {};
|
||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||
await updateEmailRequest(token, selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to save email request");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Emails</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
Manage Email and their statuses.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tabs.map((t) => {
|
||||
const active = statusTab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setStatusTab(t.key)}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition ${
|
||||
active
|
||||
? "border-amber-500/60 bg-amber-500/10 text-amber-200"
|
||||
: "border-zinc-800 bg-zinc-950/60 text-zinc-300 hover:border-amber-400/60 hover:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ${
|
||||
active ? "bg-amber-500/15 text-amber-200" : "bg-zinc-900 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{counts[t.key]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">ID</th>
|
||||
<th className="px-3 py-2 font-medium">Email</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Created At</th>
|
||||
<th className="px-3 py-2 font-medium">Updated At</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEmails.map((email) => (
|
||||
<tr
|
||||
key={email.id}
|
||||
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||
>
|
||||
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
|
||||
{email.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{new Date(email.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{email.updatedAt ? formatDate(email.updatedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(email)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(email.id)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filteredEmails.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||
>
|
||||
No email requests found for{" "}
|
||||
<span className="text-zinc-300">{tabs.find((t) => t.key === statusTab)?.label}</span>.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">
|
||||
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Status
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="pending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type UpdateEmailRequestDto,
|
||||
} from "@/lib/api/emailRequests";
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/api/dateFormattingUtility";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
@@ -110,7 +110,7 @@ export default function AdminEmailPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Email Requests
|
||||
Processed E-mail
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
Manage email requests and their statuses.
|
||||
@@ -155,7 +155,7 @@ export default function AdminEmailPage() {
|
||||
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||
>
|
||||
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{email.email}</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
|
||||
{email.status}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Settings,
|
||||
LucideMail,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type AdminLeftNavigationProps = {
|
||||
collapsed: boolean;
|
||||
@@ -59,8 +60,8 @@ const navItems = [
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "List Emails",
|
||||
href: "/admin/email",
|
||||
label: "Manage Emails",
|
||||
href: "/admin/email/manage",
|
||||
icon: <LucideMail className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
@@ -97,7 +98,7 @@ export default function AdminLeftNavigation({
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||
Battl Builders
|
||||
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link>
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import QuillImageResize from "quill-image-resize-module";
|
||||
Quill.register("modules/imageResize", QuillImageResize);
|
||||
import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface EmailRequest {
|
||||
id: number;
|
||||
recipient: string;
|
||||
email: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
|
||||
Generated
+63
@@ -12,6 +12,7 @@
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "14.2.3",
|
||||
"quill": "^2.0.3",
|
||||
"quill-image-resize-module": "^3.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-quill": "^2.0.0"
|
||||
@@ -4712,6 +4713,68 @@
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-image-resize-module": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-image-resize-module/-/quill-image-resize-module-3.0.0.tgz",
|
||||
"integrity": "sha512-1TZBnUxU/WIx5dPyVjQ9yN7C6mLZSp04HyWBEMqT320DIq4MW4JgzlOPDZX5ZpBM3bU6sacU4kTLUc8VgYQZYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.4",
|
||||
"quill": "^1.2.2",
|
||||
"raw-loader": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-image-resize-module/node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill-image-resize-module/node_modules/fast-diff": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/quill-image-resize-module/node_modules/parchment": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/quill-image-resize-module/node_modules/quill": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clone": "^2.1.1",
|
||||
"deep-equal": "^1.0.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"extend": "^3.0.2",
|
||||
"parchment": "^1.1.4",
|
||||
"quill-delta": "^3.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-image-resize-module/node_modules/quill-delta": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-equal": "^1.0.1",
|
||||
"extend": "^3.0.2",
|
||||
"fast-diff": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-loader": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz",
|
||||
"integrity": "sha512-sf7oGoLuaYAScB4VGr0tzetsYlS8EJH6qnTCfQ/WVEa89hALQ4RQfCKt5xCyPQKPDUbVUAIP1QsxAwfAjlDp7Q=="
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "14.2.3",
|
||||
"quill": "^2.0.3",
|
||||
"quill-image-resize-module": "^3.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-quill": "^2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user