Compare commits

..

11 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
e4259978de Shows data and adds some tests for the OAuth check 2026-02-16 12:24:50 +01:00
9a10ff4727 adds the role and I'll go ahead and link and socialite 2026-02-16 12:16:53 +01:00
42 changed files with 995 additions and 153 deletions

View File

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

View File

@@ -38,6 +38,7 @@ public function callback(): RedirectResponse
'photo' => $azureUser->getAvatar(), 'photo' => $azureUser->getAvatar(),
'job_title' => Arr::get($azureUser->user, 'jobTitle'), 'job_title' => Arr::get($azureUser->user, 'jobTitle'),
'department' => Arr::get($azureUser->user, 'department'), 'department' => Arr::get($azureUser->user, 'department'),
'company_name' => Arr::get($azureUser->user, 'companyName'),
'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')), 'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')),
] ]
); );

View File

@@ -11,6 +11,7 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -109,6 +110,8 @@ private function saveAnswers(Session $session, array $answers): void
*/ */
private function completeSession(Session $session): RedirectResponse private function completeSession(Session $session): RedirectResponse
{ {
$this->validateSessionCompletion($session);
$scoringService = new ScoringService; $scoringService = new ScoringService;
$score = $scoringService->calculateScore($session); $score = $scoringService->calculateScore($session);
$result = $scoringService->determineResult($score); $result = $scoringService->determineResult($score);
@@ -125,6 +128,76 @@ private function completeSession(Session $session): RedirectResponse
return redirect()->route('sessions.result', $session); 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. * Display the final session result.
*/ */

View File

@@ -58,6 +58,8 @@ private function getAuthenticatedUser(): ?array
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'job_title' => $user->job_title,
'company_name' => $user->company_name,
]; ];
} }

View File

