diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index aaf8c2d..0ae7fa3 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -218,6 +218,17 @@ export class AdminController { 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') async updateOrgSettings( @Req() req: any, diff --git a/backend/src/modules/ideas/entities/idea.entity.ts b/backend/src/modules/ideas/entities/idea.entity.ts index 2d96657..e3229b2 100644 --- a/backend/src/modules/ideas/entities/idea.entity.ts +++ b/backend/src/modules/ideas/entities/idea.entity.ts @@ -30,6 +30,9 @@ export class Idea { @Column({ length: 20, default: 'new' }) status: string; + @Column({ name: 'admin_note', type: 'text', nullable: true }) + adminNote: string; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; diff --git a/backend/src/modules/ideas/ideas.service.ts b/backend/src/modules/ideas/ideas.service.ts index c081986..e780b1b 100644 --- a/backend/src/modules/ideas/ideas.service.ts +++ b/backend/src/modules/ideas/ideas.service.ts @@ -50,6 +50,7 @@ export class IdeasService { 'idea.description AS description', 'idea.status AS status', 'idea.createdAt AS "createdAt"', + 'idea.adminNote AS "adminNote"', 'org.id AS "orgId"', 'org.name AS "orgName"', 'user.id AS "userId"', @@ -75,4 +76,14 @@ export class IdeasService { idea.status = status; return this.ideasRepository.save(idea); } + + async updateNote(id: string, adminNote: string): Promise { + const idea = await this.ideasRepository.findOne({ where: { id } }); + if (!idea) { + throw new NotFoundException('Idea not found'); + } + + idea.adminNote = adminNote; + return this.ideasRepository.save(idea); + } } diff --git a/db/migrations/019-ideas-admin-note.sql b/db/migrations/019-ideas-admin-note.sql new file mode 100644 index 0000000..189a04a --- /dev/null +++ b/db/migrations/019-ideas-admin-note.sql @@ -0,0 +1,2 @@ +-- Add private admin note column to ideas table +ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d21e25b..bcc6117 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { SettingsPage } from './pages/settings/SettingsPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { AdminPage } from './pages/admin/AdminPage'; +import { AdminIdeasPage } from './pages/admin/AdminIdeasPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; @@ -133,6 +134,7 @@ export function App() { } > } /> + } /> {/* Main app routes (require auth + org) */} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d07d371..36d2f59 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -20,6 +20,7 @@ import { IconCalculator, IconGitCompare, IconScale, + IconBulb, } from '@tabler/icons-react'; import { useAuthStore } from '../../stores/authStore'; @@ -132,6 +133,13 @@ export function Sidebar({ onNavigate }: SidebarProps) { onClick={() => go('/admin')} color="red" /> + } + active={location.pathname === '/admin/ideas'} + onClick={() => go('/admin/ideas')} + color="yellow" + /> {organizations && organizations.length > 0 && ( <> @@ -230,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) { onClick={() => go('/admin')} color="red" /> + } + active={location.pathname === '/admin/ideas'} + onClick={() => go('/admin/ideas')} + color="yellow" + /> )} diff --git a/frontend/src/pages/admin/AdminIdeasPage.tsx b/frontend/src/pages/admin/AdminIdeasPage.tsx new file mode 100644 index 0000000..7780955 --- /dev/null +++ b/frontend/src/pages/admin/AdminIdeasPage.tsx @@ -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 = { + 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(null); + const [selectedIdea, setSelectedIdea] = useState(null); + const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false); + const [noteText, setNoteText] = useState(''); + const queryClient = useQueryClient(); + + const { data: ideas, isLoading } = useQuery({ + 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
; + } + + return ( + + + + + Idea Submissions + + {counts.total} total + + + {/* Summary cards */} + + + New + {counts.new} + + + Reviewed + {counts.reviewed} + + + Accepted + {counts.accepted} + + + Rejected + {counts.rejected} + + + + {/* Filters */} + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + style={{ flex: 1 }} + /> + { + if (val && val !== selectedIdea.status) { + updateStatus.mutate({ id: selectedIdea.id, status: val }, { + onSuccess: () => { + setSelectedIdea({ ...selectedIdea, status: val }); + }, + }); + } + }} + w={200} + /> + + + + + Private Admin Note + Only visible to super admins + +