Compare commits

..

2 Commits

Author SHA1 Message Date
c693cde038 adds logging and policies 2026-02-03 11:47:08 +01:00
9583b7030c fixes on step 10 2026-02-03 11:12:41 +01:00
27 changed files with 2161 additions and 99 deletions

View File

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

View File

@@ -6,8 +6,10 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ActivityLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Laravel\Socialite\Facades\Socialite;
final class SocialiteController extends Controller
@@ -37,6 +39,8 @@ public function callback(): RedirectResponse
auth()->login($user);
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser, 'companyName')]);
return redirect('/');
}
@@ -45,6 +49,8 @@ public function callback(): RedirectResponse
*/
public function logout(Request $request): RedirectResponse
{
ActivityLogger::log('logout', auth()->id());
auth()->logout();
$request->session()->invalidate();

View File

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

View File

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

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

@@ -0,0 +1,127 @@
<?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 Maatwebsite\LaravelNovaExcel\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;
/**
* 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()
->rules('required'),
BelongsTo::make('Question', 'question', QuestionResource::class)
->sortable()
->filterable()
->rules('required'),
Text::make('Value')
->sortable()
->filterable()
->copyable()
->rules('nullable', 'max:255'),
Textarea::make('Text Value')
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
];
}
/**
* 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,124 @@
<?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 Maatwebsite\LaravelNovaExcel\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 = false;
/**
* 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()
->rules('required', 'max:255'),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
HasMany::make('Question Groups', 'questionGroups', QuestionGroupResource::class),
HasMany::make('Sessions', 'sessions', SessionResource::class),
];
}
/**
* 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,
];
}
}

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

@@ -0,0 +1,138 @@
<?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 Maatwebsite\LaravelNovaExcel\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';
/**
* 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'),
BelongsTo::make('Session', 'session', SessionResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
Text::make('Action')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
Code::make('Metadata')
->json()
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
];
}
/**
* 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,135 @@
<?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 Maatwebsite\LaravelNovaExcel\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 = false;
/**
* 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'),
Text::make('Name')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer'),
Textarea::make('Description')
->rules('nullable'),
Textarea::make('Scoring Instructions')
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
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,158 @@
<?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\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\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 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('Question Group', 'questionGroup', QuestionGroupResource::class)
->sortable()
->filterable()
->readonly(),
Textarea::make('Text')
->rules('required')
->updateRules('required'),
Boolean::make('Has Yes')
->sortable()
->filterable()
->readonly(),
Boolean::make('Has No')
->sortable()
->filterable()
->readonly(),
Boolean::make('Has NA', 'has_na')
->sortable()
->filterable()
->readonly(),
Text::make('Details')
->sortable()
->filterable()
->copyable()
->readonly(),
Number::make('Sort Order')
->sortable()
->filterable()
->copyable()
->readonly(),
Boolean::make('Is Scored')
->sortable()
->filterable()
->readonly(),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
];
}
/**
* 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,134 @@
<?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 Maatwebsite\LaravelNovaExcel\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';
/**
* 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'),
Number::make('Score')
->sortable()
->filterable()
->copyable()
->rules('required', 'integer'),
Boolean::make('Passed')
->sortable()
->filterable()
->rules('required', 'boolean'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
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,168 @@
<?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\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 Maatwebsite\LaravelNovaExcel\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';
/**
* 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'),
BelongsTo::make('Category', 'category', CategoryResource::class)
->sortable()
->filterable()
->rules('required'),
BelongsTo::make('Screening', 'screening', ScreeningResource::class)
->nullable()
->sortable()
->filterable()
->rules('nullable'),
Text::make('Status')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
Number::make('Score')
->sortable()
->filterable()
->copyable()
->rules('nullable', 'integer'),
Text::make('Result')
->sortable()
->filterable()
->copyable()
->rules('nullable', 'max:255'),
Code::make('Basic Info', 'basic_info')
->json()
->rules('nullable'),
Textarea::make('Additional Comments')
->rules('nullable'),
DateTime::make('Completed At')
->sortable()
->filterable()
->rules('nullable'),
DateTime::make('Created At')
->exceptOnForms()
->sortable()
->filterable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable()
->filterable(),
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,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Illuminate\Http\Request;
@@ -9,7 +11,7 @@
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
class User extends Resource
final class User extends Resource
{
use PasswordValidationRules;

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 false;
}
/**
* Determine whether the user can update the category.
*/
public function update(User $user, Category $category): bool
{
return false;
}
/**
* 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 false;
}
/**
* Determine whether the user can update the question group.
*/
public function update(User $user, QuestionGroup $questionGroup): bool
{
return false;
}
/**
* 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 false;
}
/**
* 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

@@ -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

@@ -11,7 +11,8 @@
"laravel/framework": "^12.0",
"laravel/nova": "^5.0",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"maatwebsite/laravel-nova-excel": "^1.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",

626
composer.lock generated
View File

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

View File

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

View File

@@ -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

View File

@@ -62,6 +62,7 @@ const buttonClasses = computed(() => {
classes.push('bg-transparent text-gray-400')
}
} else {
classes.push('cursor-pointer')
if (props.variant === 'primary') {
classes.push(
'bg-primary text-gray-900',

View File

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

View File

@@ -27,22 +27,6 @@ const props = defineProps({
},
})
// Basic info form (unchanged from Step 8)
const basicInfoForm = useForm({
basic_info: {
client_name: props.session.basic_info?.client_name ?? '',
client_contact: props.session.basic_info?.client_contact ?? '',
lead_firm_name: props.session.basic_info?.lead_firm_name ?? '',
lead_firm_contact: props.session.basic_info?.lead_firm_contact ?? '',
},
})
const saveBasicInfo = () => {
basicInfoForm.put(`/sessions/${props.session.id}`, {
preserveScroll: true,
})
}
// Answer management
const answerData = reactive({})
@@ -122,77 +106,19 @@ const hasScoredAnswers = computed(() => {
<ScoreIndicator :score="score" :visible="hasScoredAnswers" />
</div>
<!-- Basic Info Section (unchanged from Step 8) -->
<!-- User Info Section -->
<div class="bg-surface/50 rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold text-white mb-4">Basic Information</h2>
<p class="text-gray-400 text-sm mb-6">All fields are required before you can proceed to the questionnaire.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="client_name" class="block text-sm font-medium text-gray-400 mb-1">Client Name</label>
<input
id="client_name"
v-model="basicInfoForm.basic_info.client_name"
type="text"
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"
placeholder="Enter client name"
/>
<p v-if="basicInfoForm.errors['basic_info.client_name']" class="text-red-500 text-sm mt-1">
{{ basicInfoForm.errors['basic_info.client_name'] }}
</p>
<span class="block text-sm font-medium text-gray-400 mb-1">Name</span>
<span class="text-white">{{ session.user.name }}</span>
</div>
<div>
<label for="client_contact" class="block text-sm font-medium text-gray-400 mb-1">Client Contact</label>
<input
id="client_contact"
v-model="basicInfoForm.basic_info.client_contact"
type="text"
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"
placeholder="Enter client contact"
/>
<p v-if="basicInfoForm.errors['basic_info.client_contact']" class="text-red-500 text-sm mt-1">
{{ basicInfoForm.errors['basic_info.client_contact'] }}
</p>
<span class="block text-sm font-medium text-gray-400 mb-1">Email</span>
<span class="text-white">{{ session.user.email }}</span>
</div>
<div>
<label for="lead_firm_name" class="block text-sm font-medium text-gray-400 mb-1">Lead Firm Name</label>
<input
id="lead_firm_name"
v-model="basicInfoForm.basic_info.lead_firm_name"
type="text"
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"
placeholder="Enter lead firm name"
/>
<p v-if="basicInfoForm.errors['basic_info.lead_firm_name']" class="text-red-500 text-sm mt-1">
{{ basicInfoForm.errors['basic_info.lead_firm_name'] }}
</p>
</div>
<div>
<label for="lead_firm_contact" class="block text-sm font-medium text-gray-400 mb-1">Lead Firm Contact</label>
<input
id="lead_firm_contact"
v-model="basicInfoForm.basic_info.lead_firm_contact"
type="text"
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"
placeholder="Enter lead firm contact"
/>
<p v-if="basicInfoForm.errors['basic_info.lead_firm_contact']" class="text-red-500 text-sm mt-1">
{{ basicInfoForm.errors['basic_info.lead_firm_contact'] }}
</p>
</div>
</div>
<div class="flex justify-end mt-6">
<AppButton
@click="saveBasicInfo"
:loading="basicInfoForm.processing"
:disabled="basicInfoForm.processing"
>
Save Basic Info
</AppButton>
</div>
</div>