@@ -28,6 +28,7 @@ final class User extends Authenticatable
'photo', 'photo',
'job_title', 'job_title',
'department', 'department',
'company_name',
'phone', 'phone',
'role_id', 'role_id',
]; ];

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\Text;
use Laravel\Nova\Fields\Textarea; use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel; use App\Nova\Actions\DownloadExcel;
final class AnswerResource extends Resource final class AnswerResource extends Resource
{ {
/** /**
* The model the resource corresponds to. * The model the resource corresponds to.
* *
@@ -78,32 +79,38 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Session', 'session', SessionResource::class) BelongsTo::make('Session', 'session', SessionResource::class)
->sortable() ->sortable()
->filterable() ->filterable()
->rules('required'), ->readonly()
->help('The questionnaire session this answer belongs to.'),
BelongsTo::make('Question', 'question', QuestionResource::class) BelongsTo::make('Question', 'question', QuestionResource::class)
->sortable() ->sortable()
->filterable() ->filterable()
->rules('required'), ->readonly()
->help('The question that was answered.'),
Text::make('Value') Text::make('Value')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->rules('nullable', 'max:255'), ->readonly()
->help('The selected answer: "yes", "no", or "not_applicable". Empty for open text questions.'),
Textarea::make('Text Value') Textarea::make('Text Value')
->alwaysShow() ->alwaysShow()
->rules('nullable'), ->readonly()
->help('Any written details or free text the user provided for this question.'),
DateTime::make('Created At') DateTime::make('Created At')
->exceptOnForms() ->exceptOnForms()
->sortable() ->sortable()
->filterable(), ->filterable()
->help('When this answer was first saved.'),
DateTime::make('Updated At') DateTime::make('Updated At')
->exceptOnForms() ->exceptOnForms()
->sortable() ->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\Number;
use Laravel\Nova\Fields\Text; use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel; use App\Nova\Actions\DownloadExcel;
final class CategoryResource extends Resource final class CategoryResource extends Resource
{ {
@@ -40,7 +40,7 @@ final class CategoryResource extends Resource
* *
* @var bool * @var bool
*/ */
public static $displayInNavigation = false; public static $displayInNavigation = true;
/** /**
* Get the displayable label of the resource. * Get the displayable label of the resource.
@@ -72,24 +72,16 @@ public function fields(NovaRequest $request): array
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->help('The name of this assessment category, such as Audit, Tax, or Legal.')
->rules('required', 'max:255'), ->rules('required', 'max:255'),
Number::make('Sort Order') Number::make('Sort Order')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->help('Controls the display order of categories. Lower numbers appear first.')
->rules('required', 'integer'), ->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('Question Groups', 'questionGroups', QuestionGroupResource::class),
HasMany::make('Sessions', 'sessions', SessionResource::class), HasMany::make('Sessions', 'sessions', SessionResource::class),

View File

@@ -1,11 +1,16 @@
<?php <?php
declare(strict_types=1);
namespace App\Nova\Dashboards; 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; use Laravel\Nova\Dashboards\Main as Dashboard;
class Main extends Dashboard final class Main extends Dashboard
{ {
/** /**
* Get the cards for the dashboard. * Get the cards for the dashboard.
@@ -15,7 +20,10 @@ class Main extends Dashboard
public function cards(): array public function cards(): array
{ {
return [ 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\ID;
use Laravel\Nova\Fields\Text; use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel; use App\Nova\Actions\DownloadExcel;
final class LogResource extends Resource final class LogResource extends Resource
{ {
@@ -86,34 +86,40 @@ public function fields(NovaRequest $request): array
->nullable() ->nullable()
->sortable() ->sortable()
->filterable() ->filterable()
->rules('nullable'), ->rules('nullable')
->help('The user who performed this action. May be empty for system events.'),
BelongsTo::make('Session', 'session', SessionResource::class) BelongsTo::make('Session', 'session', SessionResource::class)
->nullable() ->nullable()
->sortable() ->sortable()
->filterable() ->filterable()
->rules('nullable'), ->rules('nullable')
->help('The questionnaire session related to this action, if any.'),
BelongsTo::make('Category', 'category', CategoryResource::class) BelongsTo::make('Category', 'category', CategoryResource::class)
->nullable() ->nullable()
->sortable() ->sortable()
->filterable() ->filterable()
->rules('nullable'), ->rules('nullable')
->help('The assessment category related to this action, if any.'),
Text::make('Action') Text::make('Action')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->rules('required', 'max:255'), ->rules('required', 'max:255')
->help('What happened, e.g. "login", "session_started", "answer_saved", "screening_completed".'),
Code::make('Metadata') Code::make('Metadata')
->json() ->json()
->rules('nullable'), ->rules('nullable')
->help('Additional details about this action in a structured format.'),
DateTime::make('Created At') DateTime::make('Created At')
->exceptOnForms() ->exceptOnForms()
->sortable() ->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\Text;
use Laravel\Nova\Fields\Textarea; use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel; use App\Nova\Actions\DownloadExcel;
final class QuestionGroupResource extends Resource final class QuestionGroupResource extends Resource
{ {
@@ -42,7 +42,7 @@ final class QuestionGroupResource extends Resource
* *
* @var bool * @var bool
*/ */
public static $displayInNavigation = false; public static $displayInNavigation = true;
/** /**
* Get the displayable label of the resource. * Get the displayable label of the resource.
@@ -73,35 +73,30 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Category', 'category', CategoryResource::class) BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable() ->sortable()
->filterable() ->filterable()
->rules('required'), ->rules('required')
->help('The assessment category this group of questions belongs to, such as Audit or Tax.'),
Text::make('Name') Text::make('Name')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->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') Number::make('Sort Order')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->rules('required', 'integer'), ->rules('required', 'integer')
->help('Controls the display order within the category. Lower numbers appear first.'),
Textarea::make('Description') 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') Textarea::make('Scoring Instructions')
->rules('nullable'), ->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."'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
HasMany::make('Questions', 'questions', QuestionResource::class), HasMany::make('Questions', 'questions', QuestionResource::class),
]; ];

View File

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

View File

@@ -9,6 +9,17 @@
abstract class Resource extends NovaResource 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. * Build an "index" query for the given resource.
*/ */

View File

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

View File

@@ -12,7 +12,7 @@
use Laravel\Nova\Fields\Select; use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Textarea; use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel; use App\Nova\Actions\DownloadExcel;
final class SessionResource extends Resource final class SessionResource extends Resource
{ {
@@ -87,18 +87,21 @@ public function fields(NovaRequest $request): array
BelongsTo::make('User', 'user', User::class) BelongsTo::make('User', 'user', User::class)
->sortable() ->sortable()
->filterable() ->filterable()
->rules('required'), ->rules('required')
->help('The person who started this questionnaire session.'),
BelongsTo::make('Category', 'category', CategoryResource::class) BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable() ->sortable()
->filterable() ->filterable()
->rules('required'), ->rules('required')
->help('The assessment category for this session, such as Audit or Tax.'),
BelongsTo::make('Screening', 'screening', ScreeningResource::class) BelongsTo::make('Screening', 'screening', ScreeningResource::class)
->nullable() ->nullable()
->sortable() ->sortable()
->filterable() ->filterable()
->rules('nullable'), ->rules('nullable')
->help('The pre-screening that was completed before starting this session.'),
Select::make('Status') Select::make('Status')
->options([ ->options([
@@ -109,13 +112,14 @@ public function fields(NovaRequest $request): array
->displayUsingLabels() ->displayUsingLabels()
->sortable() ->sortable()
->filterable() ->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') Number::make('Score')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->rules('nullable', 'integer'), ->rules('nullable', 'integer')
->help('The total score from all scored questions. Only "Yes" answers count as points.'),
Select::make('Result') Select::make('Result')
->options([ ->options([
@@ -126,25 +130,17 @@ public function fields(NovaRequest $request): array
->displayUsingLabels() ->displayUsingLabels()
->sortable() ->sortable()
->filterable() ->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') 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') DateTime::make('Completed At')
->sortable() ->sortable()
->filterable() ->filterable()
->rules('nullable'), ->rules('nullable')
->help('The date and time when the user submitted this session.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
HasMany::make('Answers', 'answers', AnswerResource::class), HasMany::make('Answers', 'answers', AnswerResource::class),

View File

@@ -51,47 +51,53 @@ public function fields(NovaRequest $request): array
BelongsTo::make('Role', 'role', RoleResource::class) BelongsTo::make('Role', 'role', RoleResource::class)
->sortable() ->sortable()
->filterable(), ->filterable()
->help('The user\'s role, which controls what they can access in the admin panel.'),
Text::make('Name') Text::make('Name')
->sortable() ->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') Text::make('Email')
->sortable() ->sortable()
->rules('required', 'email', 'max:254') ->rules('required', 'email', 'max:254')
->creationRules('unique:users,email') ->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') Text::make('Azure ID', 'azure_id')
->onlyOnDetail() ->onlyOnDetail()
->copyable(), ->copyable()
->help('A unique identifier from Azure AD. Set automatically when the user logs in.'),
Text::make('Photo', 'photo') Text::make('Photo', 'photo')
->onlyOnDetail() ->onlyOnDetail()
->copyable(), ->copyable()
->help('A link to the user\'s profile photo from Azure AD.'),
Text::make('Job Title', 'job_title') Text::make('Job Title', 'job_title')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->readonly(), ->help('The user\'s job title, imported from Azure AD.'),
Text::make('Department') Text::make('Department')
->sortable() ->sortable()
->filterable() ->filterable()
->copyable() ->copyable()
->readonly(), ->help('The department the user belongs to, imported from Azure AD.'),
Text::make('Phone') Text::make('Phone')
->sortable() ->sortable()
->copyable() ->copyable()
->readonly(), ->help('The user\'s phone number, imported from Azure AD.'),
Password::make('Password') Password::make('Password')
->onlyOnForms() ->onlyOnForms()
->creationRules($this->passwordRules()) ->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 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 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 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 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 public function create(User $user): bool
{ {
return false; return true;
} }
/** /**

View File

@@ -1,10 +1,15 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider final class AppServiceProvider extends ServiceProvider
{ {
/** /**
* Register any application services. * Register any application services.
@@ -16,9 +21,10 @@ public function register(): void
/** /**
* Bootstrap any application services. * Bootstrap any application services.
* Registers the Microsoft Azure Socialite provider for SSO authentication.
*/ */
public function boot(): void public function boot(): void
{ {
// Event::listen(SocialiteWasCalled::class, AzureExtendSocialite::class.'@handle');
} }
} }

View File

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

View File

@@ -6,13 +6,14 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.4",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/nova": "^5.0", "laravel/nova": "^5.0",
"laravel/socialite": "^5.24", "laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"maatwebsite/laravel-nova-excel": "^1.3" "maatwebsite/laravel-nova-excel": "^1.3",
"socialiteproviders/microsoft-azure": "^5.2"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

129
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c5908b1cf6b95103d6009afd8de09581", "content-hash": "535a9303784c8d25d1d3b32702506cc9",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -5182,6 +5182,131 @@
], ],
"time": "2025-02-18T12:50:31+00:00" "time": "2025-02-18T12:50:31+00:00"
}, },
{
"name": "socialiteproviders/manager",
"version": "v4.8.1",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
"laravel/socialite": "^5.5",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.2",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SocialiteProviders\\Manager\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SocialiteProviders\\Manager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andy Wendt",
"email": "andy@awendt.com"
},
{
"name": "Anton Komarev",
"email": "a.komarev@cybercog.su"
},
{
"name": "Miguel Piedrafita",
"email": "soy@miguelpiedrafita.com"
},
{
"name": "atymic",
"email": "atymicq@gmail.com",
"homepage": "https://atymic.dev"
}
],
"description": "Easily add new or override built-in providers in Laravel Socialite.",
"homepage": "https://socialiteproviders.com",
"keywords": [
"laravel",
"manager",
"oauth",
"providers",
"socialite"
],
"support": {
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
"time": "2025-02-24T19:33:30+00:00"
},
{
"name": "socialiteproviders/microsoft-azure",
"version": "5.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Microsoft-Azure.git",
"reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/453d62c9d7e3b3b76e94c913fb46e68a33347b16",
"reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"socialiteproviders/manager": "^4.4"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Azure\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Hemmings",
"email": "chris@hemmin.gs"
}
],
"description": "Microsoft Azure OAuth2 Provider for Laravel Socialite",
"keywords": [
"azure",
"laravel",
"microsoft",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/microsoft-azure",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2024-03-15T03:02:10+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v8.0.0", "version": "v8.0.0",
@@ -10804,7 +10929,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"

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

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Database\Factories; namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@@ -9,7 +11,7 @@
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/ */
class UserFactory extends Factory final class UserFactory extends Factory
{ {
/** /**
* The current password being used by the factory. * The current password being used by the factory.
@@ -27,8 +29,10 @@ public function definition(): array
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => self::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'job_title' => fake()->jobTitle(),
'company_name' => fake()->company(),
]; ];
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@@ -20,6 +22,7 @@ public function up(): void
$table->string('photo')->nullable(); $table->string('photo')->nullable();
$table->string('job_title')->nullable(); $table->string('job_title')->nullable();
$table->string('department')->nullable(); $table->string('department')->nullable();
$table->string('company_name')->nullable();
$table->string('phone')->nullable(); $table->string('phone')->nullable();
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable(); $table->string('password')->nullable();

View File

@@ -19,10 +19,12 @@ public function run(): void
User::factory()->create([ User::factory()->create([
'name' => 'Jonathan', 'name' => 'Jonathan',
'email' => 'jonathan@blijnder.nl', 'email' => 'jonathan.van.rij@agerion.nl',
'password' => bcrypt('secret'), 'password' => bcrypt('secret'),
'email_verified_at' => now(), 'email_verified_at' => now(),
'role_id' => $adminRole->id, 'role_id' => $adminRole->id,
'job_title' => 'Senior Developer',
'company_name' => 'Baker Tilly',
]); ]);
} }
} }

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

@@ -18,6 +18,10 @@ const props = defineProps({
type: String, type: String,
default: undefined, default: undefined,
}, },
external: {
type: Boolean,
default: false,
},
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -32,7 +36,11 @@ const emit = defineEmits(['click'])
const isDisabled = computed(() => props.disabled || props.loading) const isDisabled = computed(() => props.disabled || props.loading)
const component = computed(() => props.href ? Link : 'button') const component = computed(() => {
if (props.href && props.external) return 'a'
if (props.href) return Link
return 'button'
})
const buttonClasses = computed(() => { const buttonClasses = computed(() => {
const classes = [ const classes = [

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ value: null, text_value: '' }), default: () => ({ value: null, text_value: '' }),
}, },
error: {
type: String,
default: null,
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -58,7 +62,10 @@ const updateTextValue = (event) => {
</script> </script>
<template> <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> <p class="text-white font-medium leading-relaxed mb-4">{{ question.text }}</p>
<!-- Text-only question (no radio buttons) --> <!-- Text-only question (no radio buttons) -->
@@ -105,5 +112,19 @@ const updateTextValue = (event) => {
</div> </div>
</Transition> </Transition>
</div> </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> </div>
</template> </template>

View File

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

View File

@@ -1,10 +1,34 @@
<script setup> <script setup>
import { Head, router } from '@inertiajs/vue3' import { computed } from 'vue'
import { Head, router, usePage } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
defineOptions({ layout: AppLayout }) defineOptions({ layout: AppLayout })
const page = usePage()
const isAuthenticated = computed(() => {
return page.props.auth?.user != null
})
const userInfo = computed(() => {
const user = page.props.auth?.user
if (!user) return null
const parts = []
if (user.job_title) parts.push(user.job_title)
if (user.company_name) {
if (parts.length > 0) {
parts.push('at', user.company_name)
} else {
parts.push(user.company_name)
}
}
return parts.length > 0 ? parts.join(' ') : null
})
const handleContinue = () => { const handleContinue = () => {
router.post('/screening') router.post('/screening')
} }
@@ -16,6 +40,9 @@ const handleContinue = () => {
<div class="flex items-center justify-center py-16"> <div class="flex items-center justify-center py-16">
<div class="text-center max-w-2xl mx-auto px-4"> <div class="text-center max-w-2xl mx-auto px-4">
<h1 class="text-4xl font-bold text-white mb-4">Go / No Go</h1> <h1 class="text-4xl font-bold text-white mb-4">Go / No Go</h1>
<p v-if="userInfo" class="text-gray-400 mb-4">
{{ userInfo }}
</p>
<p class="text-gray-400 mb-4 text-lg"> <p class="text-gray-400 mb-4 text-lg">
Baker Tilly International Go/No Go Checklist Baker Tilly International Go/No Go Checklist
</p> </p>
@@ -24,9 +51,12 @@ const handleContinue = () => {
You will first complete a short pre-screening questionnaire, followed by a detailed category-specific checklist You will first complete a short pre-screening questionnaire, followed by a detailed category-specific checklist
to determine whether to pursue (Go), decline (No Go), or escalate (Consult Leadership) an opportunity. to determine whether to pursue (Go), decline (No Go), or escalate (Consult Leadership) an opportunity.
</p> </p>
<AppButton size="lg" @click="handleContinue" data-cy="start-screening"> <AppButton v-if="isAuthenticated" size="lg" @click="handleContinue" data-cy="start-screening">
Continue Continue
</AppButton> </AppButton>
<AppButton v-else size="lg" href="/login" external>
Log in
</AppButton>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive } from 'vue' import { computed, reactive, ref, watch, nextTick } from 'vue'
import { Head, useForm, router } from '@inertiajs/vue3' import { Head, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
@@ -44,6 +44,11 @@ const initializeAnswers = () => {
} }
initializeAnswers() initializeAnswers()
// Validation state
const validationErrors = ref({})
const showErrors = ref(false)
const questionRefs = ref({})
// Save a single answer with partial reload including score // Save a single answer with partial reload including score
let saveTimeout = null let saveTimeout = null
const saveAnswer = (questionId) => { const saveAnswer = (questionId) => {
@@ -82,11 +87,83 @@ const saveComments = () => {
}, 1000) }, 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 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 completing = true
clearTimeout(saveTimeout)
router.put(`/sessions/${props.session.id}`, { router.put(`/sessions/${props.session.id}`, {
answers: { ...answerData },
complete: true, 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> <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 class="divide-y divide-white/[0.06]">
<QuestionCard <div
v-for="question in group.questions" v-for="question in group.questions"
:key="question.id" :key="question.id"
:question="question" :ref="el => { if (el) questionRefs[question.id] = el }"
:modelValue="answerData[question.id]" :data-question-id="question.id"
@update:modelValue="updateAnswer(question.id, $event)" >
/> <QuestionCard
:question="question"
:modelValue="answerData[question.id]"
:error="showErrors ? validationErrors[question.id] : null"
@update:modelValue="updateAnswer(question.id, $event)"
/>
</div>
</div> </div>
</div> </div>
@@ -164,8 +247,24 @@ const hasScoredAnswers = computed(() => {
</div> </div>
</div> </div>
<!-- Complete button - now enabled --> <!-- Complete button with validation summary -->
<div class="mt-12 pt-8 border-t border-white/[0.06]"> <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"> <div class="flex justify-end">
<AppButton size="lg" @click="completeSession" data-cy="complete-session"> <AppButton size="lg" @click="completeSession" data-cy="complete-session">
Complete Complete

View File

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

BIN
selected-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -41,7 +41,16 @@ public function test_callback_matches_existing_user_by_email(): void
$socialiteUser = Mockery::mock(SocialiteUser::class); $socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getEmail')->andReturn('existing@example.com'); $socialiteUser->shouldReceive('getEmail')->andReturn('existing@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('Updated Name'); $socialiteUser->shouldReceive('getName')->andReturn('Updated Name');
$socialiteUser->shouldReceive('getId')->andReturn('azure-123');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->shouldReceive('offsetExists')->andReturn(false); $socialiteUser->shouldReceive('offsetExists')->andReturn(false);
$socialiteUser->user = [
'jobTitle' => null,
'department' => null,
'companyName' => null,
'mobilePhone' => null,
'businessPhones' => [],
];
$driver = Mockery::mock(); $driver = Mockery::mock();
$driver->shouldReceive('user')->andReturn($socialiteUser); $driver->shouldReceive('user')->andReturn($socialiteUser);
@@ -57,7 +66,7 @@ public function test_callback_matches_existing_user_by_email(): void
$existingUser->refresh(); $existingUser->refresh();
$this->assertEquals('Original Name', $existingUser->name); $this->assertEquals('Updated Name', $existingUser->name);
$this->assertAuthenticatedAs($existingUser); $this->assertAuthenticatedAs($existingUser);
} }
@@ -78,7 +87,7 @@ public function test_login_jonathan_works_in_testing_env(): void
'name' => 'Jonathan', 'name' => 'Jonathan',
]); ]);
$this->get('/login-jonathan') $this->get('/login-for-testing')
->assertRedirect('/'); ->assertRedirect('/');
$user = User::where('email', 'jonathan@blijnder.nl')->first(); $user = User::where('email', 'jonathan@blijnder.nl')->first();

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