Compare commits

..

9 Commits

Author SHA1 Message Date
78c51d55b5 adds help texts 2026-02-16 16:00:16 +01:00
514f1cb483 excel export works 2026-02-16 15:34:30 +01:00
77edd1b666 kind of done 2026-02-16 15:17:33 +01:00
fb1c28a0ba fixing bugs 2026-02-16 15:09:41 +01:00
c39b8085af adds validation 2026-02-16 13:41:25 +01:00
eb43b35873 fixes issues 2026-02-16 13:37:37 +01:00
f57bdd68da role check 2026-02-16 12:56:56 +01:00
e4b3689e64 login for testing 2026-02-16 12:49:10 +01:00
84355f2463 This is a root fix. 2026-02-16 12:44:35 +01:00
31 changed files with 786 additions and 134 deletions

View File

@@ -28,7 +28,9 @@
"mcp__playwright__browser_run_code",
"mcp__playwright__browser_wait_for",
"WebFetch(domain:www.bakertilly.nl)",
"mcp__playwright__browser_type"
"mcp__playwright__browser_type",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_evaluate"
]
}
}

View File

@@ -11,6 +11,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -109,6 +110,8 @@ private function saveAnswers(Session $session, array $answers): void
*/
private function completeSession(Session $session): RedirectResponse
{
$this->validateSessionCompletion($session);
$scoringService = new ScoringService;
$score = $scoringService->calculateScore($session);
$result = $scoringService->determineResult($score);
@@ -125,6 +128,76 @@ private function completeSession(Session $session): RedirectResponse
return redirect()->route('sessions.result', $session);
}
/**
* Validate that all required fields are answered before session completion.
*/
private function validateSessionCompletion(Session $session): void
{
$session->load(['category.questionGroups.questions', 'answers']);
$errors = [];
foreach ($session->category->questionGroups as $questionGroup) {
foreach ($questionGroup->questions as $question) {
$answer = $session->answers->firstWhere('question_id', $question->id);
$this->validateRadioAnswer($question, $answer, $errors);
$this->validateDetailsAnswer($question, $answer, $errors);
}
}
if (Arr::exists($errors, 0)) {
throw ValidationException::withMessages([
'complete' => $errors,
]);
}
}
/**
* Validate that radio button questions have an answer selected.
*/
private function validateRadioAnswer($question, $answer, array &$errors): void
{
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($hasRadioButtons && (! $answer || $answer->value === null)) {
$errors[] = "Question '{$question->text}' requires an answer.";
}
}
/**
* Validate that questions with required details have text values provided.
*/
private function validateDetailsAnswer($question, $answer, array &$errors): void
{
$details = $question->details;
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($details === 'required') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details to be provided.";
}
}
if ($details === 'req_on_yes' && $answer && $answer->value === 'yes') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'Yes'.";
}
}
if ($details === 'req_on_no' && $answer && $answer->value === 'no') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'No'.";
}
}
if (! $hasRadioButtons && $details !== null && $details !== '' && $details !== 'optional') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires a text response.";
}
}
}
/**
* Display the final session result.
*/

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Nova\Actions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel as BaseDownloadExcel;
// Fixes Nova 5 incompatibility where field names are PendingTranslation objects instead of strings.
final class DownloadExcel extends BaseDownloadExcel
{
protected $onlyIndexFields = false;
/**
* @param Model|mixed $row
*/
public function map($row): array
{
$only = array_map('strval', $this->getOnly());
$except = $this->getExcept();
if ($row instanceof Model) {
if (!$this->onlyIndexFields && $except === null && (!is_array($only) || count($only) === 0)) {
$except = $row->getHidden();
}
$row->setHidden([]);
$row = $this->replaceFieldValuesWhenOnResource($row, $only);
}
if (is_array($only) && count($only) > 0) {
$row = Arr::only($row, $only);
}
if (is_array($except) && count($except) > 0) {
$row = Arr::except($row, $except);
}
return $row;
}
protected function replaceFieldValuesWhenOnResource(Model $model, array $only = []): array
{
$resource = $this->resolveResource($model);
$fields = $this->resourceFields($resource);
$row = [];
foreach ($fields as $field) {
if (!$this->isExportableField($field)) {
continue;
}
if (\in_array($field->attribute, $only, true)) {
$row[$field->attribute] = $field->value;
} elseif (\in_array((string) $field->name, $only, true)) {
$row[(string) $field->name] = $field->value;
}
}
foreach (array_diff($only, array_keys($row)) as $attribute) {
if ($model->{$attribute}) {
$row[$attribute] = $model->{$attribute};
} else {
$row[$attribute] = '';
}
}
$row = array_merge(array_flip($only), $row);
return $row;
}
}

