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:
JoeBot
2026-04-02 17:20:37 -04:00
parent 2f6297ae68
commit 140cd7acb7
12 changed files with 345 additions and 1 deletions

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">