Compare commits

..

18 Commits

117 changed files with 13916 additions and 321 deletions

View File

@@ -26,7 +26,11 @@
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_navigate_back",
"mcp__playwright__browser_run_code",
"mcp__playwright__browser_wait_for"
"mcp__playwright__browser_wait_for",
"WebFetch(domain:www.bakertilly.nl)",
"mcp__playwright__browser_type",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_evaluate"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Go No Go"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://go-no-go.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,12 +20,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=go-no-go
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_REDIRECT_URI=/auth/callback
AZURE_TENANT_ID=common
NOVA_LICENSE_KEY=

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
final class DevMenuCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'menu';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Developer tools menu';
public function handle(): int
{
if (! in_array(app()->environment(), ['local', 'testing'])) {
$this->error('This command can only be run in local or testing environments.');
return Command::FAILURE;
}
$this->info('');
$this->info(' ╔═══════════════════════════════╗');
$this->info(' ║ Go No Go — Dev Tools ║');
$this->info(' ╚═══════════════════════════════╝');
$this->info('');
$choice = $this->choice('Select an action', [
0 => 'Exit',
1 => 'Fresh migrate, seed & build',
]);
if ($choice === 'Exit') {
$this->info('Bye!');
return Command::SUCCESS;
}
if ($choice === 'Fresh migrate, seed & build') {
$this->freshMigrateAndBuild();
}
return Command::SUCCESS;
}
/**
* Runs migrate:fresh with seeding, then runs npm build.
*
* Displays output from both processes and confirms success or failure.
*/
private function freshMigrateAndBuild(): void
{
$this->info('');
$this->comment('Running migrate:fresh --seed...');
$this->call('migrate:fresh', ['--seed' => true]);
$this->info('');
$this->comment('Running npm run build...');
$process = new Process(['npm', 'run', 'build']);
$process->setWorkingDirectory(base_path());
$process->setTimeout(120);
$process->run(function (string $type, string $output): void {
$this->output->write($output);
});
if ($process->isSuccessful()) {
$this->info('');
$this->info('Environment rebuilt successfully.');
} else {
$this->error('Build failed.');
}
}
}

View File

@@ -5,9 +5,12 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Role;
use App\Models\User;
use App\Services\ActivityLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Laravel\Socialite\Facades\Socialite;
final class SocialiteController extends Controller
@@ -27,16 +30,27 @@ public function callback(): RedirectResponse
{
$azureUser = Socialite::driver('azure')->user();
$user = User::query()->firstOrCreate(
$user = User::query()->updateOrCreate(
['email' => $azureUser->getEmail()],
[
'name' => $azureUser->getName(),
'password' => null,
'azure_id' => $azureUser->getId(),
'photo' => $azureUser->getAvatar(),
'job_title' => Arr::get($azureUser->user, 'jobTitle'),
'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')),
]
);
if ($user->role_id === null) {
$user->update(['role_id' => Role::where('name', 'user')->first()->id]);
}
auth()->login($user);
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
return redirect('/');
}
@@ -45,6 +59,8 @@ public function callback(): RedirectResponse
*/
public function logout(Request $request): RedirectResponse
{
ActivityLogger::log('logout', auth()->id());
auth()->logout();
$request->session()->invalidate();

View File

@@ -7,8 +7,10 @@
use App\Http\Requests\Screening\UpdateScreeningRequest;
use App\Models\Category;
use App\Models\Screening;
use App\Services\ActivityLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;
use Inertia\Response;
@@ -23,6 +25,8 @@ public function store(Request $request): RedirectResponse
'user_id' => auth()->id(),
]);
ActivityLogger::log('screening_started', auth()->id());
return redirect()->route('screening.show', $screening);
}
@@ -44,8 +48,10 @@ public function update(UpdateScreeningRequest $request, Screening $screening): R
{
$validated = $request->validated();
$this->saveAnswers($screening, $validated['answers']);
$this->calculateAndUpdateScore($screening, $validated['answers']);
$this->saveAnswers($screening, Arr::get($validated, 'answers'));
$this->calculateAndUpdateScore($screening, Arr::get($validated, 'answers'));
ActivityLogger::log('screening_completed', auth()->id(), metadata: ['score' => $screening->score, 'passed' => $screening->passed]);
return redirect()->route('screening.result', $screening);
}

View File

@@ -6,9 +6,12 @@
use App\Http\Requests\Session\UpdateSessionRequest;
use App\Models\Session;
use App\Services\ActivityLogger;
use App\Services\ScoringService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -26,6 +29,8 @@ public function store(Request $request): RedirectResponse
'status' => 'in_progress',
]);
ActivityLogger::log('session_started', auth()->id(), sessionId: $session->id, categoryId: (int) $request->input('category_id'), metadata: ['category_id' => $request->input('category_id')]);
return redirect()->route('sessions.show', $session);
}
@@ -34,7 +39,7 @@ public function store(Request $request): RedirectResponse
*/
public function show(Session $session): Response
{
$session->load('category');
$session->load('category', 'user');
$questionGroups = $session->category
->questionGroups()
@@ -42,6 +47,8 @@ public function show(Session $session): Response
->orderBy('sort_order')
->get();
ActivityLogger::log('step_viewed', auth()->id(), sessionId: $session->id, categoryId: $session->category_id, metadata: ['question_group_id' => $questionGroups->first()?->id]);
$answers = $session->answers()->get()->keyBy('question_id');
$scoringService = new ScoringService;
@@ -62,16 +69,12 @@ public function update(UpdateSessionRequest $request, Session $session): Redirec
{
$validated = $request->validated();
if (isset($validated['basic_info'])) {
$session->update(['basic_info' => $validated['basic_info']]);
if (Arr::has($validated, 'answers')) {
$this->saveAnswers($session, Arr::get($validated, 'answers'));
}
if (isset($validated['answers'])) {
$this->saveAnswers($session, $validated['answers']);
}
if (isset($validated['additional_comments'])) {
$session->update(['additional_comments' => $validated['additional_comments']]);
if (Arr::has($validated, 'additional_comments')) {
$session->update(['additional_comments' => Arr::get($validated, 'additional_comments')]);
}
if ($request->boolean('complete')) {
@@ -90,10 +93,15 @@ private function saveAnswers(Session $session, array $answers): void
$session->answers()->updateOrCreate(
['question_id' => (int) $questionId],
[
'value' => $answer['value'] ?? null,
'text_value' => $answer['text_value'] ?? null,
'value' => Arr::get($answer, 'value'),
'text_value' => Arr::get($answer, 'text_value'),
]
);
ActivityLogger::log('answer_saved', auth()->id(), sessionId: $session->id, categoryId: $session->category_id, metadata: [
'question_id' => (int) $questionId,
'value' => Arr::get($answer, 'value'),
]);
}
}
@@ -102,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);
@@ -113,9 +123,81 @@ private function completeSession(Session $session): RedirectResponse
'completed_at' => now(),
]);
ActivityLogger::log('session_completed', auth()->id(), sessionId: $session->id, categoryId: $session->category_id, metadata: ['category_id' => $session->category_id, 'score' => $score, 'result' => $result]);
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

@@ -6,7 +6,9 @@
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use Inertia\Middleware;
use Laravel\Nova\Nova;
final class HandleInertiaRequests extends Middleware
{
@@ -32,6 +34,7 @@ public function share(Request $request): array
...parent::share($request),
'auth' => [
'user' => $this->getAuthenticatedUser(),
'logo_href' => $this->getLogoHref(),
],
'flash' => [
'success' => fn () => Arr::get($request->session()->all(), 'success'),
@@ -55,6 +58,22 @@ private function getAuthenticatedUser(): ?array
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'job_title' => $user->job_title,
'company_name' => $user->company_name,
];
}
/**
* Determine logo href based on user Nova access.
*/
private function getLogoHref(): string
{
$user = auth()->user();
if ($user !== null && Gate::allows('viewNova', $user)) {
return Nova::path();
}
return '/';
}
}

