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_console_messages",
"mcp__playwright__browser_navigate_back", "mcp__playwright__browser_navigate_back",
"mcp__playwright__browser_run_code", "mcp__playwright__browser_run_code",
"mcp__playwright__browser_wait_for" "mcp__playwright__browser_wait_for",
"WebFetch(domain:www.bakertilly.nl)",
"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_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://go-no-go.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -20,12 +20,12 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=go-no-go
# DB_USERNAME=root DB_USERNAME=root
# DB_PASSWORD= DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLogger;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
final class SocialiteController extends Controller final class SocialiteController extends Controller
@@ -27,16 +30,27 @@ public function callback(): RedirectResponse
{ {
$azureUser = Socialite::driver('azure')->user(); $azureUser = Socialite::driver('azure')->user();
$user = User::query()->firstOrCreate( $user = User::query()->updateOrCreate(
['email' => $azureUser->getEmail()], ['email' => $azureUser->getEmail()],
[ [
'name' => $azureUser->getName(), '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); auth()->login($user);
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
return redirect('/'); return redirect('/');
} }
@@ -45,6 +59,8 @@ public function callback(): RedirectResponse
*/ */
public function logout(Request $request): RedirectResponse public function logout(Request $request): RedirectResponse
{ {
ActivityLogger::log('logout', auth()->id());
auth()->logout(); auth()->logout();
$request->session()->invalidate(); $request->session()->invalidate();

View File

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

View File

@@ -6,9 +6,12 @@
use App\Http\Requests\Session\UpdateSessionRequest; use App\Http\Requests\Session\UpdateSessionRequest;
use App\Models\Session; use App\Models\Session;
use App\Services\ActivityLogger;
use App\Services\ScoringService; use App\Services\ScoringService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -26,6 +29,8 @@ public function store(Request $request): RedirectResponse
'status' => 'in_progress', '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); return redirect()->route('sessions.show', $session);
} }
@@ -34,7 +39,7 @@ public function store(Request $request): RedirectResponse
*/ */
public function show(Session $session): Response public function show(Session $session): Response
{ {
$session->load('category'); $session->load('category', 'user');
$questionGroups = $session->category $questionGroups = $session->category
->questionGroups() ->questionGroups()
@@ -42,6 +47,8 @@ public function show(Session $session): Response
->orderBy('sort_order') ->orderBy('sort_order')
->get(); ->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'); $answers = $session->answers()->get()->keyBy('question_id');
$scoringService = new ScoringService; $scoringService = new ScoringService;
@@ -62,16 +69,12 @@ public function update(UpdateSessionRequest $request, Session $session): Redirec
{ {
$validated = $request->validated(); $validated = $request->validated();
if (isset($validated['basic_info'])) { if (Arr::has($validated, 'answers')) {
$session->update(['basic_info' => $validated['basic_info']]); $this->saveAnswers($session, Arr::get($validated, 'answers'));
} }
if (isset($validated['answers'])) { if (Arr::has($validated, 'additional_comments')) {
$this->saveAnswers($session, $validated['answers']); $session->update(['additional_comments' => Arr::get($validated, 'additional_comments')]);
}
if (isset($validated['additional_comments'])) {
$session->update(['additional_comments' => $validated['additional_comments']]);
} }
if ($request->boolean('complete')) { if ($request->boolean('complete')) {
@@ -90,10 +93,15 @@ private function saveAnswers(Session $session, array $answers): void
$session->answers()->updateOrCreate( $session->answers()->updateOrCreate(
['question_id' => (int) $questionId], ['question_id' => (int) $questionId],
[ [
'value' => $answer['value'] ?? null, 'value' => Arr::get($answer, 'value'),
'text_value' => $answer['text_value'] ?? null, '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 private function completeSession(Session $session): RedirectResponse
{ {
$this->validateSessionCompletion($session);
$scoringService = new ScoringService; $scoringService = new ScoringService;
$score = $scoringService->calculateScore($session); $score = $scoringService->calculateScore($session);
$result = $scoringService->determineResult($score); $result = $scoringService->determineResult($score);
@@ -113,9 +123,81 @@ private function completeSession(Session $session): RedirectResponse
'completed_at' => now(), '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); return redirect()->route('sessions.result', $session);
} }
/**
* Validate that all required fields are answered before session completion.
*/
private function validateSessionCompletion(Session $session): void
{
$session->load(['category.questionGroups.questions', 'answers']);
$errors = [];
foreach ($session->category->questionGroups as $questionGroup) {
foreach ($questionGroup->questions as $question) {
$answer = $session->answers->firstWhere('question_id', $question->id);
$this->validateRadioAnswer($question, $answer, $errors);
$this->validateDetailsAnswer($question, $answer, $errors);
}
}
if (Arr::exists($errors, 0)) {
throw ValidationException::withMessages([
'complete' => $errors,
]);
}
}
/**
* Validate that radio button questions have an answer selected.
*/
private function validateRadioAnswer($question, $answer, array &$errors): void
{
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($hasRadioButtons && (! $answer || $answer->value === null)) {
$errors[] = "Question '{$question->text}' requires an answer.";
}
}
/**
* Validate that questions with required details have text values provided.
*/
private function validateDetailsAnswer($question, $answer, array &$errors): void
{
$details = $question->details;
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($details === 'required') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details to be provided.";
}
}
if ($details === 'req_on_yes' && $answer && $answer->value === 'yes') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'Yes'.";
}
}
if ($details === 'req_on_no' && $answer && $answer->value === 'no') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'No'.";
}
}
if (! $hasRadioButtons && $details !== null && $details !== '' && $details !== 'optional') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires a text response.";
}
}
}
/** /**
* Display the final session result. * Display the final session result.
*/ */

View File

@@ -6,7 +6,9 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use Inertia\Middleware; use Inertia\Middleware;
use Laravel\Nova\Nova;
final class HandleInertiaRequests extends Middleware final class HandleInertiaRequests extends Middleware
{ {
@@ -32,6 +34,7 @@ public function share(Request $request): array
...parent::share($request), ...parent::share($request),
'auth' => [ 'auth' => [
'user' => $this->getAuthenticatedUser(), 'user' => $this->getAuthenticatedUser(),
'logo_href' => $this->getLogoHref(),
], ],
'flash' => [ 'flash' => [
'success' => fn () => Arr::get($request->session()->all(), 'success'), 'success' => fn () => Arr::get($request->session()->all(), 'success'),
@@ -55,6 +58,22 @@ private function getAuthenticatedUser(): ?array
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'job_title' => $user->job_title,
'company_name' => $user->company_name,
]; ];
} }
/**
* 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 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 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 public function rules(): array
{ {
return [ 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' => ['sometimes', 'array'],
'answers.*.value' => ['nullable', 'string', 'in:yes,no,not_applicable'], 'answers.*.value' => ['nullable', 'string', 'in:yes,no,not_applicable'],
'answers.*.text_value' => ['nullable', 'string', 'max:10000'], 'answers.*.text_value' => ['nullable', 'string', 'max:10000'],
@@ -41,20 +36,6 @@ public function rules(): array
public function messages(): array public function messages(): array
{ {
return [ 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.array' => 'Answers must be a valid data structure.',
'answers.*.value.in' => 'Answer value must be yes, no, or not_applicable.', 'answers.*.value.in' => 'Answer value must be yes, no, or not_applicable.',
'answers.*.text_value.string' => 'Answer text must be text.', 'answers.*.text_value.string' => 'Answer text must be text.',

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Question extends Model final class Question extends Model
{ {
use HasFactory;
/** /**
* Fillable attributes for mass assignment. * Fillable attributes for mass assignment.
*/ */
@@ -45,4 +49,12 @@ public function questionGroup(): BelongsTo
{ {
return $this->belongsTo(QuestionGroup::class); 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; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
final class QuestionGroup extends Model final class QuestionGroup extends Model
{ {
use HasFactory;
/** /**
* Fillable attributes for mass assignment. * 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; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
final class Screening extends Model final class Screening extends Model
{ {
use HasFactory;
/** /**
* Fillable attributes for mass assignment. * Fillable attributes for mass assignment.
*/ */

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -23,6 +24,13 @@ final class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'azure_id',
'photo',
'job_title',
'department',
'company_name',
'phone',
'role_id',
]; ];
/** /**
@@ -33,6 +41,7 @@ final class User extends Authenticatable
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'azure_id',
]; ];
/** /**
@@ -45,9 +54,18 @@ protected function casts(): array
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', '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. * 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 <?php
declare(strict_types=1);
namespace App\Nova\Dashboards; namespace App\Nova\Dashboards;
use Laravel\Nova\Cards\Help; use App\Nova\Metrics\ScreeningsTrend;
use App\Nova\Metrics\SessionsTrend;
use App\Nova\Metrics\TotalScreenings;
use App\Nova\Metrics\TotalSessions;
use Laravel\Nova\Dashboards\Main as Dashboard; use Laravel\Nova\Dashboards\Main as Dashboard;
class Main extends Dashboard final class Main extends Dashboard
{ {
/** /**
* Get the cards for the dashboard. * Get the cards for the dashboard.
@@ -15,7 +20,10 @@ class Main extends Dashboard
public function cards(): array public function cards(): array
{ {
return [ return [
new Help, new TotalSessions,
new TotalScreenings,
new SessionsTrend,
new ScreeningsTrend,
]; ];
} }
} }

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 abstract class Resource extends NovaResource
{ {
public static function perPageOptions()
{
return [50, 100, 150];
}
public static function perPageViaRelationshipOptions()
{
return [10, 25, 50];
}
/** /**
* Build an "index" query for the given resource. * Build an "index" query for the given resource.
*/ */

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 <?php
declare(strict_types=1);
namespace App\Nova; namespace App\Nova;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Laravel\Nova\Auth\PasswordValidationRules; use Laravel\Nova\Auth\PasswordValidationRules;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\ID; use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Password; use Laravel\Nova\Fields\Password;
use Laravel\Nova\Fields\Text; use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest; use Laravel\Nova\Http\Requests\NovaRequest;
class User extends Resource final class User extends Resource
{ {
use PasswordValidationRules; use PasswordValidationRules;
@@ -33,7 +36,7 @@ class User extends Resource
* @var array * @var array
*/ */
public static $search = [ public static $search = [
'id', 'name', 'email', 'id', 'name', 'email', 'department', 'job_title',
]; ];
/** /**
@@ -46,20 +49,55 @@ public function fields(NovaRequest $request): array
return [ return [
ID::make()->sortable(), 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') Text::make('Name')
->sortable() ->sortable()
->rules('required', 'max:255'), ->rules('required', 'max:255')
->help('The user\'s full name, imported from Azure AD when they first log in.'),
Text::make('Email') Text::make('Email')
->sortable() ->sortable()
->rules('required', 'email', 'max:254') ->rules('required', 'email', 'max:254')
->creationRules('unique:users,email') ->creationRules('unique:users,email')
->updateRules('unique:users,email,{{resourceId}}'), ->updateRules('unique:users,email,{{resourceId}}')
->help('The user\'s email address, used to identify them when logging in via Azure AD.'),
Text::make('Azure ID', 'azure_id')
->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') Password::make('Password')
->onlyOnForms() ->onlyOnForms()
->creationRules($this->passwordRules()) ->creationRules($this->passwordRules())
->updateRules($this->optionalPasswordRules()), ->updateRules($this->optionalPasswordRules())
->help('Only needed for admin panel access. Regular users log in via Azure AD and do not need a password.'),
]; ];
} }

View File

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

View File

@@ -3,8 +3,18 @@
namespace App\Providers; namespace App\Providers;
use App\Models\User; use App\Models\User;
use App\Nova\CategoryResource;
use App\Nova\Dashboards\Main;
use App\Nova\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 Illuminate\Support\Facades\Gate;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Nova\Menu\MenuItem;
use Laravel\Nova\Menu\MenuSection;
use Laravel\Nova\Nova; use Laravel\Nova\Nova;
use Laravel\Nova\NovaApplicationServiceProvider; use Laravel\Nova\NovaApplicationServiceProvider;
@@ -17,7 +27,27 @@ public function boot(): void
{ {
parent::boot(); 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 protected function routes(): void
{ {
Nova::routes() Nova::routes()
->withAuthenticationRoutes(default: true) ->withAuthenticationRoutes(default: false)
->withPasswordResetRoutes() ->withPasswordResetRoutes()
->withEmailVerificationRoutes() ->withEmailVerificationRoutes()
->register(); ->register();
@@ -54,9 +84,7 @@ protected function routes(): void
protected function gate(): void protected function gate(): void
{ {
Gate::define('viewNova', function (User $user) { Gate::define('viewNova', function (User $user) {
return in_array($user->email, [ return $user->role?->name === 'admin';
'jonathan@blijnder.nl',
]);
}); });
} }

View File

@@ -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 { ->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(); })->create();

View File

@@ -6,12 +6,14 @@
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.4",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/nova": "^5.0", "laravel/nova": "^5.0",
"laravel/socialite": "^5.24", "laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1",
"maatwebsite/laravel-nova-excel": "^1.3",
"socialiteproviders/microsoft-azure": "^5.2"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "846969b15fec689e62554bd1be9c57a8", "content-hash": "535a9303784c8d25d1d3b32702506cc9",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -247,6 +247,85 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "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", "name": "composer/semver",
"version": "3.4.4", "version": "3.4.4",
@@ -747,6 +826,67 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "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", "name": "firebase/php-jwt",
"version": "v7.0.2", "version": "v7.0.2",
@@ -2149,6 +2289,58 @@
}, },
"time": "2025-12-19T19:16:45+00:00" "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", "name": "league/commonmark",
"version": "2.8.0", "version": "2.8.0",
@@ -2784,6 +2976,330 @@
], ],
"time": "2026-01-15T06:54:53+00:00" "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", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3562,6 +4078,114 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "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", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@@ -4558,6 +5182,131 @@
], ],
"time": "2025-02-18T12:50:31+00:00" "time": "2025-02-18T12:50:31+00:00"
}, },
{
"name": "socialiteproviders/manager",
"version": "v4.8.1",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
"laravel/socialite": "^5.5",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.2",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SocialiteProviders\\Manager\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SocialiteProviders\\Manager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andy Wendt",
"email": "andy@awendt.com"
},
{
"name": "Anton Komarev",
"email": "a.komarev@cybercog.su"
},
{
"name": "Miguel Piedrafita",
"email": "soy@miguelpiedrafita.com"
},
{
"name": "atymic",
"email": "atymicq@gmail.com",
"homepage": "https://atymic.dev"
}
],
"description": "Easily add new or override built-in providers in Laravel Socialite.",
"homepage": "https://socialiteproviders.com",
"keywords": [
"laravel",
"manager",
"oauth",
"providers",
"socialite"
],
"support": {
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
"time": "2025-02-24T19:33:30+00:00"
},
{
"name": "socialiteproviders/microsoft-azure",
"version": "5.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Microsoft-Azure.git",
"reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Microsoft-Azure/zipball/453d62c9d7e3b3b76e94c913fb46e68a33347b16",
"reference": "453d62c9d7e3b3b76e94c913fb46e68a33347b16",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"socialiteproviders/manager": "^4.4"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Azure\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Hemmings",
"email": "chris@hemmin.gs"
}
],
"description": "Microsoft Azure OAuth2 Provider for Laravel Socialite",
"keywords": [
"azure",
"laravel",
"microsoft",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/microsoft-azure",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2024-03-15T03:02:10+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v8.0.0", "version": "v8.0.0",
@@ -10180,7 +10929,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.9.0"

159
config/fortify.php Normal file
View File

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

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

View File

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

View File

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

View File

@@ -1,22 +1,30 @@
<?php <?php
declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; 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 public function run(): void
{ {
$adminRole = Role::where('name', 'admin')->first();
User::factory()->create([ User::factory()->create([
'name' => 'Jonathan', 'name' => 'Jonathan',
'email' => 'jonathan@blijnder.nl', 'email' => 'jonathan.van.rij@agerion.nl',
'password' => bcrypt('secret'), 'password' => bcrypt('secret'),
'email_verified_at' => now(), 'email_verified_at' => now(),
'role_id' => $adminRole->id,
'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 ## 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. 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 ## 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. 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 ### 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 ### Agents

View File

@@ -9,6 +9,7 @@ ### Color Palette
| Token | Hex | RGB | Tailwind Class | Usage | | Token | Hex | RGB | Tailwind Class | Usage |
|-------------|-----------|------------------|-----------------|---------------------------------------------| |-------------|-----------|------------------|-----------------|---------------------------------------------|
| Primary | `#d1ec51` | 209, 236, 81 | `bg-primary`, `text-primary` | Buttons (default), accents, highlights | | 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 | | 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 | | Background | `#2b303a` | 43, 48, 58 | `bg-surface` | Page background, card backgrounds |
| Text | `#ffffff` | 255, 255, 255 | `text-white` | Primary body text on dark background | | Text | `#ffffff` | 255, 255, 255 | `text-white` | Primary body text on dark background |
@@ -96,6 +97,23 @@ ### QuestionCard
- `has_na` -- show N/A button - `has_na` -- show N/A button
- `details` -- show a text input for additional notes - `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 ## Icons
Heroicons is the only icon library. No other icon packages. 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", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite" "dev": "vite",
"cy:open": "cypress open",
"cy:run": "cypress run",
"test:e2e": "cypress run"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"cypress": "^15.9.0",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "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'; 'Segoe UI Symbol', 'Noto Color Emoji';
--color-primary: #d1ec51; --color-primary: #d1ec51;
--color-primary-dark: #b5d136;
--color-secondary: #00b7b3; --color-secondary: #00b7b3;
--color-surface: #2b303a; --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, type: String,
default: undefined, default: undefined,
}, },
external: {
type: Boolean,
default: false,
},
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -32,7 +36,11 @@ const emit = defineEmits(['click'])
const isDisabled = computed(() => props.disabled || props.loading) const isDisabled = computed(() => props.disabled || props.loading)
const component = computed(() => props.href ? Link : 'button') const component = computed(() => {
if (props.href && props.external) return 'a'
if (props.href) return Link
return 'button'
})
const buttonClasses = computed(() => { const buttonClasses = computed(() => {
const classes = [ const classes = [
@@ -44,11 +52,11 @@ const buttonClasses = computed(() => {
// Size classes // Size classes
if (props.size === 'sm') { 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') { } 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') { } else if (props.size === 'lg') {
classes.push('px-7 py-3 text-lg') classes.push('px-8 py-3.5 text-lg')
} }
// Variant classes // Variant classes
@@ -62,6 +70,7 @@ const buttonClasses = computed(() => {
classes.push('bg-transparent text-gray-400') classes.push('bg-transparent text-gray-400')
} }
} else { } else {
classes.push('cursor-pointer')
if (props.variant === 'primary') { if (props.variant === 'primary') {
classes.push( classes.push(
'bg-primary text-gray-900', 'bg-primary text-gray-900',

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
const props = defineProps({ const props = defineProps({
question: { question: {
@@ -10,6 +11,10 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({ value: null, text_value: '' }), default: () => ({ value: null, text_value: '' }),
}, },
error: {
type: String,
default: null,
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -39,6 +44,14 @@ const isTextOnly = computed(() => {
return !hasRadioButtons.value && props.question.details 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) => { const updateValue = (value) => {
emit('update:modelValue', { ...props.modelValue, value }) emit('update:modelValue', { ...props.modelValue, value })
} }
@@ -49,8 +62,11 @@ const updateTextValue = (event) => {
</script> </script>
<template> <template>
<div class="py-4"> <div
<p class="text-white mb-3">{{ question.text }}</p> 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) --> <!-- Text-only question (no radio buttons) -->
<div v-if="isTextOnly"> <div v-if="isTextOnly">
@@ -58,62 +74,57 @@ const updateTextValue = (event) => {
:value="modelValue.text_value" :value="modelValue.text_value"
@input="updateTextValue" @input="updateTextValue"
rows="3" 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..." placeholder="Enter your response..."
></textarea> ></textarea>
</div> </div>
<!-- Radio button question --> <!-- Radio button question -->
<div v-if="hasRadioButtons"> <div v-if="hasRadioButtons">
<div class="flex flex-wrap gap-4 mb-3"> <RadioButtonGroup
<label v-if="question.has_yes" class="flex items-center gap-2 cursor-pointer"> :modelValue="modelValue.value"
<input @update:modelValue="updateValue($event)"
type="radio" :name="`question-${question.id}`"
:name="`question-${question.id}`" :options="availableOptions"
value="yes" class="mb-3"
: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"
/>
<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) --> <!-- Details textarea (conditional) -->
<div v-if="showDetails" class="mt-2"> <Transition
<label class="block text-sm text-gray-400 mb-1"> enter-active-class="transition-all duration-300 ease-out"
Details{{ detailsRequired ? ' (required)' : ' (optional)' }} enter-from-class="opacity-0 -translate-y-1 max-h-0"
</label> enter-to-class="opacity-100 translate-y-0 max-h-40"
<textarea leave-active-class="transition-all duration-200 ease-in"
:value="modelValue.text_value" leave-from-class="opacity-100 translate-y-0 max-h-40"
@input="updateTextValue" leave-to-class="opacity-0 -translate-y-1 max-h-0"
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" <div v-if="showDetails" class="mt-2">
placeholder="Enter details..." <label class="block text-sm text-gray-400 mb-1">
></textarea> Details{{ detailsRequired ? ' (required)' : ' (optional)' }}
</div> </label>
<textarea
:value="modelValue.text_value"
@input="updateTextValue"
rows="2"
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> </div>
<!-- Error message -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"
>
<p v-if="error" class="text-red-400 text-sm mt-2 bg-red-500/10 px-3 py-2 rounded-md">
{{ error }}
</p>
</Transition>
</div> </div>
</template> </template>

View File

@@ -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> </script>
<template> <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"> <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 }} {{ score }}
</span> </span>
<span class="text-sm text-gray-400">points</span> <span class="text-xs uppercase tracking-wider text-gray-500">
points
</span>
</div> </div>
<div <div
:class="[ :class="[
scoreData.bgClass, 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 }} {{ scoreData.label }}

View File

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

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