Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd271763d9 | ||
|
|
5dfc25ec83 |
@ -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 }),
|
||||||
|
|||||||
264
backend/src/routes/interviewWorkflow.js
Normal file
264
backend/src/routes/interviewWorkflow.js
Normal 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;
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|||||||
369
frontend/src/pages/interview-workspace.tsx
Normal file
369
frontend/src/pages/interview-workspace.tsx
Normal 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'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
|
||||||
@ -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';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user