View File

@@ -13,7 +13,7 @@ final class UpdateScreeningRequest extends FormRequest
*/
public function authorize(): bool
{
return true;
return $this->route('screening')->user_id === auth()->id();
}
/**

View File

@@ -13,7 +13,7 @@ final class UpdateSessionRequest extends FormRequest
*/
public function authorize(): bool
{
return true;
return $this->route('session')->user_id === auth()->id();
}
/**
@@ -22,11 +22,6 @@ public function authorize(): bool
public function rules(): array
{
return [
'basic_info' => ['sometimes', 'required', 'array'],
'basic_info.client_name' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.client_contact' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.lead_firm_name' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.lead_firm_contact' => ['required_with:basic_info', 'string', 'max:255'],
'answers' => ['sometimes', 'array'],
'answers.*.value' => ['nullable', 'string', 'in:yes,no,not_applicable'],
'answers.*.text_value' => ['nullable', 'string', 'max:10000'],
@@ -41,20 +36,6 @@ public function rules(): array
public function messages(): array
{
return [
'basic_info.required' => 'Basic information is required.',
'basic_info.array' => 'Basic information must be a valid data structure.',
'basic_info.client_name.required_with' => 'The client name is required.',
'basic_info.client_name.string' => 'The client name must be text.',
'basic_info.client_name.max' => 'The client name cannot exceed 255 characters.',
'basic_info.client_contact.required_with' => 'The client contact is required.',
'basic_info.client_contact.string' => 'The client contact must be text.',
'basic_info.client_contact.max' => 'The client contact cannot exceed 255 characters.',
'basic_info.lead_firm_name.required_with' => 'The lead firm name is required.',
'basic_info.lead_firm_name.string' => 'The lead firm name must be text.',
'basic_info.lead_firm_name.max' => 'The lead firm name cannot exceed 255 characters.',
'basic_info.lead_firm_contact.required_with' => 'The lead firm contact is required.',
'basic_info.lead_firm_contact.string' => 'The lead firm contact must be text.',
'basic_info.lead_firm_contact.max' => 'The lead firm contact cannot exceed 255 characters.',
'answers.array' => 'Answers must be a valid data structure.',
'answers.*.value.in' => 'Answer value must be yes, no, or not_applicable.',
'answers.*.text_value.string' => 'Answer text must be text.',

View File

@@ -4,11 +4,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Answer extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/

View File

@@ -4,11 +4,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Category extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/

View File

@@ -4,11 +4,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class Log extends Model
{
use HasFactory;
/**
* Disable the updated_at timestamp for append-only logs.
*/

View File

@@ -4,11 +4,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Question extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/
@@ -45,4 +49,12 @@ public function questionGroup(): BelongsTo
{
return $this->belongsTo(QuestionGroup::class);
}
/**
* Get all answers for this question.
*/
public function answers(): HasMany
{
return $this->hasMany(Answer::class);
}
}

View File

@@ -4,12 +4,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class QuestionGroup extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/

23
app/Models/Role.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Role extends Model
{
protected $fillable = [
'name',
];
/**
* Get all users with this role.
*/
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

@@ -4,12 +4,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Screening extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/

View File

@@ -4,11 +4,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ScreeningAnswer extends Model
{
use HasFactory;
/**
* Fillable attributes for mass assignment.
*/

View File

@@ -4,12 +4,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Session extends Model
{
use HasFactory;
protected $table = 'questionnaire_sessions';
/**
@@ -22,7 +25,6 @@ final class Session extends Model
'status',
'score',
'result',
'basic_info',
'additional_comments',
'completed_at',
];
@@ -37,7 +39,6 @@ protected function casts(): array
'category_id' => 'integer',
'screening_id' => 'integer',
'score' => 'integer',
'basic_info' => 'array',
'completed_at' => 'datetime',
];
}

View File

@@ -5,6 +5,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -23,6 +24,13 @@ final class User extends Authenticatable
'name',
'email',
'password',
'azure_id',
'photo',
'job_title',
'department',
'company_name',
'phone',
'role_id',
];
/**
@@ -33,6 +41,7 @@ final class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'azure_id',
];
/**
@@ -45,9 +54,18 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role_id' => 'integer',
];
}
/**
* Get the role assigned to this user.
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Get all sessions for this user.
*/

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;
}
}

158
app/Nova/AnswerResource.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class AnswerResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Answer>
*/
public static string $model = \App\Models\Answer::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'id';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'value'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = false;
/**
* The relationships that should be eager loaded on index queries.
*
* @var array
*/
public static $with = ['session', 'question'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Answers';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Answer';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
BelongsTo::make('Session', 'session', SessionResource::class)
->sortable()
->filterable()
->readonly()
->help('The questionnaire session this answer belongs to.'),
BelongsTo::make('Question', 'question', QuestionResource::class)
->sortable()
->filterable()
->readonly()
->help('The question that was answered.'),
Text::make('Value')
->sortable()
->filterable()
->copyable()
->readonly()
->help('The selected answer: "yes", "no", or "not_applicable". Empty for open text questions.'),
Textarea::make('Text Value')
->alwaysShow()
->readonly()
->help('Any written details or free text the user provided for this question.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable()
->help('When this answer was first saved.'),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable()
->help('When this answer was last changed.'),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class CategoryResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Category>
*/
public static string $model = \App\Models\Category::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'name';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'name'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Categories';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Category';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
Text::make('Name')
->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'),
HasMany::make('Question Groups', 'questionGroups', QuestionGroupResource::class),
HasMany::make('Sessions', 'sessions', SessionResource::class),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

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,
];
}
}

