937 lines
41 KiB
TypeScript
937 lines
41 KiB
TypeScript
import {
|
||
mdiArrowRight,
|
||
mdiBrain,
|
||
mdiChartLine,
|
||
mdiClipboardTextClockOutline,
|
||
mdiCreation,
|
||
mdiLightningBolt,
|
||
mdiRobotOutline,
|
||
mdiShieldCheck,
|
||
mdiTarget,
|
||
} from '@mdi/js';
|
||
import Head from 'next/head';
|
||
import Link from 'next/link';
|
||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||
import axios from 'axios';
|
||
import BaseButton from '../components/BaseButton';
|
||
import BaseIcon from '../components/BaseIcon';
|
||
import CardBox from '../components/CardBox';
|
||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||
import NotificationBar from '../components/NotificationBar';
|
||
import SectionMain from '../components/SectionMain';
|
||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||
import { getPageTitle } from '../config';
|
||
import { hasPermission } from '../helpers/userPermissions';
|
||
import { useAppSelector } from '../stores/hooks';
|
||
|
||
type TemplateSummary = {
|
||
key: string;
|
||
title: string;
|
||
accent: string;
|
||
description: string;
|
||
defaultCadence: string;
|
||
defaultVisibility: string;
|
||
questionCount: number;
|
||
previewQuestions: Array<{
|
||
prompt: string;
|
||
question_type: string;
|
||
}>;
|
||
};
|
||
|
||
type RecentSurvey = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
status: string;
|
||
visibility: string;
|
||
cadence: string;
|
||
opens_at: string | null;
|
||
closes_at: string | null;
|
||
createdAt: string;
|
||
templateName: string;
|
||
questionCount: number;
|
||
responseCount: number;
|
||
invitationCount: number;
|
||
responseRate: number;
|
||
latestAnalysisAt: string | null;
|
||
latestAnalysisScore: number | null;
|
||
};
|
||
|
||
type LaunchpadSummary = {
|
||
overview: {
|
||
totalSurveys: number;
|
||
activeSurveys: number;
|
||
totalResponses: number;
|
||
totalInvitations: number;
|
||
responseRate: number;
|
||
aiReadyCount: number;
|
||
activeDepartmentCount: number;
|
||
};
|
||
templates: TemplateSummary[];
|
||
recentSurveys: RecentSurvey[];
|
||
responseTrend: Array<{
|
||
key: string;
|
||
label: string;
|
||
count: number;
|
||
}>;
|
||
departmentBreakdown: Array<{
|
||
id: string;
|
||
name: string;
|
||
responseCount: number;
|
||
}>;
|
||
};
|
||
|
||
type AnalysisResult = {
|
||
overallSentimentScore: number | null;
|
||
executiveSummary: string;
|
||
topThemes: Array<{
|
||
theme: string;
|
||
confidence: string;
|
||
quote: string;
|
||
insight: string;
|
||
}>;
|
||
departmentRisks: Array<{
|
||
department: string;
|
||
riskLevel: string;
|
||
riskScore: number;
|
||
signal: string;
|
||
}>;
|
||
recommendedActions: Array<{
|
||
title: string;
|
||
owner: string;
|
||
timeline: string;
|
||
template: string;
|
||
}>;
|
||
};
|
||
|
||
const cadenceLabels: Record<string, string> = {
|
||
one_time: 'One-time',
|
||
weekly: 'Weekly',
|
||
biweekly: 'Biweekly',
|
||
monthly: 'Monthly',
|
||
quarterly: 'Quarterly',
|
||
};
|
||
|
||
const visibilityLabels: Record<string, string> = {
|
||
anonymous: 'Anonymous',
|
||
identified: 'Identified',
|
||
};
|
||
|
||
const statusClassNames: Record<string, string> = {
|
||
draft: 'bg-slate-500/15 text-slate-200',
|
||
scheduled: 'bg-sky-500/15 text-sky-200',
|
||
sending: 'bg-violet-500/15 text-violet-200',
|
||
active: 'bg-emerald-500/15 text-emerald-200',
|
||
closed: 'bg-amber-500/15 text-amber-200',
|
||
archived: 'bg-zinc-500/15 text-zinc-300',
|
||
};
|
||
|
||
const questionTypeLabels: Record<string, string> = {
|
||
rating_1_10: '1–10 rating',
|
||
multiple_choice: 'Multiple choice',
|
||
open_text: 'Open text',
|
||
emoji_reaction: 'Emoji reaction',
|
||
};
|
||
|
||
const getDefaultTitle = (template?: TemplateSummary) => {
|
||
if (!template) {
|
||
return '';
|
||
}
|
||
|
||
const today = new Date().toLocaleDateString('en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
});
|
||
|
||
return `${template.title} • ${today}`;
|
||
};
|
||
|
||
const PulseHQPage = () => {
|
||
const { currentUser } = useAppSelector((state) => state.auth);
|
||
const canCreateSurveys = Boolean(currentUser && hasPermission(currentUser, 'CREATE_SURVEYS'));
|
||
|
||
const [summary, setSummary] = useState<LaunchpadSummary | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [pageError, setPageError] = useState('');
|
||
const [selectedTemplateKey, setSelectedTemplateKey] = useState('weekly_pulse');
|
||
const [launching, setLaunching] = useState(false);
|
||
const [launchError, setLaunchError] = useState('');
|
||
const [launchSuccess, setLaunchSuccess] = useState<null | {
|
||
surveyId: string;
|
||
title: string;
|
||
questionCount: number;
|
||
templateTitle: string;
|
||
}>(null);
|
||
const [selectedSurveyId, setSelectedSurveyId] = useState('');
|
||
const [analysisLoading, setAnalysisLoading] = useState(false);
|
||
const [analysisError, setAnalysisError] = useState('');
|
||
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);
|
||
const [form, setForm] = useState({
|
||
templateKey: 'weekly_pulse',
|
||
title: '',
|
||
description: '',
|
||
cadence: 'weekly',
|
||
visibility: 'anonymous',
|
||
opens_at: '',
|
||
closes_at: '',
|
||
include_open_text: true,
|
||
});
|
||
|
||
const selectedTemplate = useMemo(
|
||
() => summary?.templates.find((template) => template.key === selectedTemplateKey) || null,
|
||
[selectedTemplateKey, summary?.templates],
|
||
);
|
||
|
||
const loadSummary = async (preferredSurveyId?: string) => {
|
||
try {
|
||
setLoading(true);
|
||
setPageError('');
|
||
const response = await axios.get('/surveys/launchpad/summary');
|
||
const nextSummary = response.data as LaunchpadSummary;
|
||
setSummary(nextSummary);
|
||
setSelectedSurveyId((current) => {
|
||
if (preferredSurveyId) {
|
||
return preferredSurveyId;
|
||
}
|
||
if (current && nextSummary.recentSurveys.some((survey) => survey.id === current)) {
|
||
return current;
|
||
}
|
||
return nextSummary.recentSurveys[0]?.id || '';
|
||
});
|
||
setSelectedTemplateKey((current) => {
|
||
if (nextSummary.templates.some((template) => template.key === current)) {
|
||
return current;
|
||
}
|
||
return nextSummary.templates[0]?.key || 'weekly_pulse';
|
||
});
|
||
setForm((current) => {
|
||
const template =
|
||
nextSummary.templates.find((item) => item.key === current.templateKey) || nextSummary.templates[0];
|
||
|
||
return {
|
||
...current,
|
||
templateKey: template?.key || current.templateKey,
|
||
cadence: current.cadence || template?.defaultCadence || 'weekly',
|
||
visibility: current.visibility || template?.defaultVisibility || 'anonymous',
|
||
description: current.description || template?.description || '',
|
||
title: current.title || getDefaultTitle(template),
|
||
};
|
||
});
|
||
} catch (error: any) {
|
||
console.error('Failed to load Pulse HQ summary:', error);
|
||
setPageError(error?.response?.data || error?.message || 'Failed to load Pulse HQ.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadSummary();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTemplate) {
|
||
return;
|
||
}
|
||
|
||
setForm((current) => ({
|
||
...current,
|
||
templateKey: selectedTemplate.key,
|
||
description:
|
||
current.description && current.templateKey === selectedTemplate.key
|
||
? current.description
|
||
: selectedTemplate.description,
|
||
cadence: selectedTemplate.defaultCadence,
|
||
visibility: selectedTemplate.defaultVisibility,
|
||
title: current.title ? current.title : getDefaultTitle(selectedTemplate),
|
||
}));
|
||
}, [selectedTemplateKey, selectedTemplate?.key]);
|
||
|
||
const trendMax = useMemo(() => {
|
||
const counts = summary?.responseTrend.map((item) => item.count) || [];
|
||
return Math.max(...counts, 1);
|
||
}, [summary?.responseTrend]);
|
||
|
||
const selectedSurvey = useMemo(
|
||
() => summary?.recentSurveys.find((survey) => survey.id === selectedSurveyId) || null,
|
||
[selectedSurveyId, summary?.recentSurveys],
|
||
);
|
||
|
||
const handleFieldChange = (
|
||
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
|
||
) => {
|
||
const { name, value, type } = event.target;
|
||
const nextValue = type === 'checkbox' ? (event.target as HTMLInputElement).checked : value;
|
||
setForm((current) => ({
|
||
...current,
|
||
[name]: nextValue,
|
||
}));
|
||
};
|
||
|
||
const handleTemplateSelect = (templateKey: string) => {
|
||
setSelectedTemplateKey(templateKey);
|
||
const template = summary?.templates.find((item) => item.key === templateKey);
|
||
if (!template) {
|
||
return;
|
||
}
|
||
|
||
setForm((current) => ({
|
||
...current,
|
||
templateKey,
|
||
cadence: template.defaultCadence,
|
||
visibility: template.defaultVisibility,
|
||
description: template.description,
|
||
title: getDefaultTitle(template),
|
||
}));
|
||
};
|
||
|
||
const handleLaunch = async (event: React.FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
setLaunchError('');
|
||
setLaunchSuccess(null);
|
||
setAnalysisError('');
|
||
|
||
if (!form.title.trim()) {
|
||
setLaunchError('Give this launch a clear title so your team recognizes it in the dashboard.');
|
||
return;
|
||
}
|
||
|
||
if (form.opens_at && form.closes_at && new Date(form.opens_at) > new Date(form.closes_at)) {
|
||
setLaunchError('Choose a close date that comes after the open date.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLaunching(true);
|
||
const response = await axios.post('/surveys/launchpad/create', form);
|
||
const payload = response.data;
|
||
setLaunchSuccess({
|
||
surveyId: payload.survey.id,
|
||
title: payload.survey.title,
|
||
questionCount: payload.questionCount,
|
||
templateTitle: payload.template.title,
|
||
});
|
||
setAnalysisResult(null);
|
||
await loadSummary(payload.survey.id);
|
||
} catch (error: any) {
|
||
console.error('Failed to launch survey:', error);
|
||
setLaunchError(error?.response?.data || error?.message || 'Survey launch failed.');
|
||
} finally {
|
||
setLaunching(false);
|
||
}
|
||
};
|
||
|
||
const handleGenerateAnalysis = async (surveyId: string) => {
|
||
if (!surveyId) {
|
||
setAnalysisError('Pick a survey with responses before generating AI analysis.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setAnalysisLoading(true);
|
||
setAnalysisError('');
|
||
const response = await axios.post(`/surveys/${surveyId}/pulse-analysis`);
|
||
setAnalysisResult(response.data.analysis);
|
||
await loadSummary(surveyId);
|
||
} catch (error: any) {
|
||
console.error('Failed to generate AI analysis:', error);
|
||
setAnalysisError(
|
||
error?.response?.data || error?.message || 'AI analysis could not be generated right now.',
|
||
);
|
||
} finally {
|
||
setAnalysisLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('Pulse HQ')}</title>
|
||
</Head>
|
||
<SectionMain>
|
||
<SectionTitleLineWithButton icon={mdiLightningBolt} title='Pulse HQ' main>
|
||
<BaseButton color='info' href='/surveys/surveys-list' label='Open survey library' />
|
||
</SectionTitleLineWithButton>
|
||
|
||
{pageError && (
|
||
<NotificationBar color='danger' icon={mdiShieldCheck}>
|
||
{pageError}
|
||
</NotificationBar>
|
||
)}
|
||
|
||
{launchSuccess && (
|
||
<NotificationBar
|
||
color='success'
|
||
icon={mdiCreation}
|
||
button={
|
||
<div className='flex flex-wrap gap-2'>
|
||
<BaseButton
|
||
color='success'
|
||
outline
|
||
href={`/surveys/surveys-view/?id=${launchSuccess.surveyId}`}
|
||
label='View survey'
|
||
/>
|
||
<BaseButton color='success' href={`/surveys/${launchSuccess.surveyId}`} label='Edit settings' />
|
||
</div>
|
||
}
|
||
>
|
||
{`${launchSuccess.title} is ready. ${launchSuccess.questionCount} questions were created from ${launchSuccess.templateTitle}.`}
|
||
</NotificationBar>
|
||
)}
|
||
|
||
<div className='grid gap-6 xl:grid-cols-[1.3fr,0.7fr]'>
|
||
<CardBox className='overflow-hidden border border-white/10 bg-gradient-to-br from-slate-950 via-slate-900 to-violet-950 text-white shadow-2xl'>
|
||
<div className='absolute pointer-events-none inset-y-0 right-0 hidden w-1/3 bg-[radial-gradient(circle_at_top,_rgba(168,85,247,0.32),_transparent_70%)] lg:block' />
|
||
<div className='grid gap-8 lg:grid-cols-[1.1fr,0.9fr]'>
|
||
<div className='space-y-6'>
|
||
<div className='inline-flex items-center gap-2 rounded-full border border-violet-400/30 bg-violet-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-violet-200'>
|
||
<BaseIcon path={mdiBrain} size={16} />
|
||
PulseSurvey launchpad
|
||
</div>
|
||
<div className='space-y-4'>
|
||
<h1 className='text-3xl font-semibold tracking-tight md:text-4xl'>
|
||
Launch recurring employee pulse surveys in minutes — then turn responses into AI-backed action.
|
||
</h1>
|
||
<p className='max-w-2xl text-sm leading-6 text-slate-300 md:text-base'>
|
||
This first slice gives your People team one polished workspace to launch a survey, review recent sends,
|
||
and generate an executive-ready AI analysis without leaving the app.
|
||
</p>
|
||
</div>
|
||
<div className='grid gap-3 sm:grid-cols-3'>
|
||
{[
|
||
{
|
||
label: 'Survey volume',
|
||
value: summary?.overview.totalSurveys ?? '—',
|
||
note: 'Total launches tracked',
|
||
},
|
||
{
|
||
label: 'Live response rate',
|
||
value: summary ? `${summary.overview.responseRate}%` : '—',
|
||
note: 'Across all invitations',
|
||
},
|
||
{
|
||
label: 'AI-ready surveys',
|
||
value: summary?.overview.aiReadyCount ?? '—',
|
||
note: 'Completed responses collected',
|
||
},
|
||
].map((metric) => (
|
||
<div key={metric.label} className='rounded-2xl border border-white/10 bg-white/5 p-4 backdrop-blur'>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-400'>{metric.label}</p>
|
||
<p className='mt-3 text-3xl font-semibold text-white'>{metric.value}</p>
|
||
<p className='mt-2 text-sm text-slate-400'>{metric.note}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className='flex flex-wrap gap-3'>
|
||
<BaseButton color='info' href='/dashboard' label='Admin interface' />
|
||
<BaseButton color='white' outline href='/survey_templates/survey_templates-list' label='Template records' />
|
||
</div>
|
||
</div>
|
||
|
||
<div className='rounded-3xl border border-white/10 bg-slate-950/70 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.45)]'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-400'>Template lineup</p>
|
||
<h2 className='mt-2 text-xl font-semibold text-white'>Choose your first pulse</h2>
|
||
</div>
|
||
<div className='rounded-2xl border border-white/10 bg-white/5 p-2 text-slate-200'>
|
||
<BaseIcon path={mdiCreation} size={22} />
|
||
</div>
|
||
</div>
|
||
<div className='mt-5 grid gap-3'>
|
||
{summary?.templates.map((template) => {
|
||
const active = selectedTemplateKey === template.key;
|
||
return (
|
||
<button
|
||
key={template.key}
|
||
type='button'
|
||
onClick={() => handleTemplateSelect(template.key)}
|
||
className={`rounded-2xl border px-4 py-4 text-left transition duration-150 ${
|
||
active
|
||
? 'border-white/30 bg-white/10 shadow-lg'
|
||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/5'
|
||
}`}
|
||
>
|
||
<div className='flex items-start justify-between gap-3'>
|
||
<div>
|
||
<div className='flex items-center gap-2'>
|
||
<span
|
||
className='inline-flex h-2.5 w-2.5 rounded-full'
|
||
style={{ backgroundColor: template.accent }}
|
||
/>
|
||
<p className='font-semibold text-white'>{template.title}</p>
|
||
</div>
|
||
<p className='mt-2 text-sm text-slate-300'>{template.description}</p>
|
||
</div>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1 text-xs text-slate-300'>
|
||
{template.questionCount} Qs
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardBox>
|
||
|
||
<CardBox className='border border-white/10 bg-slate-950 text-slate-100 shadow-xl'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Workflow preview</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>This launch creates</h2>
|
||
</div>
|
||
<BaseIcon path={mdiClipboardTextClockOutline} size={24} className='text-violet-300' />
|
||
</div>
|
||
<div className='mt-6 space-y-4'>
|
||
{selectedTemplate?.previewQuestions.map((question, index) => (
|
||
<div key={`${question.prompt}-${index}`} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||
<div className='flex items-center justify-between gap-2'>
|
||
<span className='text-sm font-medium text-white'>{`Q${index + 1}`}</span>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1 text-xs text-slate-300'>
|
||
{questionTypeLabels[question.question_type] || question.question_type}
|
||
</span>
|
||
</div>
|
||
<p className='mt-3 text-sm leading-6 text-slate-300'>{question.prompt}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className='mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 p-4 text-sm text-emerald-100'>
|
||
After launch, your survey appears below, inherits cadence + visibility defaults, and becomes eligible for AI
|
||
analysis as soon as completed responses start coming in.
|
||
</div>
|
||
</CardBox>
|
||
</div>
|
||
|
||
<div className='mt-6 grid gap-6 xl:grid-cols-[1.1fr,0.9fr]'>
|
||
<CardBox className='border border-slate-200/10 bg-slate-950 text-slate-100'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Launch survey</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>Create a branded pulse from a proven template</h2>
|
||
</div>
|
||
<BaseIcon path={mdiTarget} size={24} className='text-sky-300' />
|
||
</div>
|
||
|
||
{!canCreateSurveys && (
|
||
<div className='mt-5 rounded-2xl border border-amber-400/20 bg-amber-500/10 p-4 text-sm text-amber-100'>
|
||
You have read access to Pulse HQ, but launching surveys requires the CREATE_SURVEYS permission.
|
||
</div>
|
||
)}
|
||
|
||
{launchError && (
|
||
<div className='mt-5 rounded-2xl border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-100'>
|
||
{launchError}
|
||
</div>
|
||
)}
|
||
|
||
<form className='mt-6 space-y-5' onSubmit={handleLaunch}>
|
||
<div className='grid gap-5 md:grid-cols-2'>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Survey title</span>
|
||
<input
|
||
className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='title'
|
||
value={form.title}
|
||
onChange={handleFieldChange}
|
||
placeholder='Weekly Pulse • Team Alpha'
|
||
disabled={!canCreateSurveys || launching}
|
||
/>
|
||
</label>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Cadence</span>
|
||
<select
|
||
className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='cadence'
|
||
value={form.cadence}
|
||
onChange={handleFieldChange}
|
||
disabled={!canCreateSurveys || launching}
|
||
>
|
||
{Object.entries(cadenceLabels).map(([value, label]) => (
|
||
<option key={value} value={value} className='bg-slate-900'>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Launch notes</span>
|
||
<textarea
|
||
className='min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='description'
|
||
value={form.description}
|
||
onChange={handleFieldChange}
|
||
placeholder='Optional context shown to admins when reviewing this survey later.'
|
||
disabled={!canCreateSurveys || launching}
|
||
/>
|
||
</label>
|
||
|
||
<div className='grid gap-5 md:grid-cols-3'>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Visibility</span>
|
||
<select
|
||
className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='visibility'
|
||
value={form.visibility}
|
||
onChange={handleFieldChange}
|
||
disabled={!canCreateSurveys || launching}
|
||
>
|
||
{Object.entries(visibilityLabels).map(([value, label]) => (
|
||
<option key={value} value={value} className='bg-slate-900'>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Open date</span>
|
||
<input
|
||
type='datetime-local'
|
||
className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='opens_at'
|
||
value={form.opens_at}
|
||
onChange={handleFieldChange}
|
||
disabled={!canCreateSurveys || launching}
|
||
/>
|
||
</label>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Close date</span>
|
||
<input
|
||
type='datetime-local'
|
||
className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-white outline-none transition focus:border-violet-400/60'
|
||
name='closes_at'
|
||
value={form.closes_at}
|
||
onChange={handleFieldChange}
|
||
disabled={!canCreateSurveys || launching}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label className='flex items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 px-4 py-4 text-sm text-slate-300'>
|
||
<div>
|
||
<p className='font-medium text-white'>Include open text follow-up</p>
|
||
<p className='mt-1 text-slate-400'>Keep qualitative context in the survey metadata for future AI analysis.</p>
|
||
</div>
|
||
<input
|
||
type='checkbox'
|
||
name='include_open_text'
|
||
checked={form.include_open_text}
|
||
onChange={handleFieldChange}
|
||
disabled={!canCreateSurveys || launching}
|
||
className='h-5 w-5 rounded border-white/20 bg-slate-900 text-violet-500 focus:ring-violet-400'
|
||
/>
|
||
</label>
|
||
|
||
<div className='flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-slate-900/70 p-4'>
|
||
<div>
|
||
<p className='text-sm font-medium text-white'>Ready to launch</p>
|
||
<p className='mt-1 text-sm text-slate-400'>Creates survey settings + question set in one action.</p>
|
||
</div>
|
||
<BaseButton
|
||
type='submit'
|
||
color='info'
|
||
icon={mdiArrowRight}
|
||
label={launching ? 'Launching…' : 'Launch survey'}
|
||
disabled={!canCreateSurveys || launching}
|
||
/>
|
||
</div>
|
||
</form>
|
||
</CardBox>
|
||
|
||
<div className='grid gap-6'>
|
||
<CardBox className='border border-slate-200/10 bg-slate-950 text-slate-100'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Response momentum</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>Last 6 months</h2>
|
||
</div>
|
||
<BaseIcon path={mdiChartLine} size={24} className='text-violet-300' />
|
||
</div>
|
||
<div className='mt-6 grid grid-cols-6 items-end gap-3'>
|
||
{(summary?.responseTrend || []).map((point) => (
|
||
<div key={point.key} className='flex flex-col items-center gap-3'>
|
||
<div className='text-xs text-slate-500'>{point.count}</div>
|
||
<div className='flex h-40 w-full items-end'>
|
||
<div
|
||
className='w-full rounded-t-2xl bg-gradient-to-t from-violet-500 via-fuchsia-500 to-sky-400 transition-all duration-300'
|
||
style={{ height: `${Math.max((point.count / trendMax) * 100, 10)}%` }}
|
||
/>
|
||
</div>
|
||
<div className='text-xs uppercase tracking-[0.2em] text-slate-500'>{point.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardBox>
|
||
|
||
<CardBox className='border border-slate-200/10 bg-slate-950 text-slate-100'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Department signal</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>Who is responding</h2>
|
||
</div>
|
||
<span className='rounded-full border border-white/10 px-3 py-1 text-xs text-slate-300'>
|
||
{summary?.overview.activeDepartmentCount ?? 0} active departments
|
||
</span>
|
||
</div>
|
||
<div className='mt-6 space-y-4'>
|
||
{(summary?.departmentBreakdown || []).length === 0 && (
|
||
<div className='rounded-2xl border border-dashed border-white/10 px-4 py-6 text-sm text-slate-400'>
|
||
Department response data will appear here once invitations start turning into submissions.
|
||
</div>
|
||
)}
|
||
{(summary?.departmentBreakdown || []).map((department) => {
|
||
const maxDepartmentResponses = Math.max(
|
||
...(summary?.departmentBreakdown || []).map((item) => item.responseCount),
|
||
1,
|
||
);
|
||
return (
|
||
<div key={department.id}>
|
||
<div className='flex items-center justify-between text-sm'>
|
||
<span className='text-slate-200'>{department.name}</span>
|
||
<span className='text-slate-400'>{department.responseCount} responses</span>
|
||
</div>
|
||
<div className='mt-2 h-2 rounded-full bg-white/10'>
|
||
<div
|
||
className='h-2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500'
|
||
style={{ width: `${Math.max((department.responseCount / maxDepartmentResponses) * 100, 8)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardBox>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='mt-6 grid gap-6 xl:grid-cols-[1.05fr,0.95fr]'>
|
||
<CardBox className='border border-slate-200/10 bg-slate-950 text-slate-100'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Recent launches</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>Survey list + detail shortcuts</h2>
|
||
</div>
|
||
<BaseButton color='white' outline href='/surveys/surveys-list' label='All surveys' />
|
||
</div>
|
||
|
||
{loading && (
|
||
<div className='mt-6 rounded-2xl border border-white/10 bg-white/5 px-4 py-10 text-center text-sm text-slate-400'>
|
||
Loading recent survey activity…
|
||
</div>
|
||
)}
|
||
|
||
{!loading && (summary?.recentSurveys || []).length === 0 && (
|
||
<div className='mt-6 rounded-2xl border border-dashed border-white/10 px-4 py-10 text-center text-sm text-slate-400'>
|
||
No surveys yet. Launch your first pulse above and it will appear here instantly.
|
||
</div>
|
||
)}
|
||
|
||
<div className='mt-6 grid gap-4'>
|
||
{(summary?.recentSurveys || []).map((survey) => (
|
||
<div key={survey.id} className='rounded-3xl border border-white/10 bg-white/[0.03] p-5'>
|
||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||
<div>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<h3 className='text-lg font-semibold text-white'>{survey.title}</h3>
|
||
<span className={`rounded-full px-2.5 py-1 text-xs ${statusClassNames[survey.status] || statusClassNames.draft}`}>
|
||
{survey.status}
|
||
</span>
|
||
</div>
|
||
<p className='mt-2 text-sm text-slate-400'>
|
||
{survey.description || `${survey.templateName} survey ready for audience and invitation setup.`}
|
||
</p>
|
||
</div>
|
||
<label className='inline-flex items-center gap-2 rounded-full border border-white/10 px-3 py-1 text-xs text-slate-300'>
|
||
<input
|
||
type='radio'
|
||
checked={selectedSurveyId === survey.id}
|
||
onChange={() => setSelectedSurveyId(survey.id)}
|
||
className='h-3.5 w-3.5 border-white/20 bg-slate-900 text-violet-400 focus:ring-violet-400'
|
||
/>
|
||
Focus for AI
|
||
</label>
|
||
</div>
|
||
|
||
<div className='mt-5 grid gap-3 md:grid-cols-4'>
|
||
{[
|
||
['Questions', survey.questionCount],
|
||
['Responses', survey.responseCount],
|
||
['Invitations', survey.invitationCount],
|
||
['Response rate', `${survey.responseRate}%`],
|
||
].map(([label, value]) => (
|
||
<div key={`${survey.id}-${label}`} className='rounded-2xl border border-white/10 bg-slate-900/80 p-3'>
|
||
<p className='text-xs uppercase tracking-[0.18em] text-slate-500'>{label}</p>
|
||
<p className='mt-2 text-xl font-semibold text-white'>{value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className='mt-5 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-400'>
|
||
<div className='flex flex-wrap items-center gap-4'>
|
||
<span>{cadenceLabels[survey.cadence] || survey.cadence}</span>
|
||
<span>{visibilityLabels[survey.visibility] || survey.visibility}</span>
|
||
<span>
|
||
{survey.opens_at
|
||
? `Opens ${new Date(survey.opens_at).toLocaleDateString()}`
|
||
: `Created ${new Date(survey.createdAt).toLocaleDateString()}`}
|
||
</span>
|
||
<span>
|
||
{survey.latestAnalysisAt
|
||
? `Last AI run ${new Date(survey.latestAnalysisAt).toLocaleDateString()}`
|
||
: 'No AI analysis yet'}
|
||
</span>
|
||
</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
<Link href={`/surveys/surveys-view/?id=${survey.id}`} className='rounded-full border border-white/10 px-3 py-1.5 text-white transition hover:border-white/30'>
|
||
View
|
||
</Link>
|
||
<Link href={`/surveys/${survey.id}`} className='rounded-full border border-white/10 px-3 py-1.5 text-white transition hover:border-white/30'>
|
||
Edit
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardBox>
|
||
|
||
<CardBox className='border border-slate-200/10 bg-slate-950 text-slate-100'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>AI insights</p>
|
||
<h2 className='mt-2 text-xl font-semibold'>Generate executive-ready analysis</h2>
|
||
</div>
|
||
<BaseIcon path={mdiRobotOutline} size={24} className='text-fuchsia-300' />
|
||
</div>
|
||
<p className='mt-3 text-sm leading-6 text-slate-400'>
|
||
Use the currently selected survey to synthesize sentiment, themes, burnout risk, and recommended actions.
|
||
</p>
|
||
|
||
<div className='mt-5 rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||
<label className='block text-sm'>
|
||
<span className='mb-2 block text-slate-300'>Survey focus</span>
|
||
<select
|
||
className='w-full rounded-2xl border border-white/10 bg-slate-950 px-4 py-3 text-white outline-none transition focus:border-fuchsia-400/60'
|
||
value={selectedSurveyId}
|
||
onChange={(event) => setSelectedSurveyId(event.target.value)}
|
||
>
|
||
{(summary?.recentSurveys || []).map((survey) => (
|
||
<option key={survey.id} value={survey.id} className='bg-slate-950'>
|
||
{survey.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className='mt-4 flex flex-wrap items-center justify-between gap-3'>
|
||
<div className='text-sm text-slate-400'>
|
||
{selectedSurvey
|
||
? `${selectedSurvey.responseCount} responses across ${selectedSurvey.questionCount} questions.`
|
||
: 'Pick a survey to begin.'}
|
||
</div>
|
||
<BaseButton
|
||
color='info'
|
||
icon={mdiBrain}
|
||
label={analysisLoading ? 'Generating…' : 'Generate AI analysis'}
|
||
onClick={() => handleGenerateAnalysis(selectedSurveyId)}
|
||
disabled={analysisLoading || !selectedSurveyId}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{analysisError && (
|
||
<div className='mt-5 rounded-2xl border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-100'>
|
||
{analysisError}
|
||
</div>
|
||
)}
|
||
|
||
{!analysisResult && !analysisLoading && !analysisError && (
|
||
<div className='mt-6 rounded-2xl border border-dashed border-white/10 px-4 py-8 text-sm text-slate-400'>
|
||
Your AI summary will appear here after you run the analysis.
|
||
</div>
|
||
)}
|
||
|
||
{analysisLoading && (
|
||
<div className='mt-6 rounded-2xl border border-white/10 bg-white/5 px-4 py-10 text-center text-sm text-slate-300'>
|
||
Reviewing survey answers, clustering themes, and drafting next-step recommendations…
|
||
</div>
|
||
)}
|
||
|
||
{analysisResult && (
|
||
<div className='mt-6 space-y-5'>
|
||
<div className='rounded-3xl border border-fuchsia-400/20 bg-fuchsia-500/10 p-5'>
|
||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.22em] text-fuchsia-200/80'>Overall sentiment</p>
|
||
<p className='mt-2 text-4xl font-semibold text-white'>
|
||
{analysisResult.overallSentimentScore ?? '—'}
|
||
<span className='ml-1 text-lg text-fuchsia-100/70'>/ 100</span>
|
||
</p>
|
||
</div>
|
||
<div className='max-w-xl text-sm leading-6 text-fuchsia-50/90'>
|
||
{analysisResult.executiveSummary}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='space-y-3'>
|
||
<h3 className='text-sm font-semibold uppercase tracking-[0.22em] text-slate-400'>Top themes</h3>
|
||
{analysisResult.topThemes.map((theme, index) => (
|
||
<div key={`${theme.theme}-${index}`} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||
<p className='font-semibold text-white'>{theme.theme}</p>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1 text-xs text-slate-300'>
|
||
{theme.confidence}
|
||
</span>
|
||
</div>
|
||
<p className='mt-2 text-sm leading-6 text-slate-300'>{theme.insight}</p>
|
||
{theme.quote && <blockquote className='mt-3 border-l-2 border-fuchsia-300/40 pl-3 text-sm italic text-fuchsia-100/90'>“{theme.quote}”</blockquote>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className='grid gap-5 md:grid-cols-2'>
|
||
<div className='space-y-3'>
|
||
<h3 className='text-sm font-semibold uppercase tracking-[0.22em] text-slate-400'>Department risk</h3>
|
||
{analysisResult.departmentRisks.map((risk, index) => (
|
||
<div key={`${risk.department}-${index}`} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||
<div className='flex items-center justify-between gap-3'>
|
||
<p className='font-semibold text-white'>{risk.department}</p>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1 text-xs text-slate-300'>
|
||
{risk.riskLevel} · {risk.riskScore}
|
||
</span>
|
||
</div>
|
||
<p className='mt-2 text-sm leading-6 text-slate-300'>{risk.signal}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className='space-y-3'>
|
||
<h3 className='text-sm font-semibold uppercase tracking-[0.22em] text-slate-400'>Recommended actions</h3>
|
||
{analysisResult.recommendedActions.map((action, index) => (
|
||
<div key={`${action.title}-${index}`} className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||
<p className='font-semibold text-white'>{action.title}</p>
|
||
<p className='mt-2 text-sm leading-6 text-slate-300'>{action.template}</p>
|
||
<div className='mt-3 flex flex-wrap gap-2 text-xs text-slate-400'>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1'>Owner: {action.owner}</span>
|
||
<span className='rounded-full bg-white/10 px-2.5 py-1'>Timeline: {action.timeline}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardBox>
|
||
</div>
|
||
</SectionMain>
|
||
</>
|
||
);
|
||
};
|
||
|
||
PulseHQPage.getLayout = function getLayout(page: ReactElement) {
|
||
return <LayoutAuthenticated permission='READ_SURVEYS'>{page}</LayoutAuthenticated>;
|
||
};
|
||
|
||
export default PulseHQPage;
|