Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions frontend/app/admin/announcements/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client";

import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import { useGetAnnouncements } from "@/lib/react-query/hooks/admin/announcements/useGetAnnouncements";
import { useSendAnnouncement } from "@/lib/react-query/hooks/admin/announcements/useSendAnnouncement";
import { Megaphone, Plus, X } from "lucide-react";

const AUDIENCES = ["ALL_MEMBERS", "ACTIVE_MEMBERS", "STAFF"];
const CHANNELS = ["IN_APP", "EMAIL", "BOTH"];

export default function AdminAnnouncementsPage() {
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState({ title: "", content: "", audience: "ALL_MEMBERS", channel: "IN_APP" });

const { data, isLoading } = useGetAnnouncements();
const send = useSendAnnouncement();
const announcements = (data as any)?.items ?? (data as any)?.data ?? [];

Check warning on line 18 in frontend/app/admin/announcements/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Unexpected any. Specify a different type

Check warning on line 18 in frontend/app/admin/announcements/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Unexpected any. Specify a different type

const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
await send.mutateAsync({ title: form.title, content: form.content });
setShowModal(false);
setForm({ title: "", content: "", audience: "ALL_MEMBERS", channel: "IN_APP" });
};

return (
<DashboardLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Announcements</h1>
<p className="text-gray-500 text-sm mt-1">Broadcast messages to member segments.</p>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm hover:bg-gray-800"
>
<Plus className="w-4 h-4" /> New Announcement
</button>
</div>

{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : (Array.isArray(announcements) ? announcements : []).length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Megaphone className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>No announcements yet.</p>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-100 divide-y">
{(Array.isArray(announcements) ? announcements : []).map((ann: any) => (

Check warning on line 51 in frontend/app/admin/announcements/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Unexpected any. Specify a different type
<div key={ann.id} className="p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-gray-900">{ann.title}</p>
<p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{ann.content}</p>
</div>
<span className="text-xs text-gray-400 shrink-0">{new Date(ann.createdAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
)}

{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="font-semibold text-gray-900">New Announcement</h2>
<button onClick={() => setShowModal(false)}><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleSend} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Audience</label>
<select value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
{AUDIENCES.map((a) => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Channel</label>
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm">
{CHANNELS.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Body</label>
<textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} required rows={4} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none" />
</div>
<div className="flex gap-3 justify-end pt-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-sm border border-gray-200 rounded-lg">Cancel</button>
<button type="submit" disabled={send.isPending} className="px-4 py-2 text-sm bg-gray-900 text-white rounded-lg disabled:opacity-50">
{send.isPending ? "Sending..." : "Send"}
</button>
</div>
</form>
</div>
</div>
)}
</DashboardLayout>
);
}
121 changes: 121 additions & 0 deletions frontend/app/admin/audit-log/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import { useGetAuditLog } from "@/lib/react-query/hooks/admin/audit/useGetAuditLog";
import { ChevronDown, ChevronRight, Shield } from "lucide-react";
import { useAuthState } from "@/lib/store/authStore";

const RESOURCE_TYPES = ["", "User", "Booking", "Payment", "Workspace", "PromoCode"];

export default function AdminAuditLogPage() {
const { user } = useAuthState();
const [filters, setFilters] = useState<{ resourceType?: string; startDate?: string; endDate?: string; page: number }>({ page: 1 });
const [expanded, setExpanded] = useState<Set<string>>(new Set());

const { data, isLoading } = useGetAuditLog({ ...filters, limit: 20 });
const logs = (data as any)?.items ?? [];

Check warning on line 17 in frontend/app/admin/audit-log/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Unexpected any. Specify a different type
const meta = data as any;

Check warning on line 18 in frontend/app/admin/audit-log/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Unexpected any. Specify a different type

if (user?.role !== "super_admin" && user?.role !== "admin") {
return (
<DashboardLayout>
<div className="text-center py-16 text-gray-400">
<Shield className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>Access restricted to super admins.</p>
</div>
</DashboardLayout>
);
}

const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};

return (
<DashboardLayout>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
<p className="text-gray-500 text-sm mt-1">Read-only record of all sensitive admin actions.</p>
</div>

{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select
className="border border-gray-200 rounded-lg px-3 py-2 text-sm"
onChange={(e) => setFilters((f) => ({ ...f, resourceType: e.target.value || undefined, page: 1 }))}
>
{RESOURCE_TYPES.map((r) => <option key={r} value={r}>{r || "All resource types"}</option>)}
</select>
<input
type="date"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm"
onChange={(e) => setFilters((f) => ({ ...f, startDate: e.target.value || undefined, page: 1 }))}
/>
<input
type="date"
className="border border-gray-200 rounded-lg px-3 py-2 text-sm"
onChange={(e) => setFilters((f) => ({ ...f, endDate: e.target.value || undefined, page: 1 }))}
/>
</div>

{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : logs.length === 0 ? (
<div className="text-center py-16 text-gray-400">No audit records found.</div>
) : (
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
{["Actor", "Action", "Resource Type", "Resource ID", "Date/Time", "IP"].map((h) => (
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">{h}</th>
))}
<th />
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.map((log: any) => (
<>
<tr key={log.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => toggle(log.id)}>
<td className="px-4 py-3 text-gray-900">
{log.actor ? `${log.actor.firstname} ${log.actor.lastname}` : log.actorUserId?.slice(0, 8)}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{log.action}</td>
<td className="px-4 py-3 text-gray-500">{log.resourceType}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-400">{log.resourceId?.slice(0, 8)}…</td>
<td className="px-4 py-3 text-gray-400">{new Date(log.createdAt).toLocaleString()}</td>
<td className="px-4 py-3 text-gray-400">{log.ipAddress ?? "—"}</td>
<td className="px-4 py-3">
{expanded.has(log.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</td>
</tr>
{expanded.has(log.id) && (
<tr key={`${log.id}-detail`}>
<td colSpan={7} className="px-4 py-3 bg-gray-50">
<pre className="text-xs text-gray-600 overflow-auto max-h-40">{JSON.stringify(log.metadata, null, 2)}</pre>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
{meta?.totalPages > 1 && (
<div className="flex justify-between items-center px-4 py-3 border-t">
<span className="text-sm text-gray-500">Page {filters.page} of {meta.totalPages}</span>
<div className="flex gap-2">
<button onClick={() => setFilters((f) => ({ ...f, page: Math.max(1, f.page - 1) }))} disabled={filters.page === 1} className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">Previous</button>
<button onClick={() => setFilters((f) => ({ ...f, page: Math.min(meta.totalPages, f.page + 1) }))} disabled={filters.page === meta.totalPages} className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">Next</button>
</div>
</div>
)}
</div>
)}
</DashboardLayout>
);
}
135 changes: 135 additions & 0 deletions frontend/app/admin/events/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";

import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import { useGetAdminEvents } from "@/lib/react-query/hooks/admin/events/useGetAdminEvents";
import { useCreateEvent } from "@/lib/react-query/hooks/admin/events/useCreateEvent";
import { useCancelEvent } from "@/lib/react-query/hooks/admin/events/useCancelEvent";
import { Calendar, Plus, X } from "lucide-react";

const emptyForm = { title: "", description: "", host: "", startDate: "", endDate: "", capacity: 30, isPublic: true };

export default function AdminEventsPage() {
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState(emptyForm);

const { data, isLoading } = useGetAdminEvents();
const createEvent = useCreateEvent();
const cancelEvent = useCancelEvent();
const events = (data as any)?.data ?? (data as any) ?? [];

const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
await createEvent.mutateAsync(form);
setShowModal(false);
setForm(emptyForm);
};

return (
<DashboardLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Events</h1>
<p className="text-gray-500 text-sm mt-1">Manage hub events and RSVPs.</p>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm hover:bg-gray-800"
>
<Plus className="w-4 h-4" /> Create Event
</button>
</div>

{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : (Array.isArray(events) ? events : []).length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Calendar className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p>No events yet.</p>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
{["Title", "Start Date", "Capacity", "Status", "Actions"].map((h) => (
<th key={h} className="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{(Array.isArray(events) ? events : []).map((ev: any) => (
<tr key={ev.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{ev.title}</td>
<td className="px-4 py-3 text-gray-500">{new Date(ev.startDate || ev.date).toLocaleDateString()}</td>
<td className="px-4 py-3 text-gray-500">{ev.capacity ?? "—"}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${ev.isCancelled ? "bg-red-100 text-red-600" : "bg-green-100 text-green-700"}`}>
{ev.isCancelled ? "Cancelled" : "Upcoming"}
</span>
</td>
<td className="px-4 py-3">
{!ev.isCancelled && (
<button
onClick={() => { if (confirm("Cancel this event?")) cancelEvent.mutate(ev.id); }}
className="text-xs text-red-500 hover:underline"
>
Cancel
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h2 className="font-semibold text-gray-900">Create Event</h2>
<button onClick={() => setShowModal(false)}><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleCreate} className="p-5 space-y-3">
{[
{ label: "Title", key: "title", type: "text" },
{ label: "Host Name", key: "host", type: "text" },
{ label: "Start Date & Time", key: "startDate", type: "datetime-local" },
{ label: "End Date & Time", key: "endDate", type: "datetime-local" },
{ label: "Capacity", key: "capacity", type: "number" },
].map(({ label, key, type }) => (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input
type={type}
value={(form as any)[key]}
onChange={(e) => setForm({ ...form, [key]: type === "number" ? +e.target.value : e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
))}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-2">
<button type="button" onClick={() => setShowModal(false)} className="px-4 py-2 text-sm border border-gray-200 rounded-lg">Cancel</button>
<button type="submit" disabled={createEvent.isPending} className="px-4 py-2 text-sm bg-gray-900 text-white rounded-lg disabled:opacity-50">
{createEvent.isPending ? "Creating..." : "Create"}
</button>
</div>
</form>
</div>
</div>
)}
</DashboardLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/apiClient";

export const useGetAnnouncements = () => {
return useQuery({
queryKey: ["admin", "announcements"],
queryFn: () => apiClient.get<any>("/announcements"),
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/apiClient";

export const useSendAnnouncement = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { title: string; content: string; type?: string }) =>
apiClient.post<any>("/announcements", data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "announcements"] }),
});
};
Loading
Loading