Compare commits

..

2 Commits

Author SHA1 Message Date
Flatlogic Bot
fd271763d9 V2 2026-06-25 16:52:23 +00:00
Flatlogic Bot
5dfc25ec83 TNA Tool V1 2026-06-25 16:46:27 +00:00
9 changed files with 761 additions and 162 deletions

View File

@ -54,6 +54,7 @@ const tagsRoutes = require('./routes/tags');
const interview_tagsRoutes = require('./routes/interview_tags'); const interview_tagsRoutes = require('./routes/interview_tags');
const reportsRoutes = require('./routes/reports'); const reportsRoutes = require('./routes/reports');
const interviewWorkflowRoutes = require('./routes/interviewWorkflow');
const getBaseUrl = (url) => { const getBaseUrl = (url) => {
@ -145,6 +146,8 @@ app.use('/api/interview_tags', passport.authenticate('jwt', {session: false}), i
app.use('/api/reports', passport.authenticate('jwt', {session: false}), reportsRoutes); app.use('/api/reports', passport.authenticate('jwt', {session: false}), reportsRoutes);
app.use('/api/interview-workflow', passport.authenticate('jwt', {session: false}), interviewWorkflowRoutes);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,264 @@
const express = require('express');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const INTERVIEW_QUESTIONS = [
'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?',
'Describe training that is most effective for your learning and why.',
'What is a reasonable amount of time that you could dedicate to training?',
'What makes learning memorable for you? How can training be designed to help stick for you?',
'How do you learn about training opportunities? Is that method effective? What could make it more effective?',
];
const SECTION_BY_QUESTION = [
'Participant priorities and interests',
'Preferred learning methods',
'Time available for training',
'What makes training memorable and effective',
'How the participant hears about training opportunities',
];
const FINDING_TYPES = [
'priority_interest',
'preferred_method',
'time_constraint',
'implication',
'channel_preference',
];
const normalizeAnswers = (answers) => {
if (!Array.isArray(answers)) return [];
return INTERVIEW_QUESTIONS.map((question, index) => {
const answer = answers[index] || {};
return {
question,
answer: String(answer.answer || '').trim(),
};
});
};
const sentenceFrom = (text) => {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
if (!clean) return 'No specific response captured.';
return clean.length > 220 ? `${clean.slice(0, 217)}...` : clean;
};
const extractTags = (answers) => {
const text = answers.map((item) => item.answer).join(' ').toLowerCase();
const tagRules = [
['hands-on learning', ['hands-on', 'practice', 'workshop', 'exercise', 'interactive']],
['short sessions', ['short', 'bite', 'micro', 'hour', '30', 'minutes']],
['peer learning', ['peer', 'colleague', 'mentor', 'community', 'team']],
['role-specific training', ['role', 'job', 'specific', 'relevant', 'real-world']],
['communication channels', ['email', 'newsletter', 'calendar', 'intranet', 'slack', 'teams']],
['time constraints', ['busy', 'time', 'schedule', 'workload', 'capacity']],
['follow-up support', ['follow-up', 'refresh', 'reinforce', 'coaching', 'support']],
];
const tags = tagRules
.filter(([, terms]) => terms.some((term) => text.includes(term)))
.map(([tag]) => tag);
return tags.length ? tags : ['training needs assessment'];
};
const buildSummary = (answers, mode) => {
const lines = SECTION_BY_QUESTION.map((section, index) => `- ${section}: ${sentenceFrom(answers[index]?.answer)}`);
const gaps = answers
.map((item) => item.answer)
.join(' ')
.toLowerCase()
.match(/gap|need|hard|difficult|barrier|challenge|limited|lack/g);
lines.push(`- Notable training gaps, barriers, or unmet needs: ${gaps ? 'Potential unmet needs or barriers were mentioned and should be reviewed in context.' : 'No explicit gap or barrier language was captured in this first-pass summary.'}`);
lines.push(`- Recommended implications for future training design: Prioritize practical, easy-to-access training formats that reflect the participant's stated preferences and constraints.`);
lines.push(`- Interview mode: ${mode === 'voice' ? 'Voice or dictated responses' : mode === 'typed' ? 'Typed responses' : 'Mixed mode'}.`);
return lines.join('\n');
};
const organizationWhere = (currentUser) => {
if (currentUser.app_role?.globalAccess) return {};
const organizationId = currentUser.organization?.id || currentUser.organizationId;
return organizationId ? { organizationId } : {};
};
router.get('/records', wrapAsync(async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 50);
const rows = await db.interviews.findAll({
where: organizationWhere(req.currentUser),
include: [
{ model: db.participants, as: 'participant' },
{ model: db.users, as: 'facilitator' },
],
order: [['createdAt', 'DESC']],
limit,
});
res.status(200).send({ rows });
}));
router.get('/analysis', wrapAsync(async (req, res) => {
const interviewWhere = organizationWhere(req.currentUser);
const interviews = await db.interviews.findAll({
where: interviewWhere,
include: [
{ model: db.participants, as: 'participant' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
order: [['createdAt', 'DESC']],
limit: 100,
});
const findingCounts = {};
const tagCounts = {};
interviews.forEach((interview) => {
(interview.interview_findings_interview || []).forEach((finding) => {
const type = finding.finding_type || 'uncategorized';
findingCounts[type] = (findingCounts[type] || 0) + 1;
});
const inferenceNotes = String(interview.inference_notes || '').trim();
if (inferenceNotes.startsWith('{')) {
const parsed = JSON.parse(inferenceNotes);
(parsed.tags || []).forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
}
});
const themes = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([theme, count]) => ({ theme, count }));
res.status(200).send({
interviewCount: interviews.length,
findingCounts,
themes,
latestSummaries: interviews.slice(0, 5).map((interview) => ({
id: interview.id,
participant: interview.participant?.display_name || 'Unnamed participant',
final_summary: interview.final_summary,
createdAt: interview.createdAt,
})),
});
}));
router.get('/records/:id', wrapAsync(async (req, res) => {
const interview = await db.interviews.findOne({
where: { id: req.params.id, ...organizationWhere(req.currentUser) },
include: [
{ model: db.participants, as: 'participant' },
{ model: db.users, as: 'facilitator' },
{ model: db.interview_responses, as: 'interview_responses_interview' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
});
if (!interview) {
return res.status(404).send('Interview record not found');
}
return res.status(200).send(interview);
}));
router.post('/records', wrapAsync(async (req, res) => {
const mode = req.body.mode;
const participant = req.body.participant || {};
const answers = normalizeAnswers(req.body.answers);
if (!['voice', 'typed', 'mixed'].includes(mode)) {
return res.status(400).send('Choose voice, typed, or mixed interview mode.');
}
const missingAnswer = answers.find((item) => !item.answer);
if (missingAnswer) {
return res.status(400).send('Please capture a response for every core interview question before saving.');
}
const organizationId = req.currentUser.organization?.id || req.currentUser.organizationId || null;
const transaction = await db.sequelize.transaction();
try {
const createdParticipant = await db.participants.create({
display_name: String(participant.display_name || '').trim() || `Participant ${new Date().toISOString().slice(0, 10)}`,
department: String(participant.department || '').trim() || null,
job_title: String(participant.job_title || '').trim() || null,
notes: String(participant.notes || '').trim() || null,
consent_to_store: true,
organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction });
const tags = extractTags(answers);
const finalSummary = buildSummary(answers, mode);
const inferenceNotes = JSON.stringify({
objective: 'Understand current practices, training gaps, and priority skill areas across workforce interviews.',
tags,
generated_by: 'rule_based_mvp_summary',
});
const interview = await db.interviews.create({
started_at: new Date(),
completed_at: new Date(),
mode,
status: 'completed',
context_notes: String(req.body.context_notes || '').trim() || 'Fixed five-question training needs assessment interview.',
final_summary: finalSummary,
inference_notes: inferenceNotes,
organizationId,
participantId: createdParticipant.id,
facilitatorId: req.currentUser.id,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction });
await Promise.all(answers.map((item, index) => db.interview_responses.create({
turn_number: index + 1,
response_format: mode === 'voice' ? 'spoken_style' : 'typed',
response_text: `Question: ${item.question}\n\nResponse: ${item.answer}`,
is_clarification: false,
interviewId: interview.id,
organizationsId: organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction })));
await Promise.all(answers.map((item, index) => db.interview_findings.create({
finding_type: FINDING_TYPES[index] || 'implication',
sentiment: 'neutral',
confidence: 0.7,
evidence_excerpt: sentenceFrom(item.answer),
notes: `${SECTION_BY_QUESTION[index]}: ${sentenceFrom(item.answer)}`,
interviewId: interview.id,
organizationsId: organizationId,
createdById: req.currentUser.id,
updatedById: req.currentUser.id,
}, { transaction })));
await transaction.commit();
const savedInterview = await db.interviews.findByPk(interview.id, {
include: [
{ model: db.participants, as: 'participant' },
{ model: db.interview_responses, as: 'interview_responses_interview' },
{ model: db.interview_findings, as: 'interview_findings_interview' },
],
});
return res.status(201).send(savedInterview);
} catch (error) {
await transaction.rollback();
throw error;
}
}));
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

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

View File

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

View File

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

View File

@ -72,6 +72,14 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, icon: 'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PARTICIPANTS' permissions: 'READ_PARTICIPANTS'
}, },
{
href: '/interview-workspace',
label: 'Interview Workspace',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardTextClockOutline' in icon ? icon['mdiClipboardTextClockOutline' as keyof typeof icon] : icon.mdiAccountVoice ?? icon.mdiTable,
permissions: 'READ_INTERVIEWS'
},
{ {
href: '/interviews/interviews-list', href: '/interviews/interviews-list',
label: 'Interviews', label: 'Interviews',

View File

@ -1,166 +1,126 @@
import { mdiAccountVoice, mdiLoginVariant } 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 LayoutGuest from '../layouts/Guest'
import { getPageTitle } from '../config'
import React, { useEffect, useState } from 'react'; const workflowCards = [
import type { ReactElement } from 'react'; {
import Head from 'next/head'; title: 'Choose voice or typed mode',
import Link from 'next/link'; text: 'Start every session by confirming how the participant wants to respond, then keep the flow interview-like and focused.',
import BaseButton from '../components/BaseButton'; },
import CardBox from '../components/CardBox'; {
import SectionFullScreen from '../components/SectionFullScreen'; title: 'Capture the fixed five questions',
import LayoutGuest from '../layouts/Guest'; text: 'One main question appears at a time, with room for concise follow-up detail when it clarifies training needs.',
import BaseDivider from '../components/BaseDivider'; },
import BaseButtons from '../components/BaseButtons'; {
import { getPageTitle } from '../config'; title: 'Save summaries and findings',
import { useAppSelector } from '../stores/hooks'; text: 'Each completed interview becomes a durable record with structured responses, a concise summary, and reusable tags.',
import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; },
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; ]
export default function Starter() { export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Training Interview Tracker'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div <div className="min-h-screen bg-[#F6F4EF] text-slate-950">
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Training needs interviews')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <header className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<div <Link href="/" className="flex items-center gap-3">
className={`flex ${ <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#123C3A] text-white shadow-lg shadow-emerald-900/20">T</span>
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <span>
} min-h-screen w-full`} <span className="block text-sm font-black uppercase tracking-[0.24em] text-[#123C3A]">TNA Studio</span>
> <span className="block text-xs text-slate-500">Training Interview Tracker</span>
{contentType === 'image' && contentPosition !== 'background' </span>
? 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 Training Interview Tracker app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>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 text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link> </Link>
</div> <nav className="flex items-center gap-2">
<BaseButton href="/login" label="Login" icon={mdiLoginVariant} color="white" />
<BaseButton href="/interview-workspace" label="Admin interface" color="success" />
</nav>
</header>
<main>
<section className="mx-auto grid max-w-7xl gap-10 px-6 pb-16 pt-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center lg:pb-24">
<div>
<div className="mb-6 inline-flex rounded-full border border-[#D9D2C3] bg-white/70 px-4 py-2 text-xs font-bold uppercase tracking-[0.24em] text-[#C56A2D] shadow-sm">Structured interviews, durable insight</div>
<h1 className="max-w-4xl text-5xl font-black leading-[0.96] tracking-tight text-[#123C3A] md:text-7xl">Turn training conversations into clear workforce priorities.</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">Run consistent training-needs assessment interviews, capture voice-style or typed responses, produce grounded summaries, and retain structured findings for trend analysis across participants.</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/interview-workspace" label="Start an interview" icon={mdiAccountVoice} color="success" className="text-base" />
<BaseButton href="/login" label="Login to admin" color="white" className="text-base" />
</div>
<p className="mt-4 text-sm text-slate-500">Authentication keeps interview records private. Use the admin interface to access the working MVP slice.</p>
</div>
<div className="relative">
<div className="absolute -left-6 top-10 h-44 w-44 rounded-full bg-[#F3A63B]/30 blur-3xl" />
<div className="absolute -right-6 bottom-10 h-52 w-52 rounded-full bg-[#0E8F7E]/20 blur-3xl" />
<div className="relative overflow-hidden rounded-[2.5rem] bg-[#123C3A] p-5 text-white shadow-2xl shadow-emerald-950/30">
<div className="rounded-[2rem] border border-white/10 bg-white/10 p-5 backdrop-blur">
<div className="mb-5 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-emerald-100">Live session</p>
<h2 className="text-2xl font-black">Question 2 of 5</h2>
</div>
<span className="rounded-full bg-[#F3A63B] px-3 py-1 text-xs font-black text-[#123C3A]">Typed mode</span>
</div>
<div className="rounded-3xl bg-white p-5 text-slate-950">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.24em] text-slate-400">Main question</p>
<p className="text-2xl font-black leading-8">Describe training that is most effective for your learning and why.</p>
</div>
<div className="mt-4 rounded-3xl border border-white/10 bg-[#082927] p-4">
<p className="text-sm leading-6 text-emerald-50/80">Captured answer becomes part of the participant summary, finding tags, and future cross-interview pattern analysis.</p>
</div>
</div>
</div>
</div>
</section>
<section className="bg-white py-16">
<div className="mx-auto max-w-7xl px-6">
<div className="mb-10 max-w-3xl">
<p className="text-sm font-bold uppercase tracking-[0.28em] text-[#0E8F7E]">First MVP workflow</p>
<h2 className="mt-3 text-4xl font-black text-[#123C3A]">A thin slice from interview start to analysis-ready record.</h2>
</div>
<div className="grid gap-5 md:grid-cols-3">
{workflowCards.map((card, index) => (
<div key={card.title} className="rounded-[2rem] border border-slate-100 bg-[#F6F4EF] p-6 shadow-sm">
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#123C3A] text-lg font-black text-white">{index + 1}</div>
<h3 className="text-xl font-black text-[#123C3A]">{card.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">{card.text}</p>
</div>
))}
</div>
</div>
</section>
<section className="mx-auto grid max-w-7xl gap-6 px-6 py-16 md:grid-cols-3">
<div className="rounded-[2rem] bg-[#123C3A] p-6 text-white">
<p className="text-4xl font-black">5</p>
<p className="mt-2 text-sm text-emerald-50/80">Core questions kept consistent across interviews.</p>
</div>
<div className="rounded-[2rem] bg-[#F3A63B] p-6 text-[#123C3A]">
<p className="text-4xl font-black">60m</p>
<p className="mt-2 text-sm text-[#123C3A]/75">Designed to keep facilitation moving without becoming a long survey.</p>
</div>
<div className="rounded-[2rem] bg-white p-6 text-[#123C3A] shadow-sm">
<p className="text-4xl font-black">Trends</p>
<p className="mt-2 text-sm text-slate-600">Themes, recurring gaps, delivery preferences, and communication channels accumulate over time.</p>
</div>
</section>
</main>
<footer className="border-t border-[#D9D2C3] px-6 py-8 text-center text-sm text-slate-500">
© 2026 Training Interview Tracker · <Link className="font-semibold text-[#123C3A]" href="/privacy-policy/">Privacy Policy</Link>
</footer>
</div> </div>
); )
} }
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>
}; }