View File

@@ -10,10 +10,11 @@
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class AnswerResource extends Resource
{
/**
* The model the resource corresponds to.
*
@@ -78,32 +79,38 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Session', 'session', SessionResource::class)
->sortable()
->filterable()
->rules('required'),
->readonly()
->help('The questionnaire session this answer belongs to.'),
BelongsTo::make('Question', 'question', QuestionResource::class)
->sortable()
->filterable()
->rules('required'),
->readonly()
->help('The question that was answered.'),
Text::make('Value')
->sortable()
->filterable()
->copyable()
->rules('nullable', 'max:255'),
->readonly()
->help('The selected answer: "yes", "no", or "not_applicable". Empty for open text questions.'),
Textarea::make('Text Value')
->alwaysShow()
->rules('nullable'),
->readonly()
->help('Any written details or free text the user provided for this question.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
->filterable()
->help('When this answer was first saved.'),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
->filterable()
->help('When this answer was last changed.'),
];
}

View File

@@ -10,7 +10,7 @@
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class CategoryResource extends Resource
{
@@ -40,7 +40,7 @@ final class CategoryResource extends Resource
*
* @var bool
*/
public static $displayInNavigation = false;
public static $displayInNavigation = true;
/**
* Get the displayable label of the resource.
@@ -72,24 +72,16 @@ public function fields(NovaRequest $request): array
->sortable()
->filterable()
->copyable()
->help('The name of this assessment category, such as Audit, Tax, or Legal.')
->rules('required', 'max:255'),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->help('Controls the display order of categories. Lower numbers appear first.')
->rules('required', 'integer'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
HasMany::make('Question Groups', 'questionGroups', QuestionGroupResource::class),
HasMany::make('Sessions', 'sessions', SessionResource::class),

View File

@@ -1,11 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Nova\Dashboards;
use Laravel\Nova\Cards\Help;
use App\Nova\Metrics\ScreeningsTrend;
use App\Nova\Metrics\SessionsTrend;
use App\Nova\Metrics\TotalScreenings;
use App\Nova\Metrics\TotalSessions;
use Laravel\Nova\Dashboards\Main as Dashboard;
class Main extends Dashboard
final class Main extends Dashboard
{
/**
* Get the cards for the dashboard.
@@ -15,7 +20,10 @@ class Main extends Dashboard
public function cards(): array
{
return [
new Help,
new TotalSessions,
new TotalScreenings,
new SessionsTrend,
new ScreeningsTrend,
];
}
}

View File

@@ -10,7 +10,7 @@
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class LogResource extends Resource
{
@@ -86,34 +86,40 @@ public function fields(NovaRequest $request): array
->nullable()
->sortable()
->filterable()
->rules('nullable'),
->rules('nullable')
->help('The user who performed this action. May be empty for system events.'),
BelongsTo::make('Session', 'session', SessionResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
->rules('nullable')
->help('The questionnaire session related to this action, if any.'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
->rules('nullable')
->help('The assessment category related to this action, if any.'),
Text::make('Action')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
->rules('required', 'max:255')
->help('What happened, e.g. "login", "session_started", "answer_saved", "screening_completed".'),
Code::make('Metadata')
->json()
->rules('nullable'),
->rules('nullable')
->help('Additional details about this action in a structured format.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
->filterable()
->help('When this action occurred.'),
];
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Nova\Metrics;
use App\Models\Screening;
use Laravel\Nova\Http\Requests\NovaRequest;
use Laravel\Nova\Metrics\Trend;
final class ScreeningsTrend extends Trend
{
public ?int $cacheFor = null;
public function calculate(NovaRequest $request): mixed
{
return $this->countByDays($request, Screening::class);
}
public function name(): string
{
return 'Screenings';
}
public function ranges(): array
{
return [
30 => '30 Days',
60 => '60 Days',
90 => '90 Days',
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Nova\Metrics;
use App\Models\Session;
use Laravel\Nova\Http\Requests\NovaRequest;
use Laravel\Nova\Metrics\Trend;
final class SessionsTrend extends Trend
{
public function calculate(NovaRequest $request): mixed
{
return $this->countByDays($request, Session::class);
}
public function name(): string
{
return 'Sessions';
}
public function ranges(): array
{
return [
30 => '30 Days',
60 => '60 Days',
90 => '90 Days',
];
}
public $cacheFor = null;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Nova\Metrics;
use App\Models\Screening;
use Laravel\Nova\Http\Requests\NovaRequest;
use Laravel\Nova\Metrics\Value;
final class TotalScreenings extends Value
{
/**
* Calculate the value of the metric.
*/
public function calculate(NovaRequest $request): mixed
{
return $this->count($request, Screening::class);
}
/**
* Get the displayable name of the metric.
*/
public function name(): string
{
return 'Total Screenings';
}
/**
* Get the ranges available for the metric.
*/
public function ranges(): array
{
return [
30 => '30 Days',
60 => '60 Days',
365 => '365 Days',
'TODAY' => 'Today',
'ALL' => 'All Time',
];
}
/**
* Determine the amount of time the results should be cached.
*/
public $cacheFor = null;
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Nova\Metrics;
use App\Models\Session;
use Laravel\Nova\Http\Requests\NovaRequest;
use Laravel\Nova\Metrics\Value;
final class TotalSessions extends Value
{
public function calculate(NovaRequest $request): mixed
{
return $this->count($request, Session::class);
}
public function name(): string
{
return 'Total Sessions';
}
public function ranges(): array
{
return [
30 => '30 Days',
60 => '60 Days',
365 => '365 Days',
'TODAY' => 'Today',
'ALL' => 'All Time',
];
}
public $cacheFor = null;
}

View File

@@ -12,7 +12,7 @@
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class QuestionGroupResource extends Resource
{
@@ -42,7 +42,7 @@ final class QuestionGroupResource extends Resource
*
* @var bool
*/
public static $displayInNavigation = false;
public static $displayInNavigation = true;
/**
* Get the displayable label of the resource.
@@ -73,35 +73,30 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable()
->filterable()
->rules('required'),
->rules('required')
->help('The assessment category this group of questions belongs to, such as Audit or Tax.'),
Text::make('Name')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
->rules('required', 'max:255')
->help('The title of this question group, shown as a section heading in the questionnaire.'),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer'),
->rules('required', 'integer')
->help('Controls the display order within the category. Lower numbers appear first.'),
Textarea::make('Description')
->rules('nullable'),
->rules('nullable')
->help('An optional description shown to users at the top of this question group.'),
Textarea::make('Scoring Instructions')
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
->rules('nullable')
->help('Optional instructions shown to users explaining how this section is scored, e.g. "If you answer yes, you will score 1 point."'),
HasMany::make('Questions', 'questions', QuestionResource::class),
];

View File

@@ -4,16 +4,18 @@
namespace App\Nova;
use Illuminate\Support\Str;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class QuestionResource extends Resource
{
@@ -78,56 +80,60 @@ public function fields(NovaRequest $request): array
return [
ID::make()->sortable(),
Text::make('Question', 'text')
->displayUsing(fn ($value) => Str::limit($value, 40))
->onlyOnIndex()
->sortable(),
BelongsTo::make('Question Group', 'questionGroup', QuestionGroupResource::class)
->sortable()
->filterable()
->readonly(),
->help('The group this question belongs to. Questions are shown together by group in the questionnaire.'),
Textarea::make('Text')
->rules('required')
->updateRules('required'),
->updateRules('required')
->help('The full question text shown to the user in the questionnaire.'),
Boolean::make('Has Yes')
->sortable()
->filterable()
->readonly(),
->help('When enabled, a "Yes" answer option is shown for this question.'),
Boolean::make('Has No')
->sortable()
->filterable()
->readonly(),
->help('When enabled, a "No" answer option is shown for this question.'),
Boolean::make('Has NA', 'has_na')
->sortable()
->filterable()
->readonly(),
->help('When enabled, a "Not Applicable" answer option is shown for this question.'),
Text::make('Details')
Select::make('Details')
->options([
'optional' => 'Optional',
'required' => 'Required',
'req_on_yes' => 'Required on Yes',
'req_on_no' => 'Required on No',
])
->displayUsingLabels()
->nullable()
->sortable()
->filterable()
->copyable()
->readonly(),
->help('Controls when the user is asked for additional details. "Required" always asks, "Optional" lets the user choose, "Required on Yes/No" only asks when that answer is selected.'),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->readonly(),
->help('Controls the display order within the question group. Lower numbers appear first.'),
Boolean::make('Is Scored')
->sortable()
->filterable()
->readonly(),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
->help('When enabled, this question counts toward the total score. A "Yes" answer scores 1 point.'),
HasMany::make('Answers', 'answers', AnswerResource::class),
];

View File

@@ -9,6 +9,17 @@
abstract class Resource extends NovaResource
{
public static function perPageOptions()
{
return [50, 100, 150];
}
public static function perPageViaRelationshipOptions()
{
return [10, 25, 50];
}
/**
* Build an "index" query for the given resource.
*/

View File

@@ -11,7 +11,7 @@
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class ScreeningResource extends Resource
{
@@ -86,28 +86,27 @@ public function fields(NovaRequest $request): array
BelongsTo::make('User', 'user', User::class)
->sortable()
->filterable()
->rules('required'),
->rules('required')
->help('The person who completed this pre-screening.'),
Number::make('Score')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer'),
->rules('required', 'integer')
->help('The number of "Yes" answers out of 10 pre-screening questions.'),
Boolean::make('Passed')
->sortable()
->filterable()
->rules('required', 'boolean'),
->rules('required', 'boolean')
->help('Whether the user scored 5 or more points and was allowed to continue to the full questionnaire.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
->filterable()
->help('When this pre-screening was started.'),
HasMany::make('Sessions', 'sessions', SessionResource::class),
];

View File

@@ -12,7 +12,7 @@
use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
use App\Nova\Actions\DownloadExcel;
final class SessionResource extends Resource
{
@@ -87,18 +87,21 @@ public function fields(NovaRequest $request): array
BelongsTo::make('User', 'user', User::class)
->sortable()
->filterable()
->rules('required'),
->rules('required')
->help('The person who started this questionnaire session.'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable()
->filterable()
->rules('required'),
->rules('required')
->help('The assessment category for this session, such as Audit or Tax.'),
BelongsTo::make('Screening', 'screening', ScreeningResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
->rules('nullable')
->help('The pre-screening that was completed before starting this session.'),
Select::make('Status')
->options([
@@ -109,13 +112,14 @@ public function fields(NovaRequest $request): array
->displayUsingLabels()
->sortable()
->filterable()
->readonly(),
->help('The current state of this session. "In Progress" means the user has not yet submitted, "Completed" means submitted, "Abandoned" means the user left without finishing.'),
Number::make('Score')
->sortable()
->filterable()
->copyable()
->rules('nullable', 'integer'),
->rules('nullable', 'integer')
->help('The total score from all scored questions. Only "Yes" answers count as points.'),
Select::make('Result')
->options([
@@ -126,25 +130,17 @@ public function fields(NovaRequest $request): array
->displayUsingLabels()
->sortable()
->filterable()
->readonly(),
->help('The final outcome based on the score. "Go" (10+ points) means pursue the opportunity, "Consult Leadership" (5-9 points) means seek advice, "No Go" (1-4 points) means do not pursue.'),
Textarea::make('Additional Comments')
->rules('nullable'),
->rules('nullable')
->help('Any extra notes the user added at the end of the questionnaire.'),
DateTime::make('Completed At')
->sortable()
->filterable()
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
->rules('nullable')
->help('The date and time when the user submitted this session.'),
HasMany::make('Answers', 'answers', AnswerResource::class),

View File

@@ -51,47 +51,53 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Role', 'role', RoleResource::class)
->sortable()
->filterable(),
->filterable()
->help('The user\'s role, which controls what they can access in the admin panel.'),
Text::make('Name')
->sortable()
->rules('required', 'max:255'),
->rules('required', 'max:255')
->help('The user\'s full name, imported from Azure AD when they first log in.'),
Text::make('Email')
->sortable()
->rules('required', 'email', 'max:254')
->creationRules('unique:users,email')
->updateRules('unique:users,email,{{resourceId}}'),
->updateRules('unique:users,email,{{resourceId}}')
->help('The user\'s email address, used to identify them when logging in via Azure AD.'),
Text::make('Azure ID', 'azure_id')
->onlyOnDetail()
->copyable(),
->copyable()
->help('A unique identifier from Azure AD. Set automatically when the user logs in.'),
Text::make('Photo', 'photo')
->onlyOnDetail()
->copyable(),
->copyable()
->help('A link to the user\'s profile photo from Azure AD.'),
Text::make('Job Title', 'job_title')
->sortable()
->filterable()
->copyable()
->readonly(),
->help('The user\'s job title, imported from Azure AD.'),
Text::make('Department')
->sortable()
->filterable()
->copyable()
->readonly(),
->help('The department the user belongs to, imported from Azure AD.'),
Text::make('Phone')
->sortable()
->copyable()
->readonly(),
->help('The user\'s phone number, imported from Azure AD.'),
Password::make('Password')
->onlyOnForms()
->creationRules($this->passwordRules())
->updateRules($this->optionalPasswordRules()),
->updateRules($this->optionalPasswordRules())
->help('Only needed for admin panel access. Regular users log in via Azure AD and do not need a password.'),
];
}

View File

@@ -30,7 +30,7 @@ public function view(User $user, Category $category): bool
*/
public function create(User $user): bool
{
return false;
return true;
}
/**
@@ -38,7 +38,7 @@ public function create(User $user): bool
*/
public function update(User $user, Category $category): bool
{
return false;
return true;
}
/**

View File

@@ -30,7 +30,7 @@ public function view(User $user, QuestionGroup $questionGroup): bool
*/
public function create(User $user): bool
{
return false;
return true;
}
/**
@@ -38,7 +38,7 @@ public function create(User $user): bool
*/
public function update(User $user, QuestionGroup $questionGroup): bool
{
return false;
return true;
}
/**

View File

@@ -30,7 +30,7 @@ public function view(User $user, Question $question): bool
*/
public function create(User $user): bool
{
return false;
return true;
}
/**

View File

@@ -3,8 +3,10 @@
namespace App\Providers;
use App\Models\User;
use App\Nova\CategoryResource;
use App\Nova\Dashboards\Main;
use App\Nova\LogResource;
use App\Nova\QuestionGroupResource;
use App\Nova\QuestionResource;
use App\Nova\ScreeningResource;
use App\Nova\SessionResource;
@@ -31,8 +33,10 @@ public function boot(): void
MenuSection::make('Questionnaire', [
MenuItem::resource(QuestionResource::class),
MenuItem::resource(ScreeningResource::class),
MenuItem::resource(QuestionGroupResource::class),
MenuItem::resource(CategoryResource::class),
MenuItem::resource(SessionResource::class),
MenuItem::resource(ScreeningResource::class),
])->icon('clipboard-document-list')->collapsible(),
MenuSection::make('Logs', [
@@ -66,7 +70,7 @@ protected function fortify(): void
protected function routes(): void
{
Nova::routes()
->withAuthenticationRoutes(default: true)
->withAuthenticationRoutes(default: false)
->withPasswordResetRoutes()
->withEmailVerificationRoutes()
->register();
@@ -80,9 +84,7 @@ protected function routes(): void
protected function gate(): void
{
Gate::define('viewNova', function (User $user) {
return in_array($user->email, [
'jonathan@blijnder.nl',
]);
return $user->role?->name === 'admin';
});
}

159
config/fortify.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/home',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => false,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
// Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
];

View File

@@ -19,7 +19,7 @@ public function run(): void
User::factory()->create([
'name' => 'Jonathan',
'email' => 'jonathan@blijnder.nl',
'email' => 'jonathan.van.rij@agerion.nl',
'password' => bcrypt('secret'),
'email_verified_at' => now(),
'role_id' => $adminRole->id,

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
final class TestCategorySeeder extends Seeder
{
/**
* Seed a minimal test category with one question group and five scored questions for quick testing.
*/
public function run(): void
{
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Test',
'sort_order' => 99,
'created_at' => now(),
'updated_at' => now(),
]);
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Test Group',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$questions = [];
for ($i = 1; $i <= 5; $i++) {
$questions[] = [
'question_group_id' => $groupId,
'text' => "Test question {$i}",
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => $i,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
];
}
DB::table('questions')->insert($questions);
}
}

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: Object,
default: () => ({ value: null, text_value: '' }),
},
error: {
type: String,
default: null,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -58,7 +62,10 @@ const updateTextValue = (event) => {
</script>
<template>
<div class="py-5 first:pt-0">
<div
class="py-5 first:pt-0 transition-all duration-200"
:class="{ 'border-l-2 border-red-400/60 pl-4 -ml-4': error }"
>
<p class="text-white font-medium leading-relaxed mb-4">{{ question.text }}</p>
<!-- Text-only question (no radio buttons) -->
@@ -105,5 +112,19 @@ const updateTextValue = (event) => {
</div>
</Transition>
</div>
<!-- Error message -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
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-1"
>
<p v-if="error" class="text-red-400 text-sm mt-2 bg-red-500/10 px-3 py-2 rounded-md">
{{ error }}
</p>
</Transition>
</div>
</template>

View File

@@ -43,6 +43,8 @@ const getSegmentClasses = (index) => {
'cursor-pointer',
'hover:bg-white/10',
'hover:text-gray-200',
'peer-checked:hover:bg-primary-dark',
'peer-checked:hover:text-gray-900',
'peer-checked:bg-primary',
'peer-checked:text-gray-900',
'peer-checked:font-semibold',

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive } from 'vue'
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'
@@ -44,6 +44,11 @@ const initializeAnswers = () => {
}
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) => {
@@ -82,11 +87,83 @@ const saveComments = () => {
}, 1000)
}
// Session completion
// 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 = () => {
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,
})
}
@@ -141,13 +218,19 @@ const hasScoredAnswers = computed(() => {
<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]">
<QuestionCard
<div
v-for="question in group.questions"
:key="question.id"
:question="question"
:modelValue="answerData[question.id]"
@update:modelValue="updateAnswer(question.id, $event)"
/>
: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>
@@ -164,8 +247,24 @@ const hasScoredAnswers = computed(() => {
</div>
</div>
<!-- Complete button - now enabled -->
<!-- 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

View File

@@ -35,12 +35,7 @@
// Dev auto-login route
Route::get('/login-for-testing', function () {
$user = \App\Models\User::where('email', 'jonathan@blijnder.nl')->first();
if (! $user) {
\Illuminate\Support\Facades\Artisan::call('db:seed', ['--class' => 'Database\\Seeders\\JonathanSeeder']);
$user = \App\Models\User::where('email', 'jonathan@blijnder.nl')->first();
}
$user = \App\Models\User::where('email', 'jonathan.van.rij@agerion.nl')->first();
auth()->login($user);

BIN
selected-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
validation-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
validation-summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB