Adds a dedicated super admin page for managing idea submissions across all tenants. Includes status summary cards, filterable/searchable table, detail modal with status updates, and private admin notes for internal tracking (sprint refs, thoughts, follow-ups). Notes are not visible to tenant users. - Database: admin_note column on shared.ideas (019 migration) - Backend: PUT /admin/ideas/:id/note endpoint - Frontend: AdminIdeasPage with table, filters, detail modal - Sidebar: "Idea Submissions" nav link in admin sections - Routing: /admin/ideas route under SuperAdminRoute guard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
|
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
|
Tooltip, Paper,
|
|
} from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { notifications } from '@mantine/notifications';
|
|
import {
|
|
IconBulb, IconSearch, IconNote, IconFilter,
|
|
} from '@tabler/icons-react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import api from '../../services/api';
|
|
|
|
interface AdminIdea {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
adminNote: string | null;
|
|
orgId: string;
|
|
orgName: string;
|
|
userId: string;
|
|
userEmail: string;
|
|
userFirstName: string;
|
|
userLastName: string;
|
|
}
|
|
|
|
const statusColor: Record<string, string> = {
|
|
new: 'blue',
|
|
reviewed: 'yellow',
|
|
accepted: 'green',
|
|
rejected: 'red',
|
|
};
|
|
|
|
const statusOptions = [
|
|
{ value: 'new', label: 'New' },
|
|
{ value: 'reviewed', label: 'Reviewed' },
|
|
{ value: 'accepted', label: 'Accepted' },
|
|
{ value: 'rejected', label: 'Rejected' },
|
|
];
|
|
|
|
function formatDate(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return '—';
|
|
return new Date(dateStr).toLocaleDateString();
|
|
}
|
|
|
|
function formatDateTime(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return '—';
|
|
return new Date(dateStr).toLocaleString();
|
|
}
|
|
|
|
export function AdminIdeasPage() {
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
|
const [noteText, setNoteText] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
|
queryKey: ['admin-ideas'],
|
|
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
|
});
|
|
|
|
const updateStatus = useMutation({
|
|
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
|
await api.put(`/admin/ideas/${id}/status`, { status });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
|
notifications.show({ message: 'Status updated', color: 'green' });
|
|
},
|
|
});
|
|
|
|
const updateNote = useMutation({
|
|
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
|
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
|
notifications.show({ message: 'Note saved', color: 'green' });
|
|
},
|
|
});
|
|
|
|
const openIdeaDetail = (idea: AdminIdea) => {
|
|
setSelectedIdea(idea);
|
|
setNoteText(idea.adminNote || '');
|
|
openDetail();
|
|
};
|
|
|
|
const handleSaveNote = () => {
|
|
if (selectedIdea) {
|
|
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
|
}
|
|
};
|
|
|
|
const filtered = (ideas || []).filter((idea) => {
|
|
const matchesSearch = !search ||
|
|
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
|
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
|
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
|
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const counts = {
|
|
total: ideas?.length || 0,
|
|
new: ideas?.filter(i => i.status === 'new').length || 0,
|
|
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
|
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
|
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <Center h={400}><Loader /></Center>;
|
|
}
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between">
|
|
<Group>
|
|
<IconBulb size={28} />
|
|
<Title order={2}>Idea Submissions</Title>
|
|
</Group>
|
|
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
|
</Group>
|
|
|
|
{/* Summary cards */}
|
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
|
<Paper withBorder p="md" radius="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
|
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
|
</Paper>
|
|
<Paper withBorder p="md" radius="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
|
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
|
</Paper>
|
|
<Paper withBorder p="md" radius="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
|
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
|
</Paper>
|
|
<Paper withBorder p="md" radius="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
|
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
|
</Paper>
|
|
</SimpleGrid>
|
|
|
|
{/* Filters */}
|
|
<Group>
|
|
<TextInput
|
|
placeholder="Search ideas, tenants, users..."
|
|
leftSection={<IconSearch size={16} />}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<Select
|
|
placeholder="All statuses"
|
|
leftSection={<IconFilter size={16} />}
|
|
data={statusOptions}
|
|
value={statusFilter}
|
|
onChange={setStatusFilter}
|
|
clearable
|
|
w={160}
|
|
/>
|
|
</Group>
|
|
|
|
{/* Ideas table */}
|
|
<Card withBorder p={0}>
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Date</Table.Th>
|
|
<Table.Th>Tenant</Table.Th>
|
|
<Table.Th>Submitted By</Table.Th>
|
|
<Table.Th>Title</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th w={40}></Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{filtered.length === 0 ? (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={6}>
|
|
<Text ta="center" c="dimmed" py="lg">
|
|
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
) : (
|
|
filtered.map((idea) => (
|
|
<Table.Tr
|
|
key={idea.id}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={() => openIdeaDetail(idea)}
|
|
>
|
|
<Table.Td>
|
|
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
|
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
|
{idea.status}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{idea.adminNote && (
|
|
<Tooltip label="Has admin note">
|
|
<IconNote size={16} color="gray" />
|
|
</Tooltip>
|
|
)}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Card>
|
|
|
|
{/* Detail Modal */}
|
|
<Modal
|
|
opened={detailOpened}
|
|
onClose={closeDetail}
|
|
title={<Text fw={600}>Idea Detail</Text>}
|
|
size="lg"
|
|
>
|
|
{selectedIdea && (
|
|
<Stack>
|
|
<Card withBorder>
|
|
<SimpleGrid cols={2} spacing="xs">
|
|
<Text size="xs" c="dimmed">Tenant</Text>
|
|
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
|
<Text size="xs" c="dimmed">Submitted By</Text>
|
|
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
|
<Text size="xs" c="dimmed">Date</Text>
|
|
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
|
</SimpleGrid>
|
|
</Card>
|
|
|
|
<Card withBorder>
|
|
<Text fw={600} mb="xs">Title</Text>
|
|
<Text size="sm">{selectedIdea.title}</Text>
|
|
{selectedIdea.description && (
|
|
<>
|
|
<Text fw={600} mt="md" mb="xs">Description</Text>
|
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<Card withBorder>
|
|
<Text fw={600} mb="xs">Status</Text>
|
|
<Select
|
|
data={statusOptions}
|
|
value={selectedIdea.status}
|
|
onChange={(val) => {
|
|
if (val && val !== selectedIdea.status) {
|
|
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
|
onSuccess: () => {
|
|
setSelectedIdea({ ...selectedIdea, status: val });
|
|
},
|
|
});
|
|
}
|
|
}}
|
|
w={200}
|
|
/>
|
|
</Card>
|
|
|
|
<Card withBorder>
|
|
<Group justify="space-between" mb="xs">
|
|
<Text fw={600}>Private Admin Note</Text>
|
|
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
|
</Group>
|
|
<Textarea
|
|
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
|
minRows={3}
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.currentTarget.value)}
|
|
/>
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
mt="xs"
|
|
onClick={handleSaveNote}
|
|
loading={updateNote.isPending}
|
|
disabled={noteText === (selectedIdea.adminNote || '')}
|
|
>
|
|
Save Note
|
|
</Button>
|
|
</Card>
|
|
</Stack>
|
|
)}
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|