Files
go-no-go/resources/js/Pages/Session/Show.vue
2026-02-16 15:09:41 +01:00

276 lines
9.3 KiB
Vue

<script setup>
import { computed, reactive, ref, watch, nextTick } from 'vue'
import { Head, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue'
import QuestionCard from '@/Components/QuestionCard.vue'
import ScoreIndicator from '@/Components/ScoreIndicator.vue'
defineOptions({ layout: AppLayout })
const props = defineProps({
session: {
type: Object,
required: true,
},
questionGroups: {
type: Array,
default: () => [],
},
answers: {
type: Object,
default: () => ({}),
},
score: {
type: Number,
default: 0,
},
})
// Answer management
const answerData = reactive({})
// Initialize answers from existing data
const initializeAnswers = () => {
props.questionGroups.forEach(group => {
group.questions.forEach(question => {
const existing = props.answers[question.id]
answerData[question.id] = {
value: existing?.value ?? null,
text_value: existing?.text_value ?? '',
}
})
})
}
initializeAnswers()
// Validation state
const validationErrors = ref({})
const showErrors = ref(false)
const questionRefs = ref({})
// Save a single answer with partial reload including score
let saveTimeout = null
const saveAnswer = (questionId) => {
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
router.put(`/sessions/${props.session.id}`, {
answers: {
[questionId]: answerData[questionId],
},
}, {
preserveScroll: true,
preserveState: true,
only: ['answers', 'score'],
})
}, 500)
}
const updateAnswer = (questionId, newValue) => {
answerData[questionId] = newValue
saveAnswer(questionId)
}
// Additional comments
const additionalComments = useForm({
additional_comments: props.session.additional_comments ?? '',
})
let commentsTimeout = null
const saveComments = () => {
clearTimeout(commentsTimeout)
commentsTimeout = setTimeout(() => {
additionalComments.put(`/sessions/${props.session.id}`, {
preserveScroll: true,
preserveState: true,
})
}, 1000)
}
// Validation function
const validate = () => {
const errors = {}
props.questionGroups.forEach(group => {
group.questions.forEach(question => {
const answer = answerData[question.id]
const hasRadioButtons = question.has_yes || question.has_no || question.has_na
// Rule 1: Radio button questions must have a selection
if (hasRadioButtons && answer.value === null) {
errors[question.id] = 'Please select an answer'
return
}
// Rule 2: Required text fields based on details
if (question.details === 'required' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
if (question.details === 'req_on_yes' && answer.value === 'yes' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
if (question.details === 'req_on_no' && answer.value === 'no' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
// Rule 3: Text-only questions (no radio buttons, has required details)
if (!hasRadioButtons && question.details && question.details !== 'optional' && !answer.text_value?.trim()) {
errors[question.id] = 'Please enter a response'
return
}
})
})
validationErrors.value = errors
return Object.keys(errors).length === 0
}
// Watch answerData for changes and revalidate when errors are showing
watch(answerData, () => {
if (showErrors.value) {
validate()
}
}, { deep: true })
// Error count for summary banner
const errorCount = computed(() => {
return Object.values(validationErrors.value).filter(err => err !== null).length
})
// Session completion with validation
let completing = false
const completeSession = async () => {
showErrors.value = true
if (!validate()) {
// Scroll to first error
await nextTick()
const firstErrorQuestionId = Object.keys(validationErrors.value)[0]
const firstErrorElement = questionRefs.value[firstErrorQuestionId]
if (firstErrorElement) {
firstErrorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return
}
completing = true
clearTimeout(saveTimeout)
router.put(`/sessions/${props.session.id}`, {
answers: { ...answerData },
complete: true,
})
}
// Check if any scored answers have been given
const hasScoredAnswers = computed(() => {
return props.score > 0
})
</script>
<template>
<Head :title="`${session.category.name} Questionnaire`" />
<div class="max-w-3xl mx-auto px-4 py-10">
<!-- Title area -->
<div class="mb-10">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">{{ session.category.name }} Questionnaire</h1>
<ScoreIndicator :score="score" :visible="hasScoredAnswers" />
</div>
<div class="h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent mt-4"></div>
</div>
<div class="space-y-8">
<!-- User Info Section -->
<div class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8">
<h2 class="text-lg font-semibold text-white mb-5">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span class="block text-sm font-medium text-gray-400 mb-1">Name</span>
<span class="text-white text-[15px]">{{ session.user.name }}</span>
</div>
<div>
<span class="block text-sm font-medium text-gray-400 mb-1">Email</span>
<span class="text-white text-[15px]">{{ session.user.email }}</span>
</div>
</div>
</div>
<!-- Question Groups -->
<div
v-for="group in questionGroups"
:key="group.id"
class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8"
>
<div class="flex items-center gap-3 mb-1">
<div class="w-2 h-2 rounded-full bg-primary/60"></div>
<h2 class="text-lg font-semibold text-white">{{ group.name }}</h2>
</div>
<p v-if="group.description" class="text-gray-400 text-sm mb-2">{{ group.description }}</p>
<p v-if="group.scoring_instructions" class="text-amber-400 text-sm italic mb-4">{{ group.scoring_instructions }}</p>
<div class="divide-y divide-white/[0.06]">
<div
v-for="question in group.questions"
:key="question.id"
:ref="el => { if (el) questionRefs[question.id] = el }"
:data-question-id="question.id"
>
<QuestionCard
:question="question"
:modelValue="answerData[question.id]"
:error="showErrors ? validationErrors[question.id] : null"
@update:modelValue="updateAnswer(question.id, $event)"
/>
</div>
</div>
</div>
<!-- Additional Comments -->
<div class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8">
<h2 class="text-lg font-semibold text-white mb-5">Additional Comments</h2>
<textarea
v-model="additionalComments.additional_comments"
@input="saveComments"
rows="4"
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-gray-600 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-colors duration-200"
placeholder="Enter any additional comments to support your decision..."
></textarea>
</div>
</div>
<!-- Complete button with validation summary -->
<div class="mt-12 pt-8 border-t border-white/[0.06]">
<!-- Validation summary banner -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="showErrors && errorCount > 0" class="bg-red-500/10 border border-red-400/20 rounded-lg px-5 py-4 mb-6">
<p class="text-red-400 text-sm font-medium">
Please complete all required fields before submitting. {{ errorCount }} {{ errorCount === 1 ? 'question requires' : 'questions require' }} your attention.
</p>
</div>
</Transition>
<div class="flex justify-end">
<AppButton size="lg" @click="completeSession" data-cy="complete-session">
Complete
</AppButton>
</div>
</div>
</div>
</template>