Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

9 changed files with 156 additions and 1893 deletions

View File

@ -3,9 +3,10 @@ const express = require('express');
const SurveysService = require('../services/surveys'); const SurveysService = require('../services/surveys');
const SurveysDBApi = require('../db/api/surveys'); const SurveysDBApi = require('../db/api/surveys');
const PulseSurveyLaunchpadService = require('../services/pulseSurveyLaunchpad');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -262,21 +263,6 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
res.status(200).send(payload); res.status(200).send(payload);
})); }));
router.get('/launchpad/summary', wrapAsync(async (req, res) => {
const payload = await PulseSurveyLaunchpadService.getLaunchpadSummary(req.currentUser);
res.status(200).send(payload);
}));
router.post('/launchpad/create', wrapAsync(async (req, res) => {
const payload = await PulseSurveyLaunchpadService.createSurveyFromTemplate(req.body, req.currentUser);
res.status(200).send(payload);
}));
router.post('/:id/pulse-analysis', wrapAsync(async (req, res) => {
const payload = await PulseSurveyLaunchpadService.generateSurveyAnalysis(req.params.id, req.currentUser);
res.status(200).send(payload);
}));
/** /**
* @swagger * @swagger
* /api/surveys: * /api/surveys:

View File

@ -1,772 +0,0 @@
const db = require('../db/models');
const SurveysDBApi = require('../db/api/surveys');
const SurveyQuestionsDBApi = require('../db/api/survey_questions');
const QuestionChoicesDBApi = require('../db/api/question_choices');
const AiAnalysesDBApi = require('../db/api/ai_analyses');
const { LocalAIApi } = require('../ai/LocalAIApi');
const { Op } = db.Sequelize;
const TEMPLATE_LIBRARY = {
weekly_pulse: {
key: 'weekly_pulse',
title: 'Weekly Pulse',
accent: '#8B5CF6',
description:
'A fast weekly check-in for morale, momentum, and manager support.',
defaultCadence: 'weekly',
defaultVisibility: 'anonymous',
questions: [
{
prompt: 'How energized did you feel about work this week?',
question_type: 'rating_1_10',
min_value: 1,
max_value: 10,
is_required: true,
help_text: '1 = drained, 10 = energized and focused',
},
{
prompt: 'What best describes your workload right now?',
question_type: 'multiple_choice',
is_required: true,
choices: [
'Very manageable',
'Healthy stretch',
'Mostly too much',
'Unsustainably high',
],
},
{
prompt: 'How supported do you feel by your manager this week?',
question_type: 'emoji_reaction',
is_required: true,
help_text: 'Choose the emoji that best matches your experience.',
},
{
prompt: 'What is one thing we should keep, stop, or improve next week?',
question_type: 'open_text',
is_required: false,
},
],
},
onboarding: {
key: 'onboarding',
title: 'Onboarding Check-In',
accent: '#14B8A6',
description:
'A first-month experience survey to spot clarity gaps and support needs early.',
defaultCadence: 'one_time',
defaultVisibility: 'identified',
questions: [
{
prompt: 'How clear is your role, team, and success criteria so far?',
question_type: 'rating_1_10',
min_value: 1,
max_value: 10,
is_required: true,
},
{
prompt: 'Which part of onboarding has been most helpful?',
question_type: 'multiple_choice',
is_required: true,
choices: ['Manager support', 'Team introductions', 'Documentation', 'Training sessions'],
},
{
prompt: 'How welcome do you feel on your team?',
question_type: 'emoji_reaction',
is_required: true,
},
{
prompt: 'What would have made your first weeks smoother?',
question_type: 'open_text',
is_required: false,
},
],
},
quarterly_deep_dive: {
key: 'quarterly_deep_dive',
title: 'Quarterly Deep Dive',
accent: '#F97316',
description:
'A richer quarterly engagement survey covering strategy, growth, and trust.',
defaultCadence: 'quarterly',
defaultVisibility: 'anonymous',
questions: [
{
prompt: 'How connected do you feel to the company strategy this quarter?',
question_type: 'rating_1_10',
min_value: 1,
max_value: 10,
is_required: true,
},
{
prompt: 'Which area needs the most attention this quarter?',
question_type: 'multiple_choice',
is_required: true,
choices: ['Recognition', 'Career growth', 'Communication', 'Workload balance'],
},
{
prompt: 'How would you describe team collaboration lately?',
question_type: 'emoji_reaction',
is_required: true,
},
{
prompt: 'What is the biggest blocker to high performance right now?',
question_type: 'open_text',
is_required: false,
},
],
},
burnout_check: {
key: 'burnout_check',
title: 'Burnout Check',
accent: '#EF4444',
description:
'A targeted pulse to surface overload, recovery signals, and department risk.',
defaultCadence: 'biweekly',
defaultVisibility: 'anonymous',
questions: [
{
prompt: 'How sustainable has your workload felt over the last two weeks?',
question_type: 'rating_1_10',
min_value: 1,
max_value: 10,
is_required: true,
help_text: '1 = not sustainable, 10 = very sustainable',
},
{
prompt: 'What is contributing most to stress right now?',
question_type: 'multiple_choice',
is_required: true,
choices: ['Too many priorities', 'Lack of clarity', 'Meetings/context switching', 'Personal energy'],
},
{
prompt: 'How are you feeling at the end of most workdays?',
question_type: 'emoji_reaction',
is_required: true,
},
{
prompt: 'What would most help you recover energy next sprint?',
question_type: 'open_text',
is_required: false,
},
],
},
};
const EMOJI_TO_SCORE = {
very_bad: 1,
bad: 3,
neutral: 5,
good: 8,
very_good: 10,
};
const ACTIVE_STATUSES = ['scheduled', 'sending', 'active'];
function buildError(message, code = 400) {
const error = new Error(message);
error.code = code;
return error;
}
function monthKey(date) {
const year = date.getUTCFullYear();
const month = `${date.getUTCMonth() + 1}`.padStart(2, '0');
return `${year}-${month}`;
}
function monthLabel(date) {
return date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
}
function parseJson(value, fallback = []) {
if (!value) {
return fallback;
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) || typeof parsed === 'object' ? parsed : fallback;
} catch (error) {
return fallback;
}
}
module.exports = class PulseSurveyLaunchpadService {
static listTemplates() {
return Object.values(TEMPLATE_LIBRARY).map((template) => ({
key: template.key,
title: template.title,
accent: template.accent,
description: template.description,
defaultCadence: template.defaultCadence,
defaultVisibility: template.defaultVisibility,
questionCount: template.questions.length,
previewQuestions: template.questions.map((question) => ({
prompt: question.prompt,
question_type: question.question_type,
})),
}));
}
static resolveTemplate(templateKey) {
return TEMPLATE_LIBRARY[templateKey] || null;
}
static async getLaunchpadSummary(currentUser) {
const organizationFilter = currentUser?.organizationsId
? { organizationsId: currentUser.organizationsId }
: {};
const [
totalSurveys,
activeSurveys,
totalResponses,
totalInvitations,
aiReadyCount,
recentSurveys,
departmentRows,
responseRows,
] = await Promise.all([
db.surveys.count({ where: organizationFilter }),
db.surveys.count({ where: { ...organizationFilter, status: { [Op.in]: ACTIVE_STATUSES } } }),
db.survey_responses.count({ where: organizationFilter }),
db.survey_invitations.count({ where: organizationFilter }),
db.survey_responses.count({
where: { ...organizationFilter, is_complete: true },
distinct: true,
col: 'surveyId',
}),
db.surveys.findAll({
where: organizationFilter,
include: [
{
model: db.survey_templates,
as: 'template',
},
],
order: [['createdAt', 'DESC']],
limit: 6,
}),
db.departments.findAll({
where: organizationFilter,
order: [['name', 'ASC']],
raw: true,
}),
db.survey_responses.findAll({
where: {
...organizationFilter,
submitted_at: {
[Op.gte]: new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 5)),
},
},
attributes: ['submitted_at'],
raw: true,
}),
]);
const recentSurveyIds = recentSurveys.map((survey) => survey.id);
const [questionCounts, responseCounts, invitationCounts, latestAnalyses, departmentResponseCounts] =
recentSurveyIds.length > 0
? await Promise.all([
db.survey_questions.findAll({
where: { surveyId: { [Op.in]: recentSurveyIds } },
attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
group: ['surveyId'],
raw: true,
}),
db.survey_responses.findAll({
where: { surveyId: { [Op.in]: recentSurveyIds } },
attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
group: ['surveyId'],
raw: true,
}),
db.survey_invitations.findAll({
where: { surveyId: { [Op.in]: recentSurveyIds } },
attributes: ['surveyId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
group: ['surveyId'],
raw: true,
}),
db.ai_analyses.findAll({
where: { surveyId: { [Op.in]: recentSurveyIds } },
order: [
['generated_at', 'DESC'],
['createdAt', 'DESC'],
],
raw: true,
}),
db.survey_responses.findAll({
where: {
...organizationFilter,
departmentId: { [Op.ne]: null },
},
attributes: ['departmentId', [db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count']],
group: ['departmentId'],
raw: true,
}),
])
: [[], [], [], [], []];
const questionCountMap = Object.fromEntries(
questionCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
);
const responseCountMap = Object.fromEntries(
responseCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
);
const invitationCountMap = Object.fromEntries(
invitationCounts.map((row) => [row.surveyId, Number(row.count || 0)]),
);
const latestAnalysisMap = {};
latestAnalyses.forEach((analysis) => {
if (!latestAnalysisMap[analysis.surveyId]) {
latestAnalysisMap[analysis.surveyId] = analysis;
}
});
const responseTrendSeed = [];
for (let index = 5; index >= 0; index -= 1) {
const date = new Date();
date.setUTCDate(1);
date.setUTCHours(0, 0, 0, 0);
date.setUTCMonth(date.getUTCMonth() - index);
responseTrendSeed.push({
key: monthKey(date),
label: monthLabel(date),
count: 0,
});
}
responseRows.forEach((row) => {
if (!row.submitted_at) {
return;
}
const date = new Date(row.submitted_at);
const bucket = responseTrendSeed.find((item) => item.key === monthKey(date));
if (bucket) {
bucket.count += 1;
}
});
const departmentBreakdown = departmentRows
.map((department) => {
const responseCount = departmentResponseCounts.find(
(row) => row.departmentId === department.id,
);
return {
id: department.id,
name: department.name || 'Unassigned',
responseCount: Number(responseCount?.count || 0),
};
})
.sort((left, right) => right.responseCount - left.responseCount)
.slice(0, 5);
const normalizedRecentSurveys = recentSurveys.map((survey) => {
const invitationCount = invitationCountMap[survey.id] || 0;
const responseCount = responseCountMap[survey.id] || 0;
const responseRate = invitationCount > 0 ? Math.round((responseCount / invitationCount) * 100) : 0;
const latestAnalysis = latestAnalysisMap[survey.id];
return {
id: survey.id,
title: survey.title,
description: survey.description,
status: survey.status,
visibility: survey.visibility,
cadence: survey.cadence,
opens_at: survey.opens_at,
closes_at: survey.closes_at,
createdAt: survey.createdAt,
templateName:
survey.template?.name ||
Object.values(TEMPLATE_LIBRARY).find((template) => template.title === survey.title)?.title ||
'Custom launch',
questionCount: questionCountMap[survey.id] || 0,
responseCount,
invitationCount,
responseRate,
latestAnalysisAt: latestAnalysis?.generated_at || latestAnalysis?.createdAt || null,
latestAnalysisScore: latestAnalysis?.overall_sentiment_score
? Number(latestAnalysis.overall_sentiment_score)
: null,
};
});
const responseRate = totalInvitations > 0 ? Math.round((totalResponses / totalInvitations) * 100) : 0;
return {
overview: {
totalSurveys,
activeSurveys,
totalResponses,
totalInvitations,
responseRate,
aiReadyCount,
activeDepartmentCount: departmentBreakdown.filter((item) => item.responseCount > 0).length,
},
templates: this.listTemplates(),
recentSurveys: normalizedRecentSurveys,
responseTrend: responseTrendSeed,
departmentBreakdown,
};
}
static async createSurveyFromTemplate(payload, currentUser) {
const template = this.resolveTemplate(payload?.templateKey);
if (!template) {
throw buildError('Please choose a valid survey template before launching.');
}
const title = String(payload?.title || '').trim();
if (!title) {
throw buildError('Survey title is required.');
}
if (payload?.opens_at && payload?.closes_at) {
const openDate = new Date(payload.opens_at);
const closeDate = new Date(payload.closes_at);
if (openDate > closeDate) {
throw buildError('Close date must be after the open date.');
}
}
const transaction = await db.sequelize.transaction();
try {
const workspace = currentUser?.organizationsId
? await db.workspaces.findOne({
where: { organizationsId: currentUser.organizationsId },
order: [['createdAt', 'ASC']],
transaction,
})
: null;
const survey = await SurveysDBApi.create(
{
title,
description: String(payload?.description || template.description || '').trim(),
status: payload?.status || (payload?.opens_at ? 'scheduled' : 'draft'),
visibility: payload?.visibility || template.defaultVisibility,
cadence: payload?.cadence || template.defaultCadence,
opens_at: payload?.opens_at || null,
closes_at: payload?.closes_at || null,
include_open_text: Boolean(payload?.include_open_text ?? true),
allow_multiple_submissions: false,
template: payload?.templateId || null,
workspace: workspace?.id || null,
organizations: currentUser?.organizationsId || null,
},
{
currentUser,
transaction,
},
);
for (const [index, questionBlueprint] of template.questions.entries()) {
const question = await SurveyQuestionsDBApi.create(
{
survey: survey.id,
organizations: currentUser?.organizationsId || null,
prompt: questionBlueprint.prompt,
question_type: questionBlueprint.question_type,
order_index: index + 1,
is_required: Boolean(questionBlueprint.is_required),
min_value: questionBlueprint.min_value || null,
max_value: questionBlueprint.max_value || null,
help_text: questionBlueprint.help_text || null,
},
{
currentUser,
transaction,
},
);
if (Array.isArray(questionBlueprint.choices)) {
for (const [choiceIndex, choice] of questionBlueprint.choices.entries()) {
await QuestionChoicesDBApi.create(
{
question: question.id,
organizations: currentUser?.organizationsId || null,
label: choice,
value: choice,
order_index: choiceIndex + 1,
},
{
currentUser,
transaction,
},
);
}
}
}
await transaction.commit();
return {
survey: {
id: survey.id,
title,
status: payload?.status || (payload?.opens_at ? 'scheduled' : 'draft'),
cadence: payload?.cadence || template.defaultCadence,
visibility: payload?.visibility || template.defaultVisibility,
opens_at: payload?.opens_at || null,
closes_at: payload?.closes_at || null,
},
template: {
key: template.key,
title: template.title,
},
questionCount: template.questions.length,
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async generateSurveyAnalysis(surveyId, currentUser) {
const survey = await db.surveys.findOne({
where: {
id: surveyId,
...(currentUser?.organizationsId ? { organizationsId: currentUser.organizationsId } : {}),
},
include: [
{
model: db.survey_templates,
as: 'template',
},
],
});
if (!survey) {
throw buildError('Survey not found.', 404);
}
const [questions, responses] = await Promise.all([
db.survey_questions.findAll({
where: { surveyId: survey.id },
order: [['order_index', 'ASC']],
raw: true,
}),
db.survey_responses.findAll({
where: { surveyId: survey.id },
include: [
{
model: db.departments,
as: 'department',
},
{
model: db.survey_answers,
as: 'survey_answers_response',
include: [
{
model: db.survey_questions,
as: 'question',
},
],
},
],
order: [['submitted_at', 'DESC']],
}),
]);
const completedResponses = responses.filter((response) => response.is_complete);
if (!completedResponses.length) {
throw buildError('Collect at least one completed response before running AI analysis.');
}
const allAnswers = completedResponses.flatMap((response) =>
(response.survey_answers_response || []).map((answer) => ({
...answer.get({ plain: true }),
departmentName: response.department?.name || 'Unassigned',
})),
);
const ratingAnswers = allAnswers.filter((answer) => Number.isFinite(answer.rating_value));
const emojiAnswers = allAnswers.filter((answer) => answer.emoji_value);
const openTextAnswers = allAnswers
.filter((answer) => answer.text_value)
.map((answer) => ({
question: answer.question?.prompt || 'Open text',
quote: String(answer.text_value).trim(),
department: answer.departmentName,
}))
.filter((answer) => answer.quote)
.slice(0, 18);
const ratingAverage = ratingAnswers.length
? Number(
(
ratingAnswers.reduce((sum, answer) => sum + Number(answer.rating_value || 0), 0) /
ratingAnswers.length
).toFixed(1),
)
: null;
const emojiAverage = emojiAnswers.length
? Number(
(
emojiAnswers.reduce((sum, answer) => sum + (EMOJI_TO_SCORE[answer.emoji_value] || 0), 0) /
emojiAnswers.length
).toFixed(1),
)
: null;
const choiceBreakdown = allAnswers
.filter((answer) => answer.choice_value)
.reduce((accumulator, answer) => {
const prompt = answer.question?.prompt || 'Choice question';
const match = accumulator[prompt] || {};
match[answer.choice_value] = (match[answer.choice_value] || 0) + 1;
accumulator[prompt] = match;
return accumulator;
}, {});
const departmentSignals = Object.values(
allAnswers.reduce((accumulator, answer) => {
const bucket = accumulator[answer.departmentName] || {
department: answer.departmentName,
ratings: [],
responses: 0,
};
if (Number.isFinite(answer.rating_value)) {
bucket.ratings.push(Number(answer.rating_value));
}
bucket.responses += 1;
accumulator[answer.departmentName] = bucket;
return accumulator;
}, {}),
).map((item) => ({
department: item.department,
responseCount: item.responses,
averageRating: item.ratings.length
? Number((item.ratings.reduce((sum, rating) => sum + rating, 0) / item.ratings.length).toFixed(1))
: null,
}));
const aiPayload = {
survey: {
id: survey.id,
title: survey.title,
description: survey.description,
status: survey.status,
cadence: survey.cadence,
visibility: survey.visibility,
template: survey.template?.name || null,
},
metrics: {
completedResponses: completedResponses.length,
ratingAverage,
emojiAverage,
},
questions: questions.map((question) => ({
prompt: question.prompt,
questionType: question.question_type,
})),
choiceBreakdown,
departmentSignals,
openTextAnswers,
};
let aiResponse;
try {
aiResponse = await LocalAIApi.createResponse(
{
input: [
{
role: 'system',
content:
'You are an expert employee engagement analyst. Respond only with valid JSON. No markdown fences, no commentary outside JSON.',
},
{
role: 'user',
content: `Analyze the employee pulse survey data below and return JSON with this exact shape: {"overallSentimentScore": number 0-100, "executiveSummary": string, "topThemes": [{"theme": string, "confidence": "high"|"medium"|"low", "quote": string, "insight": string}], "departmentRisks": [{"department": string, "riskLevel": "low"|"medium"|"high", "riskScore": number 0-100, "signal": string}], "recommendedActions": [{"title": string, "owner": string, "timeline": string, "template": string}]}. Use real quotes only from provided openTextAnswers. Limit topThemes to 5 and recommendedActions to 3. Data: ${JSON.stringify(aiPayload)}`,
},
],
},
{ poll_interval: 5, poll_timeout: 300 },
);
if (!aiResponse.success) {
console.error('PulseSurvey AI analysis failed:', aiResponse);
throw buildError(aiResponse.error || aiResponse.message || 'AI analysis failed.', 502);
}
const parsed = LocalAIApi.decodeJsonFromResponse(aiResponse);
const savedAnalysis = await AiAnalysesDBApi.create(
{
workspace: survey.workspaceId || null,
survey: survey.id,
organizations: survey.organizationsId || currentUser?.organizationsId || null,
status: 'completed',
model: aiResponse?.data?.model || 'ai-proxy-default',
overall_sentiment_score: parsed.overallSentimentScore ?? null,
summary: parsed.executiveSummary || '',
top_themes_json: JSON.stringify(parsed.topThemes || []),
department_risks_json: JSON.stringify(parsed.departmentRisks || []),
recommended_actions_json: JSON.stringify(parsed.recommendedActions || []),
generated_at: new Date(),
},
{
currentUser,
},
);
return {
survey: {
id: survey.id,
title: survey.title,
},
meta: {
completedResponses: completedResponses.length,
questionCount: questions.length,
ratingAverage,
emojiAverage,
},
analysis: {
id: savedAnalysis.id,
overallSentimentScore: parsed.overallSentimentScore ?? null,
executiveSummary: parsed.executiveSummary || '',
topThemes: Array.isArray(parsed.topThemes) ? parsed.topThemes : [],
departmentRisks: Array.isArray(parsed.departmentRisks) ? parsed.departmentRisks : [],
recommendedActions: Array.isArray(parsed.recommendedActions) ? parsed.recommendedActions : [],
},
};
} catch (error) {
console.error('PulseSurvey analysis route failed:', error);
await AiAnalysesDBApi.create(
{
workspace: survey.workspaceId || null,
survey: survey.id,
organizations: survey.organizationsId || currentUser?.organizationsId || null,
status: 'failed',
model: aiResponse?.data?.model || 'ai-proxy-default',
summary: '',
error_message: error.message,
generated_at: new Date(),
},
{
currentUser,
},
);
throw error;
}
}
static parseStoredAnalysis(record) {
return {
id: record.id,
overallSentimentScore: record.overall_sentiment_score ? Number(record.overall_sentiment_score) : null,
executiveSummary: record.summary || '',
topThemes: parseJson(record.top_themes_json, []),
departmentRisks: parseJson(record.department_risks_json, []),
recommendedActions: parseJson(record.recommended_actions_json, []),
generatedAt: record.generated_at || record.createdAt || null,
};
}
};

View File

@ -3,8 +3,10 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList' import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces' import { MenuAsideItem } from '../interfaces'
import { useAppDispatch, useAppSelector } from '../stores/hooks' import { useAppSelector } from '../stores/hooks'
import Link from 'next/link'; import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, {useEffect, useRef} from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useEffect, useState } from 'react' import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,14 +7,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/pulse-hq',
label: 'Pulse HQ',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiHeartPulse' in icon ? icon['mdiHeartPulse' as keyof typeof icon] : icon.mdiChartTimelineVariant ?? icon.mdiTable,
permissions: 'READ_SURVEYS'
},
{ {
href: '/users/users-list', href: '/users/users-list',

View File

@ -1,179 +1,166 @@
import { mdiArrowRight, mdiBrain, mdiChartBoxOutline, mdiHeartPulse, mdiShieldCrownOutline } from '@mdi/js';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import SectionMain from '../components/SectionMain'; import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const landingTemplates = [
{
title: 'Weekly Pulse',
accent: '#8B5CF6',
description: 'Fast morale and workload check-ins for every team.',
},
{
title: 'Onboarding',
accent: '#14B8A6',
description: 'Catch new-hire friction before it turns into attrition.',
},
{
title: 'Quarterly Deep Dive',
accent: '#F97316',
description: 'Zoom out on strategy, growth, trust, and collaboration.',
},
{
title: 'Burnout Check',
accent: '#EF4444',
description: 'Surface overload signals and department-level risk quickly.',
},
];
export default function Starter() { export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'PulseSurvey MVP'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<> <div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('PulseSurvey')}</title> <title>{getPageTitle('Starter Page')}</title>
</Head> </Head>
<div className='min-h-screen bg-[#050816] text-slate-100'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(139,92,246,0.18),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.16),_transparent_26%),linear-gradient(180deg,_rgba(5,8,22,0.95),_rgba(5,8,22,1))]' />
<SectionMain>
<div className='relative z-10'>
<header className='flex flex-wrap items-center justify-between gap-4 py-4'>
<div className='flex items-center gap-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-sky-400 text-white shadow-lg'>
<BaseIcon path={mdiHeartPulse} size={22} />
</div>
<div>
<p className='text-sm font-semibold tracking-[0.2em] text-slate-300 uppercase'>PulseSurvey</p>
<p className='text-xs text-slate-500'>Continuous engagement intelligence</p>
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='white' outline label='Login' />
<BaseButton href='/dashboard' color='info' label='Admin interface' />
</div>
</header>
<main className='py-12 md:py-20'> <SectionFullScreen bg='violet'>
<div className='grid items-center gap-10 lg:grid-cols-[1.1fr,0.9fr]'> <div
<div> className={`flex ${
<div className='inline-flex items-center gap-2 rounded-full border border-violet-400/20 bg-violet-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-violet-200'> contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
<BaseIcon path={mdiBrain} size={16} /> } min-h-screen w-full`}
AI-powered employee engagement >
</div> {contentType === 'image' && contentPosition !== 'background'
<h1 className='mt-6 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl'> ? imageBlock(illustrationImage)
Spot burnout risk early, launch better pulse surveys, and turn feedback into action. : null}
</h1> {contentType === 'video' && contentPosition !== 'background'
<p className='mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg'> ? videoBlock(illustrationVideo)
PulseSurvey helps People Ops teams run modern engagement surveys, monitor response momentum, and generate : null}
crisp AI summaries with recommended next steps all from one polished admin workspace. <div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
</p> <CardBox className='w-full md:w-3/5 lg:w-2/3'>
<div className='mt-8 flex flex-wrap gap-3'> <CardBoxComponentTitle title="Welcome to your PulseSurvey MVP app!"/>
<BaseButton href='/login' color='info' icon={mdiArrowRight} label='Start in admin' />
<BaseButton href='/pulse-hq' color='white' outline label='Preview Pulse HQ' />
</div>
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
{[
['4 launch templates', 'Weekly pulse, onboarding, quarterly deep dive, burnout checks'],
['AI-ready insights', 'Sentiment, top themes, risk flags, and actions'],
['Multi-tenant foundation', 'Organizations, workspaces, teams, and roles already seeded'],
].map(([title, description]) => (
<div key={title} className='rounded-2xl border border-white/10 bg-white/[0.04] p-4 backdrop-blur'>
<p className='text-sm font-semibold text-white'>{title}</p>
<p className='mt-2 text-sm leading-6 text-slate-400'>{description}</p>
</div>
))}
</div>
</div>
<CardBox className='border border-white/10 bg-slate-950/80 text-slate-100 shadow-[0_24px_60px_rgba(15,23,42,0.45)]'> <div className="space-y-3">
<div className='flex items-center justify-between gap-3'> <p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<div> <p className='text-center '>For guides and documentation please check
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Inside the MVP</p> your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<h2 className='mt-2 text-2xl font-semibold text-white'>Your first complete workflow</h2> </div>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
<BaseIcon path={mdiChartBoxOutline} size={22} className='text-sky-300' />
</div>
</div>
<div className='mt-6 space-y-4'>
{[
{
title: '1. Launch a pulse survey',
text: 'Choose a proven template, set cadence + visibility, and create the survey with seeded questions in one action.',
},
{
title: '2. Review recent survey health',
text: 'See response rate, invitations, question count, department participation, and quick links to view/edit details.',
},
{
title: '3. Generate AI analysis',
text: 'Turn completed responses into themes, risk signals, and recommended actions ready for leadership readouts.',
},
].map((item) => (
<div key={item.title} className='rounded-2xl border border-white/10 bg-white/[0.03] p-4'>
<p className='font-semibold text-white'>{item.title}</p>
<p className='mt-2 text-sm leading-6 text-slate-400'>{item.text}</p>
</div>
))}
</div>
</CardBox>
</div>
<section className='mt-16'> <BaseButtons>
<div className='flex flex-wrap items-end justify-between gap-4'> <BaseButton
<div> href='/login'
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Survey templates</p> label='Login'
<h2 className='mt-2 text-3xl font-semibold text-white'>Launch modes HR teams actually use</h2> color='info'
</div> className='w-full'
<Link href='/login' className='text-sm text-sky-300 transition hover:text-sky-200'> />
Log in to build and manage surveys
</Link>
</div>
<div className='mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{landingTemplates.map((template) => (
<div key={template.title} className='rounded-3xl border border-white/10 bg-white/[0.04] p-5'>
<div className='flex items-center gap-3'>
<span className='h-3 w-3 rounded-full' style={{ backgroundColor: template.accent }} />
<p className='font-semibold text-white'>{template.title}</p>
</div>
<p className='mt-4 text-sm leading-6 text-slate-400'>{template.description}</p>
</div>
))}
</div>
</section>
<section className='mt-16 rounded-[2rem] border border-white/10 bg-gradient-to-r from-violet-500/10 via-slate-900 to-sky-500/10 p-8'> </BaseButtons>
<div className='grid gap-6 lg:grid-cols-[1fr,auto] lg:items-center'> </CardBox>
<div> </div>
<div className='inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300'>
<BaseIcon path={mdiShieldCrownOutline} size={16} />
Public landing · private admin
</div>
<h2 className='mt-4 text-3xl font-semibold text-white'>Ready to try the admin experience?</h2>
<p className='mt-3 max-w-2xl text-base leading-7 text-slate-300'>
Use the admin interface to launch surveys, inspect recent activity, and generate AI insights from within the secured workspace.
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='info' label='Login' />
<BaseButton href='/dashboard' color='white' outline label='Open admin interface' />
</div>
</div>
</section>
</main>
</div>
</SectionMain>
</div> </div>
</> </SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };

View File

@ -1,936 +0,0 @@
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: '110 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;

View File

@ -1,7 +1,9 @@
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';