View File

@ -0,0 +1,369 @@
import { mdiAccountVoice, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiClipboardTextOutline, mdiMicrophoneOutline, mdiPencilOutline } from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../layouts/Authenticated'
import { getPageTitle } from '../config'
import { useAppSelector } from '../stores/hooks'
type InterviewMode = 'voice' | 'typed' | 'mixed'
type SavedInterview = {
id: string
mode?: InterviewMode
status?: string
final_summary?: string
createdAt?: string
completed_at?: string
participant?: {
display_name?: string
department?: string
job_title?: string
}
interview_responses_interview?: Array<{
id: string
turn_number: number
response_text: string
}>
}
type Analysis = {
interviewCount: number
findingCounts: Record<string, number>
themes: Array<{ theme: string; count: number }>
}
const QUESTIONS = [
'Do you have any professional development training that you recommend that would be of interest or value to your colleagues?',
'Describe training that is most effective for your learning and why.',
'What is a reasonable amount of time that you could dedicate to training?',
'What makes learning memorable for you? How can training be designed to help stick for you?',
'How do you learn about training opportunities? Is that method effective? What could make it more effective?',
]
const SECTION_TITLES = [
'Participant priorities and interests',
'Preferred learning methods',
'Time available for training',
'Memorable and effective learning',
'Training opportunity channels',
]
const emptyAnswers = QUESTIONS.map(() => ({ answer: '' }))
const inputClass = 'w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-800 shadow-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-900 dark:text-slate-100'
const labelClass = 'mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-500 dark:text-slate-400'
const responseOnly = (responseText: string) => responseText.split('Response:').pop()?.trim() || responseText
const InterviewWorkspacePage = () => {
const { token } = useAppSelector((state) => state.auth)
const [mode, setMode] = useState<InterviewMode | ''>('')
const [participant, setParticipant] = useState({ display_name: '', department: '', job_title: '', notes: '' })
const [answers, setAnswers] = useState(emptyAnswers)
const [activeQuestion, setActiveQuestion] = useState(0)
const [records, setRecords] = useState<SavedInterview[]>([])
const [selectedRecord, setSelectedRecord] = useState<SavedInterview | null>(null)
const [analysis, setAnalysis] = useState<Analysis | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [notice, setNotice] = useState('')
const [error, setError] = useState('')
const answeredCount = useMemo(() => answers.filter((item) => item.answer.trim()).length, [answers])
const canSave = Boolean(mode) && answeredCount === QUESTIONS.length && !isSaving
const fetchRecords = async () => {
setIsLoading(true)
try {
const [recordsResponse, analysisResponse] = await Promise.all([
axios.get('/interview-workflow/records'),
axios.get('/interview-workflow/analysis'),
])
const fetchedRecords = recordsResponse.data?.rows || []
setRecords(fetchedRecords)
setAnalysis(analysisResponse.data)
setSelectedRecord((current) => current || fetchedRecords[0] || null)
} catch (fetchError) {
if (axios.isAxiosError(fetchError) && fetchError.response?.status === 401) {
setError('Please log in to load the interview workspace.')
return
}
console.error('Failed to load interview workspace data', fetchError)
setError('Could not load saved interview records. Please refresh and try again.')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (token) {
fetchRecords()
}
}, [token])
const updateAnswer = (value: string) => {
setAnswers((current) => current.map((item, index) => (index === activeQuestion ? { answer: value } : item)))
}
const resetInterview = () => {
setMode('')
setParticipant({ display_name: '', department: '', job_title: '', notes: '' })
setAnswers(emptyAnswers)
setActiveQuestion(0)
setNotice('Ready for the next interview. Choose a response mode to begin.')
setError('')
}
const saveInterview = async () => {
if (!canSave || !mode) return
setIsSaving(true)
setError('')
setNotice('')
try {
const response = await axios.post('/interview-workflow/records', {
mode,
participant,
answers,
})
setNotice('Interview saved. A structured summary and findings were added to the repository.')
setSelectedRecord(response.data)
setRecords((current) => [response.data, ...current.filter((record) => record.id !== response.data.id)])
await fetchRecords()
} catch (saveError) {
console.error('Failed to save interview record', saveError)
setError(axios.isAxiosError(saveError) && saveError.response?.data ? String(saveError.response.data) : 'Could not save the interview. Please review the responses and try again.')
} finally {
setIsSaving(false)
}
}
const selectedResponses = [...(selectedRecord?.interview_responses_interview || [])].sort((a, b) => a.turn_number - b.turn_number)
return (
<>
<Head>
<title>{getPageTitle('Interview workspace')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiAccountVoice} title="Training needs interview workspace" main>
<BaseButton href="/interviews/interviews-list" label="Open CRUD records" color="whiteDark" />
</SectionTitleLineWithButton>
<div className="mb-6 overflow-hidden rounded-[2rem] bg-gradient-to-br from-slate-950 via-teal-950 to-emerald-900 p-6 text-white shadow-2xl shadow-emerald-900/20 md:p-8">
<div className="grid gap-6 lg:grid-cols-[1.25fr_0.75fr] lg:items-center">
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.35em] text-emerald-200">Facilitated assessment flow</p>
<h2 className="max-w-3xl text-3xl font-black leading-tight md:text-5xl">Run the interview, capture every answer, and keep findings ready for trend analysis.</h2>
<p className="mt-4 max-w-2xl text-sm leading-6 text-emerald-50/80 md:text-base">This workspace keeps the fixed five-question script on pace, saves a concise participant summary, and extracts reusable finding tags for future cross-interview analysis.</p>
</div>
<div className="grid grid-cols-3 gap-3 rounded-3xl border border-white/10 bg-white/10 p-4 backdrop-blur">
<div className="rounded-2xl bg-white/10 p-4 text-center">
<p className="text-3xl font-black">{records.length}</p>
<p className="text-xs text-emerald-100">Saved</p>
</div>
<div className="rounded-2xl bg-white/10 p-4 text-center">
<p className="text-3xl font-black">{answeredCount}/5</p>
<p className="text-xs text-emerald-100">Current</p>
</div>
<div className="rounded-2xl bg-white/10 p-4 text-center">
<p className="text-3xl font-black">60</p>
<p className="text-xs text-emerald-100">Minutes</p>
</div>
</div>
</div>
</div>
{notice && <div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">{notice}</div>}
{error && <div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-100">{error}</div>}
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<CardBox className="border-0 shadow-xl shadow-slate-200/70 dark:shadow-none" cardBoxClassName="p-0">
<div className="border-b border-slate-100 p-6 dark:border-dark-700">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-emerald-600">Step 1</p>
<h3 className="text-2xl font-black text-slate-900 dark:text-white">Choose the session mode</h3>
<p className="mt-1 text-sm text-slate-500">Ask this first and keep the mode for the session unless the participant switches.</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<button className={`rounded-2xl border px-4 py-3 text-left transition ${mode === 'voice' ? 'border-emerald-500 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100' : 'border-slate-200 hover:border-emerald-300 dark:border-dark-700'}`} type="button" onClick={() => setMode('voice')}>
<span className="block text-sm font-bold">Voice or dictated</span>
<span className="text-xs text-slate-500">Concise prompts that read aloud well.</span>
</button>
<button className={`rounded-2xl border px-4 py-3 text-left transition ${mode === 'typed' ? 'border-indigo-500 bg-indigo-50 text-indigo-900 ring-2 ring-indigo-100' : 'border-slate-200 hover:border-indigo-300 dark:border-dark-700'}`} type="button" onClick={() => setMode('typed')}>
<span className="block text-sm font-bold">Typed responses</span>
<span className="text-xs text-slate-500">Clear written prompts for text capture.</span>
</button>
</div>
</div>
</div>
<div className="grid gap-6 p-6 lg:grid-cols-[0.85fr_1.15fr]">
<div className="space-y-4">
<div>
<label className={labelClass}>Participant label</label>
<input className={inputClass} value={participant.display_name} onChange={(event) => setParticipant({ ...participant, display_name: event.target.value })} placeholder="Optional, e.g. Participant A or HR manager" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<div>
<label className={labelClass}>Department</label>
<input className={inputClass} value={participant.department} onChange={(event) => setParticipant({ ...participant, department: event.target.value })} placeholder="Optional" />
</div>
<div>
<label className={labelClass}>Role / title</label>
<input className={inputClass} value={participant.job_title} onChange={(event) => setParticipant({ ...participant, job_title: event.target.value })} placeholder="Optional" />
</div>
</div>
<div className="rounded-3xl bg-slate-50 p-4 text-sm text-slate-600 dark:bg-dark-800 dark:text-slate-300">
<p className="font-bold text-slate-900 dark:text-white">Research objective</p>
<p className="mt-2 leading-6">Understand current practices, training gaps, and priority skill areas so training can be delivered in formats that support real-world application.</p>
</div>
</div>
<div className="rounded-[1.75rem] border border-slate-100 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-emerald-600">Question {activeQuestion + 1} of {QUESTIONS.length}</p>
<h4 className="text-lg font-black text-slate-900 dark:text-white">{SECTION_TITLES[activeQuestion]}</h4>
</div>
<div className="rounded-full bg-white px-3 py-1 text-xs font-bold text-slate-600 shadow-sm dark:bg-dark-900 dark:text-slate-300">{mode || 'mode?'}</div>
</div>
<div className="rounded-3xl bg-white p-5 shadow-sm dark:bg-dark-900">
<p className="mb-3 flex items-center text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">{mode === 'voice' ? <span className="mr-2">Voice prompt</span> : <span className="mr-2">Main question</span>}</p>
<p className="text-xl font-black leading-8 text-slate-950 dark:text-white">{QUESTIONS[activeQuestion]}</p>
{mode === 'voice' && <p className="mt-3 text-sm text-slate-500">Read this naturally, then capture the participant&apos;s spoken or dictated answer below.</p>}
</div>
<textarea className={`${inputClass} mt-4 min-h-[180px] resize-y`} value={answers[activeQuestion].answer} onChange={(event) => updateAnswer(event.target.value)} placeholder="Capture the participant response here. Add brief follow-up detail only when it clarifies training needs." />
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">
{QUESTIONS.map((question, index) => (
<button key={question} className={`h-9 w-9 rounded-full text-sm font-bold transition ${index === activeQuestion ? 'bg-slate-950 text-white dark:bg-white dark:text-slate-950' : answers[index].answer.trim() ? 'bg-emerald-100 text-emerald-800' : 'bg-white text-slate-500 dark:bg-dark-900'}`} type="button" onClick={() => setActiveQuestion(index)}>
{index + 1}
</button>
))}
</div>
<div className="flex gap-2">
<BaseButton label="Previous" color="whiteDark" disabled={activeQuestion === 0} onClick={() => setActiveQuestion((current) => Math.max(current - 1, 0))} />
<BaseButton label={activeQuestion === QUESTIONS.length - 1 ? 'Review' : 'Next'} color="info" onClick={() => setActiveQuestion((current) => Math.min(current + 1, QUESTIONS.length - 1))} />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-slate-100 p-6 dark:border-dark-700 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-slate-500">Save is enabled after a mode is selected and all five responses are captured.</p>
<div className="flex gap-2">
<BaseButton label="Reset" color="whiteDark" onClick={resetInterview} />
<BaseButton label={isSaving ? 'Saving...' : 'Save interview summary'} color="success" disabled={!canSave} onClick={saveInterview} />
</div>
</div>
</CardBox>
<div className="space-y-6">
<CardBox className="border-0 shadow-xl shadow-slate-200/70 dark:shadow-none">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-indigo-600">Cross-interview analysis</p>
<h3 className="text-2xl font-black text-slate-900 dark:text-white">Emerging themes</h3>
</div>
<BaseButton icon={mdiChartTimelineVariant} color="whiteDark" small onClick={fetchRecords} />
</div>
{analysis && analysis.interviewCount > 0 ? (
<div className="space-y-4">
<p className="text-sm text-slate-500">Based on {analysis.interviewCount} saved interview{analysis.interviewCount === 1 ? '' : 's'}.</p>
<div className="flex flex-wrap gap-2">
{analysis.themes.length ? analysis.themes.map((theme) => (
<span key={theme.theme} className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200">{theme.theme} × {theme.count}</span>
)) : <span className="text-sm text-slate-500">No repeated tags yet. Save more interviews to reveal patterns.</span>}
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(analysis.findingCounts).map(([finding, count]) => (
<div key={finding} className="rounded-2xl bg-slate-50 p-3 dark:bg-dark-800">
<p className="text-xl font-black text-slate-900 dark:text-white">{count}</p>
<p className="text-xs capitalize text-slate-500">{finding.replace(/_/g, ' ')}</p>
</div>
))}
</div>
</div>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-dark-700">No saved interviews yet. Complete the first interview to start building durable analysis.</div>
)}
</CardBox>
<CardBox className="border-0 shadow-xl shadow-slate-200/70 dark:shadow-none">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-emerald-600">Repository</p>
<h3 className="text-2xl font-black text-slate-900 dark:text-white">Saved interviews</h3>
</div>
{isLoading && <span className="text-xs text-slate-400">Loading...</span>}
</div>
{records.length ? (
<div className="space-y-3">
{records.map((record) => (
<button key={record.id} type="button" onClick={() => setSelectedRecord(record)} className={`w-full rounded-3xl border p-4 text-left transition ${selectedRecord?.id === record.id ? 'border-emerald-400 bg-emerald-50 dark:bg-emerald-950' : 'border-slate-100 bg-white hover:border-emerald-200 dark:border-dark-700 dark:bg-dark-900'}`}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-black text-slate-900 dark:text-white">{record.participant?.display_name || 'Unnamed participant'}</p>
<p className="text-sm text-slate-500">{record.participant?.department || 'No department'} · {record.mode || 'mode not set'}</p>
</div>
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-bold text-slate-500 dark:bg-dark-800">{record.status}</span>
</div>
</button>
))}
</div>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-dark-700">The repository is empty. Saved interviews will appear here with summaries and detail.</div>
)}
</CardBox>
</div>
</div>
{selectedRecord && (
<CardBox className="mt-6 border-0 shadow-xl shadow-slate-200/70 dark:shadow-none">
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<div>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-2xl bg-emerald-100 p-3 text-emerald-700"><BaseButton icon={mdiCheckCircleOutline} color="success" small /></div>
<div>
<p className="text-sm font-semibold text-emerald-600">Selected interview</p>
<h3 className="text-2xl font-black text-slate-900 dark:text-white">{selectedRecord.participant?.display_name || 'Unnamed participant'}</h3>
</div>
</div>
<pre className="whitespace-pre-wrap rounded-3xl bg-slate-950 p-5 text-sm leading-6 text-emerald-50">{selectedRecord.final_summary || 'No summary available.'}</pre>
</div>
<div>
<p className="mb-4 text-sm font-semibold text-slate-500">Captured responses</p>
<div className="space-y-3">
{selectedResponses.length ? selectedResponses.map((response, index) => (
<div key={response.id} className="rounded-3xl bg-slate-50 p-4 dark:bg-dark-800">
<p className="text-xs font-bold uppercase tracking-[0.22em] text-slate-400">Question {index + 1}</p>
<p className="mt-2 text-sm font-bold text-slate-900 dark:text-white">{QUESTIONS[index]}</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{responseOnly(response.response_text)}</p>
</div>
)) : <p className="text-sm text-slate-500">No responses were loaded for this interview.</p>}
</div>
</div>
</div>
</CardBox>
)}
</SectionMain>
</>
)
}
InterviewWorkspacePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default InterviewWorkspacePage

View File

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