From be323ebbecdf29f2d29652b202db705bf9f78662 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 26 Mar 2026 17:54:46 +0000 Subject: [PATCH] puls --- backend/src/routes/surveys.js | 18 +- backend/src/services/pulseSurveyLaunchpad.js | 772 +++++++++++++++ frontend/src/components/AsideMenuLayer.tsx | 4 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 301 +++--- frontend/src/pages/pulse-hq.tsx | 936 +++++++++++++++++++ frontend/src/pages/search.tsx | 4 +- 9 files changed, 1893 insertions(+), 156 deletions(-) create mode 100644 backend/src/services/pulseSurveyLaunchpad.js create mode 100644 frontend/src/pages/pulse-hq.tsx diff --git a/backend/src/routes/surveys.js b/backend/src/routes/surveys.js index 043a2bf..c35f40e 100644 --- a/backend/src/routes/surveys.js +++ b/backend/src/routes/surveys.js @@ -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: diff --git a/backend/src/services/pulseSurveyLaunchpad.js b/backend/src/services/pulseSurveyLaunchpad.js new file mode 100644 index 0000000..0117bd7 --- /dev/null +++ b/backend/src/services/pulseSurveyLaunchpad.js @@ -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, + }; + } +}; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 1d359e4..37da01f 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -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'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6529710..6ee322a 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index f43d1a9..a9c9607 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('PulseSurvey')} +
+
+ +
+
+
+
+ +
+
+

PulseSurvey

+

Continuous engagement intelligence

+
+
+
+ + +
+
- -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - +
+
+
+
+ + AI-powered employee engagement +
+

+ Spot burnout risk early, launch better pulse surveys, and turn feedback into action. +

+

+ 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. +

+
+ + +
+
+ {[ + ['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]) => ( +
+

{title}

+

{description}

+
+ ))} +
+
- - -
+ +
+
+

Inside the MVP

+

Your first complete workflow

+
+
+ +
+
+
+ {[ + { + 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) => ( +
+

{item.title}

+

{item.text}

+
+ ))} +
+
+
+ +
+
+
+

Survey templates

+

Launch modes HR teams actually use

+
+ + Log in to build and manage surveys → + +
+
+ {landingTemplates.map((template) => ( +
+
+ +

{template.title}

+
+

{template.description}

+
+ ))} +
+
+ +
+
+
+
+ + Public landing · private admin +
+

Ready to try the admin experience?

+

+ Use the admin interface to launch surveys, inspect recent activity, and generate AI insights from within the secured workspace. +

+
+
+ + +
+
+
+ +
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/pulse-hq.tsx b/frontend/src/pages/pulse-hq.tsx new file mode 100644 index 0000000..50675d7 --- /dev/null +++ b/frontend/src/pages/pulse-hq.tsx @@ -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 = { + one_time: 'One-time', + weekly: 'Weekly', + biweekly: 'Biweekly', + monthly: 'Monthly', + quarterly: 'Quarterly', +}; + +const visibilityLabels: Record = { + anonymous: 'Anonymous', + identified: 'Identified', +}; + +const statusClassNames: Record = { + 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 = { + 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(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); + const [selectedSurveyId, setSelectedSurveyId] = useState(''); + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysisError, setAnalysisError] = useState(''); + const [analysisResult, setAnalysisResult] = useState(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, + ) => { + 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) => { + 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 ( + <> + + {getPageTitle('Pulse HQ')} + + + + + + + {pageError && ( + + {pageError} + + )} + + {launchSuccess && ( + + + +
+ } + > + {`${launchSuccess.title} is ready. ${launchSuccess.questionCount} questions were created from ${launchSuccess.templateTitle}.`} + + )} + +
+ +
+
+
+
+ + PulseSurvey launchpad +
+
+

+ Launch recurring employee pulse surveys in minutes — then turn responses into AI-backed action. +

+

+ 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. +

+
+
+ {[ + { + 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) => ( +
+

{metric.label}

+

{metric.value}

+

{metric.note}

+
+ ))} +
+
+ + +
+
+ +
+
+
+

Template lineup

+

Choose your first pulse

+
+
+ +
+
+
+ {summary?.templates.map((template) => { + const active = selectedTemplateKey === template.key; + return ( + + ); + })} +
+
+
+ + + +
+
+

Workflow preview

+

This launch creates

+
+ +
+
+ {selectedTemplate?.previewQuestions.map((question, index) => ( +
+
+ {`Q${index + 1}`} + + {questionTypeLabels[question.question_type] || question.question_type} + +
+

{question.prompt}

+
+ ))} +
+
+ After launch, your survey appears below, inherits cadence + visibility defaults, and becomes eligible for AI + analysis as soon as completed responses start coming in. +
+
+
+ +
+ +
+
+

Launch survey

+

Create a branded pulse from a proven template

+
+ +
+ + {!canCreateSurveys && ( +
+ You have read access to Pulse HQ, but launching surveys requires the CREATE_SURVEYS permission. +
+ )} + + {launchError && ( +
+ {launchError} +
+ )} + +
+
+ + +
+ +