feat: add admin ideas management page with private notes

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>
This commit is contained in:
JoeBot
2026-04-02 17:35:30 -04:00
parent 140cd7acb7
commit d430b96b51
7 changed files with 352 additions and 0 deletions

View File

@@ -218,6 +218,17 @@ export class AdminController {
return { success: true, idea }; return { success: true, idea };
} }
@Put('ideas/:id/note')
async updateIdeaNote(
@Req() req: any,
@Param('id') id: string,
@Body() body: { adminNote: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateNote(id, body.adminNote);
return { success: true, idea };
}
@Put('organizations/:id/settings') @Put('organizations/:id/settings')
async updateOrgSettings( async updateOrgSettings(
@Req() req: any, @Req() req: any,

View File

@@ -30,6 +30,9 @@ export class Idea {
@Column({ length: 20, default: 'new' }) @Column({ length: 20, default: 'new' })
status: string; status: string;
@Column({ name: 'admin_note', type: 'text', nullable: true })
adminNote: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date; createdAt: Date;

View File

@@ -50,6 +50,7 @@ export class IdeasService {
'idea.description AS description', 'idea.description AS description',
'idea.status AS status', 'idea.status AS status',
'idea.createdAt AS "createdAt"', 'idea.createdAt AS "createdAt"',
'idea.adminNote AS "adminNote"',
'org.id AS "orgId"', 'org.id AS "orgId"',
'org.name AS "orgName"', 'org.name AS "orgName"',
'user.id AS "userId"', 'user.id AS "userId"',
@@ -75,4 +76,14 @@ export class IdeasService {
idea.status = status; idea.status = status;
return this.ideasRepository.save(idea); return this.ideasRepository.save(idea);
} }
async updateNote(id: string, adminNote: string): Promise<Idea> {
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.adminNote = adminNote;
return this.ideasRepository.save(idea);
}
} }

View File

@@ -0,0 +1,2 @@
-- Add private admin note column to ideas table
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;

View File

@@ -29,6 +29,7 @@ import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
import { AdminPage } from './pages/admin/AdminPage'; import { AdminPage } from './pages/admin/AdminPage';
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
@@ -133,6 +134,7 @@ export function App() {
} }
> >
<Route index element={<AdminPage />} /> <Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
</Route> </Route>
{/* Main app routes (require auth + org) */} {/* Main app routes (require auth + org) */}

View File

@@ -20,6 +20,7 @@ import {
IconCalculator, IconCalculator,
IconGitCompare, IconGitCompare,
IconScale, IconScale,
IconBulb,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -132,6 +133,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')} onClick={() => go('/admin')}
color="red" color="red"
/> />
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
{organizations && organizations.length > 0 && ( {organizations && organizations.length > 0 && (
<> <>
<Divider my="sm" /> <Divider my="sm" />
@@ -230,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')} onClick={() => go('/admin')}
color="red" color="red"
/> />
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
</> </>
)} )}
</ScrollArea> </ScrollArea>

View File

@@ -0,0 +1,308 @@
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>
);
}