276 lines
9.3 KiB
Vue
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>
|