feat: add ideation feature with per-tenant toggle
Adds idea submission capability gated by a per-tenant feature flag. Super admins can enable/disable ideation for specific tenants via the admin tenant detail drawer. Users see a lightbulb icon in the header when enabled, opening a modal to submit ideas (title + description). Ideas are stored in shared schema for cross-tenant backlog querying. - Database: shared.ideas table (018-ideas.sql migration) - Backend: Ideas NestJS module (entity, service, controller) - Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status, PUT /admin/organizations/:id/settings - Frontend: IdeaModal component, lightbulb ActionIcon in header - Admin UI: Feature Toggles card with ideation Switch in drawer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
|||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { EmailModule } from './modules/email/email.module';
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||||
|
import { IdeasModule } from './modules/ideas/ideas.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
|
IdeasModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
|
import { IdeasService } from '../ideas/ideas.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -17,6 +18,7 @@ export class AdminController {
|
|||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
|
private ideasService: IdeasService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -196,4 +198,34 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
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('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 { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||||
|
import { IdeasModule } from '../ideas/ideas.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
IdeasModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
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;
|
||||||
|
}
|
||||||
46
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
46
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@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 {}
|
||||||
78
backend/src/modules/ideas/ideas.service.ts
Normal file
78
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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"',
|
||||||
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
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,
|
IconEyeOff,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
|
IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
|
import { IdeaModal } from '../ideas/IdeaModal';
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -28,6 +30,10 @@ export function AppLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
|
// ── Ideation State ──
|
||||||
|
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
||||||
|
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
||||||
|
|
||||||
// ── Onboarding State ──
|
// ── Onboarding State ──
|
||||||
const [showTour, setShowTour] = useState(false);
|
const [showTour, setShowTour] = useState(false);
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
@@ -121,6 +127,13 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<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'}>
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -209,6 +222,9 @@ export function AppLayout() {
|
|||||||
{/* ── Onboarding Components ── */}
|
{/* ── Onboarding Components ── */}
|
||||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
|
|
||||||
|
{/* ── Ideation Modal ── */}
|
||||||
|
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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({
|
const impersonateUser = useMutation({
|
||||||
mutationFn: async (userId: string) => {
|
mutationFn: async (userId: string) => {
|
||||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||||
@@ -782,6 +792,27 @@ export function AdminPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Card>
|
</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>
|
<Card withBorder>
|
||||||
<Text fw={600} mb="xs">Subscription</Text>
|
<Text fw={600} mb="xs">Subscription</Text>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
Reference in New Issue
Block a user