167
app/Nova/LogResource.php Normal file
View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\Code;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class LogResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Log>
*/
public static string $model = \App\Models\Log::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'action';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'action'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* The group associated with the resource.
*
* @var string
*/
public static $group = 'Analytics';
/**
* The relationships that should be eager loaded on index queries.
*
* @var array
*/
public static $with = ['user', 'session', 'category'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Logs';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Log';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
BelongsTo::make('User', 'user', User::class)
->nullable()
->sortable()
->filterable()
->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')
->help('The questionnaire session related to this action, if any.'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable')
->help('The assessment category related to this action, if any.'),
Text::make('Action')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255')
->help('What happened, e.g. "login", "session_started", "answer_saved", "screening_completed".'),
Code::make('Metadata')
->json()
->rules('nullable')
->help('Additional details about this action in a structured format.'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable()
->help('When this action occurred.'),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

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

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class QuestionGroupResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\QuestionGroup>
*/
public static string $model = \App\Models\QuestionGroup::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'name';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'name'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Question Groups';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Question Group';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable()
->filterable()
->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')
->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')
->help('Controls the display order within the category. Lower numbers appear first.'),
Textarea::make('Description')
->rules('nullable')
->help('An optional description shown to users at the top of this question group.'),
Textarea::make('Scoring Instructions')
->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),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
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 App\Nova\Actions\DownloadExcel;
final class QuestionResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Question>
*/
public static string $model = \App\Models\Question::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'text';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'text'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* The group associated with the resource.
*
* @var string
*/
public static $group = 'Questionnaire';
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Questions';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Question';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
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()
->help('The group this question belongs to. Questions are shown together by group in the questionnaire.'),
Textarea::make('Text')
->rules('required')
->updateRules('required')
->help('The full question text shown to the user in the questionnaire.'),
Boolean::make('Has Yes')
->sortable()
->filterable()
->help('When enabled, a "Yes" answer option is shown for this question.'),
Boolean::make('Has No')
->sortable()
->filterable()
->help('When enabled, a "No" answer option is shown for this question.'),
Boolean::make('Has NA', 'has_na')
->sortable()
->filterable()
->help('When enabled, a "Not Applicable" answer option is shown for this question.'),
Select::make('Details')
->options([
'optional' => 'Optional',
'required' => 'Required',
'req_on_yes' => 'Required on Yes',
'req_on_no' => 'Required on No',
])
->displayUsingLabels()
->nullable()
->sortable()
->filterable()
->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()
->help('Controls the display order within the question group. Lower numbers appear first.'),
Boolean::make('Is Scored')
->sortable()
->filterable()
->help('When enabled, this question counts toward the total score. A "Yes" answer scores 1 point.'),
HasMany::make('Answers', 'answers', AnswerResource::class),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

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.
*/

75
app/Nova/RoleResource.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
final class RoleResource extends Resource
{
public static string $model = \App\Models\Role::class;
public static $title = 'name';
public static $search = ['id', 'name'];
public static $displayInNavigation = false;
public static function label(): string
{
return 'Roles';
}
public static function singularLabel(): string
{
return 'Role';
}
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
Text::make('Name')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
DateTime::make('Created At')
->exceptOnForms()
->sortable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable(),
HasMany::make('Users', 'users', User::class),
];
}
public function cards(NovaRequest $request): array
{
return [];
}
public function filters(NovaRequest $request): array
{
return [];
}
public function lenses(NovaRequest $request): array
{
return [];
}
public function actions(NovaRequest $request): array
{
return [];
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Nova;
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\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class ScreeningResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Screening>
*/
public static string $model = \App\Models\Screening::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'id';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* The group associated with the resource.
*
* @var string
*/
public static $group = 'Questionnaire';
/**
* The relationships that should be eager loaded on index queries.
*
* @var array
*/
public static $with = ['user'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Screenings';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Screening';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
BelongsTo::make('User', 'user', User::class)
->sortable()
->filterable()
->rules('required')
->help('The person who completed this pre-screening.'),
Number::make('Score')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer')
->help('The number of "Yes" answers out of 10 pre-screening questions.'),
Boolean::make('Passed')
->sortable()
->filterable()
->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()
->help('When this pre-screening was started.'),
HasMany::make('Sessions', 'sessions', SessionResource::class),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\BelongsTo;
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\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use App\Nova\Actions\DownloadExcel;
final class SessionResource extends Resource
{
/**
* The model the resource corresponds to.
*
* @var class-string<\App\Models\Session>
*/
public static string $model = \App\Models\Session::class;
/**
* The single value that should be used to represent the resource when being displayed.
*
* @var string
*/
public static $title = 'id';
/**
* The columns that should be searched.
*
* @var array
*/
public static $search = ['id', 'status', 'result'];
/**
* Indicates if the resource should be displayed in the sidebar.
*
* @var bool
*/
public static $displayInNavigation = true;
/**
* The group associated with the resource.
*
* @var string
*/
public static $group = 'Questionnaire';
/**
* The relationships that should be eager loaded on index queries.
*
* @var array
*/
public static $with = ['user', 'category', 'screening'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Sessions';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Session';
}
/**
* Get the fields displayed by the resource.
*
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
*/
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
BelongsTo::make('User', 'user', User::class)
->sortable()
->filterable()
->rules('required')
->help('The person who started this questionnaire session.'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable()
->filterable()
->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')
->help('The pre-screening that was completed before starting this session.'),
Select::make('Status')
->options([
'in_progress' => 'In Progress',
'completed' => 'Completed',
'abandoned' => 'Abandoned',
])
->displayUsingLabels()
->sortable()
->filterable()
->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')
->help('The total score from all scored questions. Only "Yes" answers count as points.'),
Select::make('Result')
->options([
'go' => 'Go',
'no_go' => 'No Go',
'consult_leadership' => 'Consult Leadership',
])
->displayUsingLabels()
->sortable()
->filterable()
->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')
->help('Any extra notes the user added at the end of the questionnaire.'),
DateTime::make('Completed At')
->sortable()
->filterable()
->rules('nullable')
->help('The date and time when the user submitted this session.'),
HasMany::make('Answers', 'answers', AnswerResource::class),
HasMany::make('Logs', 'logs', LogResource::class),
];
}
/**
* Get the cards available for the request.
*
* @return array<int, \Laravel\Nova\Card>
*/
public function cards(NovaRequest $request): array
{
return [];
}
/**
* Get the filters available for the resource.
*
* @return array<int, \Laravel\Nova\Filters\Filter>
*/
public function filters(NovaRequest $request): array
{
return [];
}
/**
* Get the lenses available for the resource.
*
* @return array<int, \Laravel\Nova\Lenses\Lens>
*/
public function lenses(NovaRequest $request): array
{
return [];
}
/**
* Get the actions available for the resource.
*
* @return array<int, \Laravel\Nova\Actions\Action>
*/
public function actions(NovaRequest $request): array
{
return [
new DownloadExcel,
];
}
}

View File

@@ -1,15 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Illuminate\Http\Request;
use Laravel\Nova\Auth\PasswordValidationRules;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Password;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
class User extends Resource
final class User extends Resource
{
use PasswordValidationRules;
@@ -33,7 +36,7 @@ class User extends Resource
* @var array
*/
public static $search = [
'id', 'name', 'email',
'id', 'name', 'email', 'department', 'job_title',
];
/**
@@ -46,20 +49,55 @@ public function fields(NovaRequest $request): array
return [
ID::make()->sortable(),
BelongsTo::make('Role', 'role', RoleResource::class)
->sortable()
->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()
->help('A unique identifier from Azure AD. Set automatically when the user logs in.'),
Text::make('Photo', 'photo')
->onlyOnDetail()
->copyable()
->help('A link to the user\'s profile photo from Azure AD.'),
Text::make('Job Title', 'job_title')
->sortable()
->filterable()
->copyable()
->help('The user\'s job title, imported from Azure AD.'),
Text::make('Department')
->sortable()
->filterable()
->copyable()
->help('The department the user belongs to, imported from Azure AD.'),
Text::make('Phone')
->sortable()
->copyable()
->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

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Answer;
use App\Models\User;
final class AnswerPolicy
{
/**
* Determine whether the user can view any answers.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the answer.
*/
public function view(User $user, Answer $answer): bool
{
return true;
}
/**
* Determine whether the user can create answers.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the answer.
*/
public function update(User $user, Answer $answer): bool
{
return false;
}
/**
* Determine whether the user can delete the answer.
*/
public function delete(User $user, Answer $answer): bool
{
return false;
}
/**
* Determine whether the user can restore the answer.
*/
public function restore(User $user, Answer $answer): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the answer.
*/
public function forceDelete(User $user, Answer $answer): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
final class CategoryPolicy
{
/**
* Determine whether the user can view any categories.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the category.
*/
public function view(User $user, Category $category): bool
{
return true;
}
/**
* Determine whether the user can create categories.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the category.
*/
public function update(User $user, Category $category): bool
{
return true;
}
/**
* Determine whether the user can delete the category.
*/
public function delete(User $user, Category $category): bool
{
return false;
}
/**
* Determine whether the user can restore the category.
*/
public function restore(User $user, Category $category): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the category.
*/
public function forceDelete(User $user, Category $category): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Log;
use App\Models\User;
final class LogPolicy
{
/**
* Determine whether the user can view any logs.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the log.
*/
public function view(User $user, Log $log): bool
{
return true;
}
/**
* Determine whether the user can create logs.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the log.
*/
public function update(User $user, Log $log): bool
{
return false;
}
/**
* Determine whether the user can delete the log.
*/
public function delete(User $user, Log $log): bool
{
return false;
}
/**
* Determine whether the user can restore the log.
*/
public function restore(User $user, Log $log): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the log.
*/
public function forceDelete(User $user, Log $log): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\QuestionGroup;
use App\Models\User;
final class QuestionGroupPolicy
{
/**
* Determine whether the user can view any question groups.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the question group.
*/
public function view(User $user, QuestionGroup $questionGroup): bool
{
return true;
}
/**
* Determine whether the user can create question groups.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the question group.
*/
public function update(User $user, QuestionGroup $questionGroup): bool
{
return true;
}
/**
* Determine whether the user can delete the question group.
*/
public function delete(User $user, QuestionGroup $questionGroup): bool
{
return false;
}
/**
* Determine whether the user can restore the question group.
*/
public function restore(User $user, QuestionGroup $questionGroup): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the question group.
*/
public function forceDelete(User $user, QuestionGroup $questionGroup): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Question;
use App\Models\User;
final class QuestionPolicy
{
/**
* Determine whether the user can view any questions.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the question.
*/
public function view(User $user, Question $question): bool
{
return true;
}
/**
* Determine whether the user can create questions.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the question.
*/
public function update(User $user, Question $question): bool
{
return true;
}
/**
* Determine whether the user can delete the question.
*/
public function delete(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can restore the question.
*/
public function restore(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the question.
*/
public function forceDelete(User $user, Question $question): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Screening;
use App\Models\User;
final class ScreeningPolicy
{
/**
* Determine whether the user can view any screenings.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the screening.
*/
public function view(User $user, Screening $screening): bool
{
return true;
}
/**
* Determine whether the user can create screenings.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the screening.
*/
public function update(User $user, Screening $screening): bool
{
return false;
}
/**
* Determine whether the user can delete the screening.
*/
public function delete(User $user, Screening $screening): bool
{
return false;
}
/**
* Determine whether the user can restore the screening.
*/
public function restore(User $user, Screening $screening): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the screening.
*/
public function forceDelete(User $user, Screening $screening): bool
{
return false;
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Session;
use App\Models\User;
final class SessionPolicy
{
/**
* Determine whether the user can view any sessions.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the session.
*/
public function view(User $user, Session $session): bool
{
return true;
}
/**
* Determine whether the user can create sessions.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the session.
*/
public function update(User $user, Session $session): bool
{
return false;
}
/**
* Determine whether the user can delete the session.
*/
public function delete(User $user, Session $session): bool
{
return false;
}
/**
* Determine whether the user can restore the session.
*/
public function restore(User $user, Session $session): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the session.
*/
public function forceDelete(User $user, Session $session): bool
{
return false;
}
}

View File

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

View File

@@ -3,8 +3,18 @@
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;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Laravel\Fortify\Features;
use Laravel\Nova\Menu\MenuItem;
use Laravel\Nova\Menu\MenuSection;
use Laravel\Nova\Nova;
use Laravel\Nova\NovaApplicationServiceProvider;
@@ -17,7 +27,27 @@ public function boot(): void
{
parent::boot();
//
Nova::mainMenu(function (Request $request) {
return [
MenuSection::dashboard(Main::class)->icon('home'),
MenuSection::make('Questionnaire', [
MenuItem::resource(QuestionResource::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', [
MenuItem::resource(LogResource::class),
])->icon('chart-bar')->collapsible(),
MenuSection::make('Users', [
MenuItem::resource(\App\Nova\User::class),
])->icon('users')->collapsible(),
];
});
}
/**
@@ -40,7 +70,7 @@ protected function fortify(): void
protected function routes(): void
{
Nova::routes()
->withAuthenticationRoutes(default: true)
->withAuthenticationRoutes(default: false)
->withPasswordResetRoutes()
->withEmailVerificationRoutes()
->register();
@@ -54,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';
});
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Log;
final class ActivityLogger
{
/**
* Log an activity to the database.
*/
public static function log(
string $action,
?int $userId = null,
?int $sessionId = null,
?int $categoryId = null,
?array $metadata = null,
): void {
Log::create([
'user_id' => $userId,
'session_id' => $sessionId,
'category_id' => $categoryId,
'action' => $action,
'metadata' => $metadata,
]);
}
}

View File

@@ -22,5 +22,19 @@
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
$exceptions->respond(function (\Symfony\Component\HttpFoundation\Response $response, \Throwable $exception, \Illuminate\Http\Request $request) {
if (! app()->environment('local') && in_array($response->getStatusCode(), [403, 404, 500, 503])) {
return \Inertia\Inertia::render('ErrorPage', ['status' => $response->getStatusCode()])
->toResponse($request)
->setStatusCode($response->getStatusCode());
}
if ($response->getStatusCode() === 419) {
return back()->with([
'message' => 'The page expired, please try again.',
]);
}
return $response;
});
})->create();

View File

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

753
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "846969b15fec689e62554bd1be9c57a8",
"content-hash": "535a9303784c8d25d1d3b32702506cc9",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -247,6 +247,85 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
@@ -747,6 +826,67 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "ezyang/htmlpurifier",
"version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
"time": "2025-10-17T16:34:55+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.2",
@@ -2149,6 +2289,58 @@
},
"time": "2025-12-19T19:16:45+00:00"
},
{
"name": "laravie/serialize-queries",
"version": "v3.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravie/serialize-queries.git",
"reference": "46b3cf05d09d1c7e35648181d2f5eadf5fe9967c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravie/serialize-queries/zipball/46b3cf05d09d1c7e35648181d2f5eadf5fe9967c",
"reference": "46b3cf05d09d1c7e35648181d2f5eadf5fe9967c",
"shasum": ""
},
"require": {
"illuminate/database": "^10.48.23 || ^11.31 || ^12.0",
"illuminate/queue": "^10.48.23 || ^11.31 || ^12.0",
"laravel/serializable-closure": "^1.3 || ^2.0",
"php": "^8.1"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^8.28 || ^9.6 || ^10.0",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.1 || ^11.0"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Laravie\\SerializesQuery\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mior Muhammad Zaki",
"email": "crynobone@gmail.com"
}
],
"description": "Serializable Laravel Query Builder",
"support": {
"issues": "https://github.com/laravie/serialize-queries/issues",
"source": "https://github.com/laravie/serialize-queries/tree/v3.2.0"
},
"time": "2025-02-17T04:39:20+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.0",
@@ -2784,6 +2976,330 @@
],
"time": "2026-01-15T06:54:53+00:00"
},
{
"name": "maatwebsite/excel",
"version": "3.1.67",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.0",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
},
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2025-08-26T09:13:16+00:00"
},
{
"name": "maatwebsite/laravel-nova-excel",
"version": "1.3.13",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Nova-Excel.git",
"reference": "dd5f4c35b2c83b1f057e891483588f694850bc35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Nova-Excel/zipball/dd5f4c35b2c83b1f057e891483588f694850bc35",
"reference": "dd5f4c35b2c83b1f057e891483588f694850bc35",
"shasum": ""
},
"require": {
"laravel/nova": "^4.0|^5.0",
"laravie/serialize-queries": "^1.0|^2.0|^3.0",
"maatwebsite/excel": "^3.1.57",
"php": "^7.1||^8.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Maatwebsite\\LaravelNovaExcel\\LaravelNovaExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\LaravelNovaExcel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@maatwebsite.nl"
}
],
"description": "Supercharged Excel exports for Laravel Nova Resources",
"keywords": [
"PHPExcel",
"actions",
"laravel",
"laravel-nova",
"nova",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Nova-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Nova-Excel/tree/1.3.13"
},
"time": "2025-05-15T09:44:24+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3562,6 +4078,114 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.2",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/09cdde5e2f078b9a3358dd217e2c8cb4dac84be2",
"reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": ">=7.4.0 <8.5.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"doctrine/instantiator": "^1.5",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.2"
},
"time": "2026-01-11T05:58:24+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -4558,6 +5182,131 @@
],
"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",
"version": "v8.0.0",
@@ -10180,7 +10929,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
"php": "^8.4"
},
"platform-dev": {},
"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,
]),
],
];

