Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be323ebbec |
@ -3,10 +3,9 @@ const express = require('express');
|
||||
|
||||
const SurveysService = require('../services/surveys');
|
||||
const SurveysDBApi = require('../db/api/surveys');
|
||||
const PulseSurveyLaunchpadService = require('../services/pulseSurveyLaunchpad');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -263,6 +262,21 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
||||
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
|
||||
* /api/surveys:
|
||||
|
||||
772
backend/src/services/pulseSurveyLaunchpad.js
Normal file
772
backend/src/services/pulseSurveyLaunchpad.js
Normal file
@ -0,0 +1,772 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import AsideMenuList from './AsideMenuList'
|
||||
import { MenuAsideItem } from '../interfaces'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
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',
|
||||
|
||||
@ -1,166 +1,179 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { mdiArrowRight, mdiBrain, mdiChartBoxOutline, mdiHeartPulse, mdiShieldCrownOutline } from '@mdi/js';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
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() {
|
||||
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 (
|
||||
<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>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('PulseSurvey')}</title>
|
||||
</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>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your PulseSurvey MVP app!"/>
|
||||
|
||||
<div className="space-y-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>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className='py-12 md:py-20'>
|
||||
<div className='grid items-center gap-10 lg:grid-cols-[1.1fr,0.9fr]'>
|
||||
<div>
|
||||
<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'>
|
||||
<BaseIcon path={mdiBrain} size={16} />
|
||||
AI-powered employee engagement
|
||||
</div>
|
||||
<h1 className='mt-6 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl'>
|
||||
Spot burnout risk early, launch better pulse surveys, and turn feedback into action.
|
||||
</h1>
|
||||
<p className='mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg'>
|
||||
PulseSurvey helps People Ops teams run modern engagement surveys, monitor response momentum, and generate
|
||||
crisp AI summaries with recommended next steps — all from one polished admin workspace.
|
||||
</p>
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
<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>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
<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='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Inside the MVP</p>
|
||||
<h2 className='mt-2 text-2xl font-semibold text-white'>Your first complete workflow</h2>
|
||||
</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'>
|
||||
<div className='flex flex-wrap items-end justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-[0.22em] text-slate-500'>Survey templates</p>
|
||||
<h2 className='mt-2 text-3xl font-semibold text-white'>Launch modes HR teams actually use</h2>
|
||||
</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 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'>
|
||||
<div className='grid gap-6 lg:grid-cols-[1fr,auto] lg:items-center'>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
936
frontend/src/pages/pulse-hq.tsx
Normal file
936
frontend/src/pages/pulse-hq.tsx
Normal file
@ -0,0 +1,936 @@
|
||||
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;
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user