dev #19
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||
import { eq, desc, and, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
const search = c.req.query('search');
|
||||
const limit = parseInt(c.req.query('limit') || '50');
|
||||
const offset = parseInt(c.req.query('offset') || '0');
|
||||
|
||||
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
if (status) {
|
||||
conditions.push(eq((emailLogs as any).status, status));
|
||||
}
|
||||
if (search && search.trim()) {
|
||||
const term = `%${search.trim().toLowerCase()}%`;
|
||||
conditions.push(or(
|
||||
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
|
||||
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
|
||||
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
|
||||
));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -55,6 +56,9 @@ export default function AdminEmailsPage() {
|
||||
const [logsOffset, setLogsOffset] = useState(0);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
|
||||
const [logsSearch, setLogsSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [logsEventFilter, setLogsEventFilter] = useState('');
|
||||
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||
|
||||
@@ -214,11 +218,20 @@ export default function AdminEmailsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [logsSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogsOffset(0);
|
||||
}, [debouncedSearch, logsEventFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
}, [activeTab, logsOffset, logsSubTab]);
|
||||
}, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -241,6 +254,8 @@ export default function AdminEmailsPage() {
|
||||
limit: 20,
|
||||
offset: logsOffset,
|
||||
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
|
||||
});
|
||||
setLogs(res.logs);
|
||||
setLogsTotal(res.pagination.total);
|
||||
@@ -757,6 +772,41 @@ export default function AdminEmailsPage() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Filters: search + event */}
|
||||
<div className="flex flex-col md:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={logsSearch}
|
||||
onChange={(e) => setLogsSearch(e.target.value)}
|
||||
placeholder="Search by recipient or subject..."
|
||||
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
{logsSearch && (
|
||||
<button
|
||||
onClick={() => setLogsSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={logsEventFilter}
|
||||
onChange={(e) => setLogsEventFilter(e.target.value)}
|
||||
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -772,7 +822,7 @@ export default function AdminEmailsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{logs.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
@@ -829,7 +879,7 @@ export default function AdminEmailsPage() {
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
||||
<div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||
|
||||
@@ -496,10 +496,11 @@ export const emailsApi = {
|
||||
}),
|
||||
|
||||
// Logs
|
||||
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
|
||||
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.eventId) query.set('eventId', params.eventId);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit) query.set('limit', params.limit.toString());
|
||||
if (params?.offset) query.set('offset', params.offset.toString());
|
||||
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
||||
|
||||
Reference in New Issue
Block a user