12
cypress.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'https://go-no-go.test',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx}',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
},
})

View File

@@ -0,0 +1,43 @@
describe('Questionnaire Flow', () => {
beforeEach(() => {
cy.resetDatabase()
cy.login()
})
it('completes the full questionnaire flow from landing to result', () => {
// 1. Landing page — click Continue
cy.get('[data-cy="start-screening"]').click()
// 2. Screening — answer all 10 questions with Yes
for (let i = 1; i <= 10; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="yes"]').check({ force: true })
})
}
cy.get('[data-cy="submit-screening"]').click()
// 3. Screening result — should pass with 10/10
cy.get('[data-cy="result-passed"]').should('exist')
cy.get('[data-cy="screening-score"]').should('contain', '10')
// 4. Select first category (Audit)
cy.get('[data-cy="category-select"]').within(() => {
cy.contains('button', 'Start').first().click()
})
// 5. Session/Show — should see questionnaire
cy.url().should('include', '/sessions/')
cy.contains('Questionnaire').should('be.visible')
// 6. Complete session
cy.get('[data-cy="complete-session"]').click()
// 7. Session result page
cy.url().should('include', '/result')
cy.get('[data-cy="session-result"]').should('exist')
// 8. Click Again to go back
cy.get('[data-cy="start-new"]').click()
cy.url().should('eq', Cypress.config('baseUrl') + '/')
})
})

