ideation-feature #11
@@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
||||
import { BillingModule } from './modules/billing/billing.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||
import { IdeasModule } from './modules/ideas/ideas.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
@@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
BillingModule,
|
||||
EmailModule,
|
||||
OnboardingModule,
|
||||
IdeasModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import { IdeasService } from '../ideas/ideas.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@ApiTags('admin')
|
||||
@@ -17,6 +18,7 @@ export class AdminController {
|
||||
private usersService: UsersService,
|
||||
private orgService: OrganizationsService,
|
||||
private analyticsService: AdminAnalyticsService,
|
||||
private ideasService: IdeasService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
@@ -196,4 +198,45 @@ export class AdminController {
|
||||
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
// ── Ideation ──
|
||||
|
||||
@Get('ideas')
|
||||
async listAllIdeas(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.ideasService.findAll();
|
||||
}
|
||||
|
||||
@Put('ideas/:id/status')
|
||||
async updateIdeaStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const idea = await this.ideasService.updateStatus(id, body.status);
|
||||
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,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, any>,
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const org = await this.orgService.updateSettings(id, body);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
import { IdeasModule } from '../ideas/ideas.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OrganizationsModule,
|
||||
IdeasModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateIdeaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Organization } from '../../organizations/entities/organization.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'ideas' })
|
||||
export class Idea {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'org_id' })
|
||||
orgId: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@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;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
@JoinColumn({ name: 'org_id' })
|
||||
organization: Organization;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
}
|
||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { IdeasService } from './ideas.service';
|
||||
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||
|
||||
@ApiTags('ideas')
|
||||
@Controller('ideas')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IdeasController {
|
||||
constructor(private ideasService: IdeasService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
|
||||
const orgId = req.user.orgId;
|
||||
const userId = req.user.userId || req.user.sub;
|
||||
const idea = await this.ideasService.create(orgId, userId, dto);
|
||||
return { success: true, idea };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findByOrg(@Req() req: any) {
|
||||
const orgId = req.user.orgId;
|
||||
return this.ideasService.findByOrg(orgId);
|
||||
}
|
||||
}
|
||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Idea } from './entities/idea.entity';
|
||||
import { Organization } from '../organizations/entities/organization.entity';
|
||||
import { IdeasController } from './ideas.controller';
|
||||
import { IdeasService } from './ideas.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Idea, Organization])],
|
||||
controllers: [IdeasController],
|
||||
providers: [IdeasService],
|
||||
exports: [IdeasService],
|
||||
})
|
||||
export class IdeasModule {}
|
||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Idea } from './entities/idea.entity';
|
||||
import { Organization } from '../organizations/entities/organization.entity';
|
||||
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||
|
||||
@Injectable()
|
||||
export class IdeasService {
|
||||
constructor(
|
||||
@InjectRepository(Idea)
|
||||
private ideasRepository: Repository<Idea>,
|
||||
@InjectRepository(Organization)
|
||||
private orgRepository: Repository<Organization>,
|
||||
) {}
|
||||
|
||||
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
||||
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
if (org.settings?.ideationEnabled !== true) {
|
||||
throw new ForbiddenException('Ideation is not enabled for this organization');
|
||||
}
|
||||
|
||||
const idea = this.ideasRepository.create({
|
||||
orgId,
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
});
|
||||
return this.ideasRepository.save(idea);
|
||||
}
|
||||
|
||||
async findByOrg(orgId: string): Promise<Idea[]> {
|
||||
return this.ideasRepository.find({
|
||||
where: { orgId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(): Promise<any[]> {
|
||||
return this.ideasRepository
|
||||
.createQueryBuilder('idea')
|
||||
.leftJoin('idea.organization', 'org')
|
||||
.leftJoin('idea.user', 'user')
|
||||
.select([
|
||||
'idea.id AS id',
|
||||
'idea.title AS title',
|
||||
'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"',
|
||||
'user.email AS "userEmail"',
|
||||
'user.firstName AS "userFirstName"',
|
||||
'user.lastName AS "userLastName"',
|
||||
])
|
||||
.orderBy('idea.createdAt', 'DESC')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string): Promise<Idea> {
|
||||
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||
if (!idea) {
|
||||
throw new NotFoundException('Idea not found');
|
||||
}
|
||||
|
||||
idea.status = status;
|
||||
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);
|
||||
}
|
||||
}
|
||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Ideation feature: shared ideas table for cross-tenant idea submissions
|
||||
CREATE TABLE IF NOT EXISTS shared.ideas (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);
|
||||
2
db/migrations/019-ideas-admin-note.sql
Normal file
2
db/migrations/019-ideas-admin-note.sql
Normal 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;
|
||||
@@ -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() {
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="ideas" element={<AdminIdeasPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Main app routes (require auth + org) */}
|
||||
|
||||
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface IdeaModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const submitIdea = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/ideas', { title, description });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Failed to submit idea',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Title"
|
||||
placeholder="Brief summary of your idea"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Describe your idea in more detail (optional)"
|
||||
minRows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => submitIdea.mutate()}
|
||||
loading={submitIdea.isPending}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
Submit Idea
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconEyeOff,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconBulb,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { AppTour } from '../onboarding/AppTour';
|
||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||
import { IdeaModal } from '../ideas/IdeaModal';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -28,6 +30,10 @@ export function AppLayout() {
|
||||
const location = useLocation();
|
||||
const isImpersonating = !!impersonationOriginal;
|
||||
|
||||
// ── Ideation State ──
|
||||
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
||||
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
||||
|
||||
// ── Onboarding State ──
|
||||
const [showTour, setShowTour] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
@@ -121,6 +127,13 @@ export function AppLayout() {
|
||||
{currentOrg && (
|
||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||
)}
|
||||
{ideationEnabled && (
|
||||
<Tooltip label="Submit an idea">
|
||||
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
|
||||
<IconBulb size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -209,6 +222,9 @@ export function AppLayout() {
|
||||
{/* ── Onboarding Components ── */}
|
||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||
|
||||
{/* ── Ideation Modal ── */}
|
||||
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<NavLink
|
||||
label="Idea Submissions"
|
||||
leftSection={<IconBulb size={18} />}
|
||||
active={location.pathname === '/admin/ideas'}
|
||||
onClick={() => go('/admin/ideas')}
|
||||
color="yellow"
|
||||
/>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
@@ -230,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
<NavLink
|
||||
label="Idea Submissions"
|
||||
leftSection={<IconBulb size={18} />}
|
||||
active={location.pathname === '/admin/ideas'}
|
||||
onClick={() => go('/admin/ideas')}
|
||||
color="yellow"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -211,6 +211,16 @@ export function AdminPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleIdeation = useMutation({
|
||||
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
||||
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const impersonateUser = useMutation({
|
||||
mutationFn: async (userId: string) => {
|
||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||
@@ -782,6 +792,27 @@ export function AdminPage() {
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Feature Toggles</Text>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconBulb size={16} />
|
||||
<div>
|
||||
<Text size="sm">Ideation</Text>
|
||||
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={tenantDetail.organization.settings?.ideationEnabled === true}
|
||||
onChange={(e) => {
|
||||
if (selectedOrgId) {
|
||||
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Subscription</Text>
|
||||
<Stack gap="xs">
|
||||
|
||||
Reference in New Issue
Block a user