View File

@@ -0,0 +1,54 @@
describe('Result Page', () => {
beforeEach(() => {
cy.resetDatabase()
cy.login()
})
function passScreeningAndStartSession() {
cy.get('[data-cy="start-screening"]').click()
for (let i = 1; i <= 10; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="yes"]').check({ force: true })
})
}
cy.get('[data-cy="submit-screening"]').click()
cy.get('[data-cy="category-select"]').within(() => {
cy.contains('button', 'Start').first().click()
})
cy.url().should('include', '/sessions/')
}
it('shows session result after completion', () => {
passScreeningAndStartSession()
// Just complete without answering specific questions
cy.get('[data-cy="complete-session"]').click()
cy.url().should('include', '/result')
cy.get('[data-cy="session-result"]').should('exist')
})
it('shows the result badge with correct result type', () => {
passScreeningAndStartSession()
cy.get('[data-cy="complete-session"]').click()
cy.url().should('include', '/result')
// Should show one of the result types
cy.get('[data-cy="session-result"]').should('exist')
cy.get('[data-cy^="result-"]').should('exist')
})
it('can start over from result page', () => {
passScreeningAndStartSession()
cy.get('[data-cy="complete-session"]').click()
cy.url().should('include', '/result')
cy.get('[data-cy="start-new"]').click()
cy.url().should('eq', Cypress.config('baseUrl') + '/')
})
})

View File

@@ -0,0 +1,69 @@
describe('Scoring Display', () => {
beforeEach(() => {
cy.resetDatabase()
cy.login()
})
it('shows No Go result when fewer than 5 yes answers', () => {
cy.get('[data-cy="start-screening"]').click()
// Answer 4 yes, 6 no
for (let i = 1; i <= 4; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="yes"]').check({ force: true })
})
}
for (let i = 5; i <= 10; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="no"]').check({ force: true })
})
}
cy.get('[data-cy="submit-screening"]').click()
// Should fail
cy.get('[data-cy="result-failed"]').should('exist')
cy.get('[data-cy="screening-score"]').should('contain', '4')
cy.get('[data-cy="category-select"]').should('not.exist')
})
it('passes at boundary with exactly 5 yes answers', () => {
cy.get('[data-cy="start-screening"]').click()
// Answer 5 yes, 5 no
for (let i = 1; i <= 5; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="yes"]').check({ force: true })
})
}
for (let i = 6; i <= 10; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="no"]').check({ force: true })
})
}
cy.get('[data-cy="submit-screening"]').click()
// Should pass
cy.get('[data-cy="result-passed"]').should('exist')
cy.get('[data-cy="screening-score"]').should('contain', '5')
cy.get('[data-cy="category-select"]').should('exist')
})
it('displays the score correctly', () => {
cy.get('[data-cy="start-screening"]').click()
// Answer 7 yes, 3 no
for (let i = 1; i <= 7; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="yes"]').check({ force: true })
})
}
for (let i = 8; i <= 10; i++) {
cy.get(`[data-cy="screening-answer-${i}"]`).within(() => {
cy.get('[data-cy="no"]').check({ force: true })
})
}
cy.get('[data-cy="submit-screening"]').click()
cy.get('[data-cy="screening-score"]').should('contain', '7')
})
})

View File

@@ -0,0 +1,8 @@
Cypress.Commands.add('login', () => {
cy.visit('/login-jonathan')
cy.url().should('include', '/')
})
Cypress.Commands.add('resetDatabase', () => {
cy.exec('herd php artisan migrate:fresh --seed --force', { timeout: 30000 })
})

1
cypress/support/e2e.js Normal file
View File

@@ -0,0 +1 @@
import './commands'

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Answer;
use App\Models\Question;
use App\Models\Session;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Answer test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Answer>
*/
final class AnswerFactory extends Factory
{
protected $model = Answer::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'session_id' => Session::factory(),
'question_id' => Question::factory(),
'value' => fake()->randomElement(['yes', 'no', 'not_applicable']),
'text_value' => null,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Category test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
*/
final class CategoryFactory extends Factory
{
protected $model = Category::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'name' => fake()->unique()->words(2, true),
'sort_order' => fake()->numberBetween(0, 10),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Log;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Log test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Log>
*/
final class LogFactory extends Factory
{
protected $model = Log::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'action' => fake()->randomElement([
'login',
'logout',
'session_started',
'session_completed',
'screening_started',
'screening_completed',
]),
'metadata' => null,
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Question;
use App\Models\QuestionGroup;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Question test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Question>
*/
final class QuestionFactory extends Factory
{
protected $model = Question::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'question_group_id' => QuestionGroup::factory(),
'text' => fake()->sentence(),
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => null,
'sort_order' => fake()->numberBetween(0, 10),
'is_scored' => true,
];
}
/**
* Indicate that the question is not scored.
*/
public function nonScored(): static
{
return $this->state(fn (array $attributes) => [
'is_scored' => false,
]);
}
/**
* Indicate that the question is text-only (no yes/no/na options).
*/
public function textOnly(): static
{
return $this->state(fn (array $attributes) => [
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'is_scored' => false,
'details' => 'required',
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Category;
use App\Models\QuestionGroup;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating QuestionGroup test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\QuestionGroup>
*/
final class QuestionGroupFactory extends Factory
{
protected $model = QuestionGroup::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'category_id' => Category::factory(),
'name' => fake()->words(3, true),
'sort_order' => fake()->numberBetween(0, 10),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Screening;
use App\Models\ScreeningAnswer;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating ScreeningAnswer test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ScreeningAnswer>
*/
final class ScreeningAnswerFactory extends Factory
{
protected $model = ScreeningAnswer::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'screening_id' => Screening::factory(),
'question_number' => fake()->numberBetween(1, 10),
'value' => fake()->randomElement(['yes', 'no']),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Screening;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Screening test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Screening>
*/
final class ScreeningFactory extends Factory
{
protected $model = Screening::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'score' => null,
'passed' => null,
];
}
/**
* Indicate that the screening passed.
*/
public function passed(): static
{
return $this->state(fn (array $attributes) => [
'score' => 10,
'passed' => true,
]);
}
/**
* Indicate that the screening failed.
*/
public function failed(): static
{
return $this->state(fn (array $attributes) => [
'score' => 3,
'passed' => false,
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Category;
use App\Models\Session;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* Factory for generating Session test data.
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Session>
*/
final class SessionFactory extends Factory
{
protected $model = Session::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'screening_id' => null,
'status' => 'in_progress',
'score' => null,
'result' => null,
'additional_comments' => null,
'completed_at' => null,
];
}
/**
* Indicate that the session is completed.
*/
public function completed(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'completed',
'score' => 8,
'result' => 'consult_leadership',
'completed_at' => now(),
]);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -9,7 +11,7 @@
/**
* @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.
@@ -27,8 +29,10 @@ public function definition(): array
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'password' => self::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'job_title' => fake()->jobTitle(),
'company_name' => fake()->company(),
];
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
$now = now();
DB::table('roles')->insert([
['name' => 'user', 'created_at' => $now, 'updated_at' => $now],
['name' => 'admin', 'created_at' => $now, 'updated_at' => $now],
]);
}
public function down(): void
{
Schema::dropIfExists('roles');
}
};

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -13,10 +15,17 @@ public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->default(1)->constrained();
$table->string('name');
$table->string('email')->unique();
$table->string('azure_id')->nullable()->unique();
$table->string('photo')->nullable();
$table->string('job_title')->nullable();
$table->string('department')->nullable();
$table->string('company_name')->nullable();
$table->string('phone')->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('password')->nullable();
$table->rememberToken();
$table->timestamps();
});

View File

@@ -21,7 +21,6 @@ public function up(): void
$table->string('status', 50)->default('in_progress');
$table->integer('score')->nullable();
$table->string('result', 50)->nullable();
$table->json('basic_info')->nullable();
$table->text('additional_comments')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();

View File

@@ -1,22 +1,30 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Seeder;
class JonathanSeeder extends Seeder
final class JonathanSeeder extends Seeder
{
/**
* Seed the application's database.
* Seed the application's database with admin user Jonathan.
*/
public function run(): void
{
$adminRole = Role::where('name', 'admin')->first();
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,
'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

@@ -366,7 +366,7 @@ ## Step 10: Scoring and Result
## Step 11: Activity Logging
[ ] **Add append-only activity logging for analytics.**
[x] **Add append-only activity logging for analytics.**
Create a logging service (or helper) that writes to the `logs` table. Integrate log writes into all relevant actions: `login`, `logout`, `screening_started`, `screening_completed`, `session_started`, `session_completed`, `session_abandoned`, `answer_saved`, `step_viewed`. Each log includes `user_id`, `session_id`, `category_id`, `action`, and `metadata` JSON as defined in `docs/technical-requirements.md` section 5 (logs table). The Log model should have no `updated_at` and should prevent updates/deletes.
@@ -403,7 +403,7 @@ ## Step 11: Activity Logging
## Step 12: Nova Resources and Policies
[ ] **Create all Nova resources, policies, and Excel export actions.**
[x] **Create all Nova resources, policies, and Excel export actions.**
Create Nova resources: `CategoryResource`, `QuestionGroupResource`, `QuestionResource`, `ScreeningResource`, `SessionResource`, `AnswerResource`, `LogResource`. Create corresponding policies enforcing the permission matrix from `docs/technical-requirements.md` section 9 (most are read-only; only Question.text is editable). Apply field behaviors: all fields filterable, sortable, and copyable where applicable. Set menu visibility (only Question, Screening, Session, Log appear in sidebar). Install `maatwebsite/laravel-nova-excel` and add `DownloadExcel` action to every resource.

View File

@@ -13,7 +13,7 @@ ### Root Level
### Frontend
- `docs/theming-templating-vue.md` - Design tokens, Tailwind config, layout, shared Vue components, icon and scoring color standards
- `docs/theming-templating-vue.md` - Design tokens, Tailwind config, layout, shared Vue components, RadioButtonGroup pill buttons, icon and scoring color standards
### Agents

View File

@@ -9,6 +9,7 @@ ### Color Palette
| Token | Hex | RGB | Tailwind Class | Usage |
|-------------|-----------|------------------|-----------------|---------------------------------------------|
| Primary | `#d1ec51` | 209, 236, 81 | `bg-primary`, `text-primary` | Buttons (default), accents, highlights |
| Primary Dark | `#b5d136` | 181, 209, 54 | `bg-primary-dark`, `text-primary-dark` | Selected/hover state for pill buttons, ~15% darker primary |
| Secondary | `#00b7b3` | 0, 183, 179 | `bg-secondary`, `text-secondary` | Button hover states, secondary accents |
| Background | `#2b303a` | 43, 48, 58 | `bg-surface` | Page background, card backgrounds |
| Text | `#ffffff` | 255, 255, 255 | `text-white` | Primary body text on dark background |
@@ -96,6 +97,23 @@ ### QuestionCard
- `has_na` -- show N/A button
- `details` -- show a text input for additional notes
### RadioButtonGroup
Pill-shaped button group that replaces native radio buttons. Options appear as connected segments with rounded outer edges.
| Prop | Type | Default | Description |
|--------------|-------------------------------|---------|--------------------------------|
| `modelValue` | `String \| null` | `null` | Selected value (v-model) |
| `options` | `Array<{value, label}>` | required | Options to render |
| `name` | `String` | required | HTML radio group name |
| `disabled` | `Boolean` | `false` | Disables all options |
Default state: `bg-primary` with `text-gray-900`.
Selected & hover state: `bg-primary-dark`.
Keyboard focus: visible ring using `ring-primary-dark`.
Used in `QuestionCard` (3-option: Yes/No/N/A) and `Screening/Show` (2-option: Yes/No).
## Icons
Heroicons is the only icon library. No other icon packages.

1603
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,16 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "vite",
"cy:open": "cypress open",
"cy:run": "cypress run",
"test:e2e": "cypress run"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"cypress": "^15.9.0",
"laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.7"

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 598.24 157.147"><path fill="#fff" d="m540.007 130.878.429-11.135c-.612.136-2.959.512-4.54.512-5.277 0-6.71-3.427-6.71-8.124V47.525l-14.238 6.437v60.506c0 8.59 4.265 17.17 16.596 17.17 4.89 0 7.835-.64 8.463-.76zm-32.05 0 .431-11.135c-.613.136-2.961.512-4.541.512-5.278 0-6.708-3.427-6.708-8.124V47.525l-14.24 6.437v60.506c0 8.59 4.266 17.17 16.596 17.17 4.89 0 7.836-.64 8.462-.76zm-55.201-.182V72.293h14.24v58.403Zm7.01-65.621c-4.577 0-8.037-1.786-8.037-8.259 0-5.916 3.126-8.373 8.036-8.373 5.023 0 8.262 1.898 8.262 8.261 0 6.139-3.015 8.37-8.262 8.37zm85.802 92.072c.515 0 3.365-.479 4.115-.595 14.004-2.146 24.264-8.333 29.914-25.426 3.847-11.636 18.65-58.798 18.65-58.798H583.14l-13.602 45.95h-2.42l-11.6-45.95h-14.771l15.804 58.398h9.095s-.356 1.162-.624 2.08c-1.898 6.502-10.256 11.635-21.355 13.222zm-160.67-26.455v-46.13c1.743-.58 5.835-1.278 10.715-1.278 2.558 0 7.32.192 9.414.424V71.435c-1.628-.464-6.03-.602-10.098-.602-10.228 0-19.22 2.585-24.27 4.838v55.021zm-37.193-35.301v-1.663c0-7.702-4.205-11.507-12.726-11.507-9.178 0-13.098 5.15-13.331 13.17zm-7.781 25.233c8.76 0 14.27-2.173 16.973-3.003l1.368 10.425c-2.272 1.047-9.72 4.154-19.37 4.154-21.154 0-31.502-10.843-31.502-31.762 0-21.16 9.868-29.722 27.982-29.722 19.749 0 25.825 10.926 25.825 29.406 0 1.975-.136 4.439-.252 5.22h-39.53c0 10.546 6.615 15.282 18.506 15.282zm-71.09 10.068v-26.69h4.45l19.527 26.69H308.7l-24.19-33.935 22.014-24.43h-15.632l-18.11 20.493h-3.949V47.27l-14.24 7.021v76.401zm161.566.949c6.196 0 9.647-.96 10.315-1.132l.656-11.275c-.539.21-4 1.057-6.03 1.057-7.156 0-9.384-5.085-9.384-11.826V84.632h17.224V72.328h-17.224V60.16l-14.237 6.478v45.51c0 13.384 6.988 19.492 18.68 19.492zm-201.83-25.923c-2.797-.445-8.417-1.17-12.705-1.17-10.1 0-11.762 4.683-11.762 8.329 0 5.358 3.496 7.792 12.166 7.792 5.662 0 10.208-.725 12.302-1.308zm-11.92 26.426c-18.713 0-25.755-6.154-25.755-18.89 0-12.995 6.856-18.8 23.926-18.8 5.962 0 12.404 1.009 13.75 1.196v-4.355c0-4.676-2.312-8.775-14.996-8.775-9.628 0-15.943 2.68-17.891 3.441l-1.1-10.856c2.568-1.012 10.775-4.389 23.048-4.389 18.453 0 25.168 7.87 25.168 23.022v34.197c-3.368 1.381-13.792 4.209-26.15 4.209zm-64.002-11.486c12.516 0 17.323-7.969 17.323-19.163 0-10.854-4.62-19.004-17.323-19.004-3.487 0-8.423 1.157-10.61 1.851v34.466c2.187.724 6.79 1.85 10.61 1.85zm-24.891-66.367 14.282-6.453v25.64c2.645-.93 7.526-2.762 15.661-2.762 17.643 0 26.21 9.396 26.21 30.78 0 25.057-9.837 30.704-32.44 30.704-10.809 0-19.595-1.986-23.713-3.7V54.29M70.175 2.867C66.105.857 59.805-.005 53.728-.005 26.208-.005 0 15.509 0 41.638c0 9.23 2.93 19.098 8.046 27.938 5.783 9.996 13.802 19.135 23.375 25.63 7.264 4.93 15.678 8.369 24.37 8.369 7.24 0 12.101-1.985 13.785-3.373a29.48 29.48 0 0 1-4.642.374c-23.7 0-39.964-20.123-45.153-35.15-1.914-5.543-3.917-12.547-3.917-19.345 0-27.818 24.451-43.092 50.117-43.092 1.362 0 2.733.044 4.11.14.106.007.174-.058.174-.132 0-.046-.029-.096-.09-.13zM74.4 44.778c-12.017 0-21.314 9.18-21.314 20.034 0 11.05 8.553 21.348 21.757 21.348 2.83 0 6.057-.837 8.88-1.944.489-.19 1.104-.421 1.515-.751.336-.272.647-.925.864-1.314 3.324-5.98 7.116-18.8 7.686-26.373.058-.776-.159-1.057-.62-1.684-3.12-4.223-9.192-9.316-18.768-9.316zm19.854.357c0 1.022-.022 3.224-.047 4.229-4.477-7.88-13.181-12.93-22.187-12.93-14.098 0-25.948 11.805-25.948 27.038 0 14.977 11.6 26.371 26.204 26.371 3.701 0 7.72-.849 10.63-2.323-1.457 2.135-2.47 3.453-3.96 5.112-.795.884-1.904 2.09-3.03 2.505-1.285.472-3.977 1.547-8.776 1.547-15.31 0-26.698-11.367-31.823-19.025-5.11-7.637-9.524-17.874-9.524-28.825 0-20.996 18.443-32.429 37.425-32.429 21.743 0 31.036 16.588 31.036 28.73"/></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -11,6 +11,7 @@ @theme {
'Segoe UI Symbol', 'Noto Color Emoji';
--color-primary: #d1ec51;
--color-primary-dark: #b5d136;
--color-secondary: #00b7b3;
--color-surface: #2b303a;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -18,6 +18,10 @@ const props = defineProps({
type: String,
default: undefined,
},
external: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
@@ -32,7 +36,11 @@ const emit = defineEmits(['click'])
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 classes = [
@@ -44,11 +52,11 @@ const buttonClasses = computed(() => {
// Size classes
if (props.size === 'sm') {
classes.push('px-3 py-1.5 text-sm')
classes.push('px-4 py-2 text-sm')
} else if (props.size === 'md') {
classes.push('px-5 py-2.5 text-base')
classes.push('px-6 py-3 text-base')
} else if (props.size === 'lg') {
classes.push('px-7 py-3 text-lg')
classes.push('px-8 py-3.5 text-lg')
}
// Variant classes
@@ -62,6 +70,7 @@ const buttonClasses = computed(() => {
classes.push('bg-transparent text-gray-400')
}
} else {
classes.push('cursor-pointer')
if (props.variant === 'primary') {
classes.push(
'bg-primary text-gray-900',

View File

@@ -8,7 +8,5 @@ defineProps({
</script>
<template>
<div :class="['font-bold text-primary', $props.class]">
Piccadilly
</div>
<img src="/images/baker-tilly-logo.svg" alt="Baker Tilly" :class="['h-8', $props.class]" />
</template>

View File

@@ -1,4 +1,6 @@
<script setup>
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import AppLogo from '@/Components/AppLogo.vue'
defineProps({
@@ -7,12 +9,17 @@ defineProps({
default: '',
},
})
const page = usePage()
const logoHref = computed(() => page.props.auth?.logo_href ?? '/')
</script>
<template>
<header class="sticky top-0 z-50 bg-surface border-b border-gray-700">
<div class="px-6 py-4 flex items-center gap-6">
<a :href="logoHref" class="flex items-center">
<AppLogo class="text-2xl" />
</a>
<h1 v-if="title" class="text-xl font-semibold text-white">
{{ title }}
</h1>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed } from 'vue'
import RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
const props = defineProps({
question: {
@@ -10,6 +11,10 @@ const props = defineProps({
type: Object,
default: () => ({ value: null, text_value: '' }),
},
error: {
type: String,
default: null,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -39,6 +44,14 @@ const isTextOnly = computed(() => {
return !hasRadioButtons.value && props.question.details
})
const availableOptions = computed(() => {
const opts = []
if (props.question.has_yes) opts.push({ value: 'yes', label: 'Yes' })
if (props.question.has_no) opts.push({ value: 'no', label: 'No' })
if (props.question.has_na) opts.push({ value: 'not_applicable', label: 'N/A' })
return opts
})
const updateValue = (value) => {
emit('update:modelValue', { ...props.modelValue, value })
}
@@ -49,8 +62,11 @@ const updateTextValue = (event) => {
</script>
<template>
<div class="py-4">
<p class="text-white mb-3">{{ question.text }}</p>
<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) -->
<div v-if="isTextOnly">
@@ -58,50 +74,30 @@ const updateTextValue = (event) => {
:value="modelValue.text_value"
@input="updateTextValue"
rows="3"
class="w-full rounded-lg border border-gray-600 bg-surface px-3 py-2 text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary"
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-gray-600 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-colors duration-200"
placeholder="Enter your response..."
></textarea>
</div>
<!-- Radio button question -->
<div v-if="hasRadioButtons">
<div class="flex flex-wrap gap-4 mb-3">
<label v-if="question.has_yes" class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
<RadioButtonGroup
:modelValue="modelValue.value"
@update:modelValue="updateValue($event)"
:name="`question-${question.id}`"
value="yes"
:checked="modelValue.value === 'yes'"
@change="updateValue('yes')"
class="w-4 h-4 text-primary bg-surface border-gray-600 focus:ring-primary focus:ring-offset-surface"
:options="availableOptions"
class="mb-3"
/>
<span class="text-white">Yes</span>
</label>
<label v-if="question.has_no" class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
:name="`question-${question.id}`"
value="no"
:checked="modelValue.value === 'no'"
@change="updateValue('no')"
class="w-4 h-4 text-primary bg-surface border-gray-600 focus:ring-primary focus:ring-offset-surface"
/>
<span class="text-white">No</span>
</label>
<label v-if="question.has_na" class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
:name="`question-${question.id}`"
value="not_applicable"
:checked="modelValue.value === 'not_applicable'"
@change="updateValue('not_applicable')"
class="w-4 h-4 text-primary bg-surface border-gray-600 focus:ring-primary focus:ring-offset-surface"
/>
<span class="text-white">N/A</span>
</label>
</div>
<!-- Details textarea (conditional) -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-1 max-h-0"
enter-to-class="opacity-100 translate-y-0 max-h-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 max-h-40"
leave-to-class="opacity-0 -translate-y-1 max-h-0"
>
<div v-if="showDetails" class="mt-2">
<label class="block text-sm text-gray-400 mb-1">
Details{{ detailsRequired ? ' (required)' : ' (optional)' }}
@@ -110,10 +106,25 @@ const updateTextValue = (event) => {
:value="modelValue.text_value"
@input="updateTextValue"
rows="2"
class="w-full rounded-lg border border-gray-600 bg-surface px-3 py-2 text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary text-sm"
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-gray-600 focus:border-primary/50 focus:ring-1 focus:ring-primary/20 text-sm transition-colors duration-200"
placeholder="Enter details..."
></textarea>
</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

@@ -0,0 +1,93 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: null,
},
options: {
type: Array,
required: true,
},
name: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const handleChange = (value) => {
emit('update:modelValue', value)
}
const getSegmentClasses = (index) => {
const classes = [
'inline-flex',
'items-center',
'justify-center',
'px-8',
'py-3',
'text-base',
'font-medium',
'select-none',
'transition-all',
'duration-200',
'bg-white/5',
'text-gray-400',
'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',
'peer-focus-visible:ring-2',
'peer-focus-visible:ring-primary',
'peer-focus-visible:ring-offset-2',
'peer-focus-visible:ring-offset-surface',
]
// All except last: add divider
if (index < props.options.length - 1) {
classes.push('border-r', 'border-white/10')
}
// Disabled state
if (props.disabled) {
classes.push('disabled:opacity-50', 'disabled:cursor-not-allowed')
}
return classes.join(' ')
}
</script>
<template>
<div role="radiogroup" :aria-label="name" class="inline-flex rounded-lg overflow-hidden border border-white/10 min-h-[44px]">
<label
v-for="(option, index) in options"
:key="option.value"
class="relative"
>
<input
type="radio"
:name="name"
:value="option.value"
:checked="modelValue === option.value"
:disabled="disabled"
:data-cy="option.value === 'not_applicable' ? 'na' : option.value"
@change="handleChange(option.value)"
class="sr-only peer"
/>
<span :class="getSegmentClasses(index)">
{{ option.label }}
</span>
</label>
</div>
</template>

View File

@@ -43,20 +43,33 @@ const scoreData = computed(() => {
}
}
})
const panelBorderClass = computed(() => {
if (props.score >= 10) return 'border-green-500/20'
if (props.score >= 5) return 'border-amber-500/20'
if (props.score >= 1) return 'border-red-500/20'
return 'border-white/10'
})
</script>
<template>
<div v-if="visible" class="inline-flex items-center gap-3">
<div
v-if="visible"
class="inline-flex items-center gap-3 bg-white/5 backdrop-blur-sm border rounded-xl px-5 py-3 transition-all duration-500"
:class="panelBorderClass"
>
<div class="flex items-baseline gap-2">
<span class="text-4xl font-bold" :class="scoreData.textClass">
<span class="text-3xl font-bold" :class="scoreData.textClass">
{{ score }}
</span>
<span class="text-sm text-gray-400">points</span>
<span class="text-xs uppercase tracking-wider text-gray-500">
points
</span>
</div>
<div
:class="[
scoreData.bgClass,
'px-4 py-2 rounded-lg text-white font-semibold text-sm',
'px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider text-white shadow-sm',
]"
>
{{ scoreData.label }}

View File

@@ -13,7 +13,16 @@ const pageTitle = computed(() => {
<template>
<div class="min-h-screen flex flex-col">
<PageHeader :title="pageTitle" />
<main class="flex-1">
<!-- Growth symbol watermark -->
<img
src="/images/growth-symbol.png"
alt=""
aria-hidden="true"
class="fixed bottom-0 right-0 w-[600px] translate-x-[15%] translate-y-[15%] opacity-[0.03] pointer-events-none select-none z-0"
/>
<main class="flex-1 relative z-10">
<slot />
</main>
</div>

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue'
import { Head } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
defineOptions({ layout: AppLayout })
const props = defineProps({
status: {
type: Number,
required: true,
},
})
const errorMessages = {
403: {
title: 'Forbidden',
description: 'You do not have permission to access this page.',
},
404: {
title: 'Page Not Found',
description: 'The page you are looking for could not be found.',
},
500: {
title: 'Server Error',
description: 'Something went wrong on our end. Please try again later.',
},
503: {
title: 'Service Unavailable',
description: 'We are currently performing maintenance. Please check back soon.',
},
}
const error = computed(() => errorMessages[props.status] ?? {
title: 'Error',
description: 'An unexpected error occurred.',
})
</script>
<template>
<Head :title="error.title" />
<div class="flex items-center justify-center py-16">
<div class="text-center max-w-md mx-auto px-4">
<ExclamationTriangleIcon class="h-16 w-16 text-primary mx-auto mb-6" />
<p class="text-6xl font-bold text-primary mb-4">{{ status }}</p>
<h1 class="text-2xl font-bold text-white mb-2">{{ error.title }}</h1>
<p class="text-gray-400 mb-8">{{ error.description }}</p>
<AppButton size="lg" href="/">
Back to Home
</AppButton>
</div>
</div>
</template>

View File

@@ -1,10 +1,34 @@
<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 AppButton from '@/Components/AppButton.vue'
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 = () => {
router.post('/screening')
}
@@ -16,6 +40,9 @@ const handleContinue = () => {
<div class="flex items-center justify-center py-16">
<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>
<p v-if="userInfo" class="text-gray-400 mb-4">
{{ userInfo }}
</p>
<p class="text-gray-400 mb-4 text-lg">
Baker Tilly International Go/No Go Checklist
</p>
@@ -24,9 +51,12 @@ const handleContinue = () => {
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.
</p>
<AppButton size="lg" @click="handleContinue">
<AppButton v-if="isAuthenticated" size="lg" @click="handleContinue" data-cy="start-screening">
Continue
</AppButton>
<AppButton v-else size="lg" href="/login" external>
Log in
</AppButton>
</div>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More