Compare commits

..

20 Commits

Author SHA1 Message Date
ede31b15cb replaces factories with db facade 2026-02-24 13:34:11 +01:00
aad1d8a2b2 adds back button 2026-02-24 12:41:00 +01:00
3cddb1c609 changes the seeder 2026-02-24 12:23:18 +01:00
e44ef5fddc Fixes the audit markdown, question, description I'll add some audit question seeder 2026-02-24 11:47:10 +01:00
e98ca8f00c adds excel sheets with question designs 2026-02-24 11:03:27 +01:00
0327b95568 change queue time and set CloseSessionsJob 2026-02-19 15:22:39 +01:00
9d61186c72 checks cron 2026-02-19 15:16:33 +01:00
953afd02e6 improvements 2026-02-19 15:10:43 +01:00
f1824ff752 improvements 2026-02-19 14:32:42 +01:00
78c51d55b5 adds help texts 2026-02-16 16:00:16 +01:00
514f1cb483 excel export works 2026-02-16 15:34:30 +01:00
77edd1b666 kind of done 2026-02-16 15:17:33 +01:00
fb1c28a0ba fixing bugs 2026-02-16 15:09:41 +01:00
c39b8085af adds validation 2026-02-16 13:41:25 +01:00
eb43b35873 fixes issues 2026-02-16 13:37:37 +01:00
f57bdd68da role check 2026-02-16 12:56:56 +01:00
e4b3689e64 login for testing 2026-02-16 12:49:10 +01:00
84355f2463 This is a root fix. 2026-02-16 12:44:35 +01:00
e4259978de Shows data and adds some tests for the OAuth check 2026-02-16 12:24:50 +01:00
9a10ff4727 adds the role and I'll go ahead and link and socialite 2026-02-16 12:16:53 +01:00
70 changed files with 4366 additions and 214 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

View File

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

View File

@@ -8,11 +8,14 @@
use App\Models\Session; use App\Models\Session;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
use App\Services\ScoringService; use App\Services\ScoringService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response as InertiaResponse;
final class SessionController extends Controller final class SessionController extends Controller
{ {
@@ -36,7 +39,7 @@ public function store(Request $request): RedirectResponse
/** /**
* Display the session questionnaire with category, question groups, questions, and existing answers. * Display the session questionnaire with category, question groups, questions, and existing answers.
*/ */
public function show(Session $session): Response public function show(Session $session): InertiaResponse
{ {
$session->load('category', 'user'); $session->load('category', 'user');
@@ -50,14 +53,10 @@ public function show(Session $session): Response
$answers = $session->answers()->get()->keyBy('question_id'); $answers = $session->answers()->get()->keyBy('question_id');
$scoringService = new ScoringService;
$score = $scoringService->calculateScore($session);
return Inertia::render('Session/Show', [ return Inertia::render('Session/Show', [
'session' => $session, 'session' => $session,
'questionGroups' => $questionGroups, 'questionGroups' => $questionGroups,
'answers' => $answers, 'answers' => $answers,
'score' => $score,
]); ]);
} }
@@ -66,6 +65,8 @@ public function show(Session $session): Response
*/ */
public function update(UpdateSessionRequest $request, Session $session): RedirectResponse public function update(UpdateSessionRequest $request, Session $session): RedirectResponse
{ {
sleep(3);
$validated = $request->validated(); $validated = $request->validated();
if (Arr::has($validated, 'answers')) { if (Arr::has($validated, 'answers')) {
@@ -109,6 +110,8 @@ private function saveAnswers(Session $session, array $answers): void
*/ */
private function completeSession(Session $session): RedirectResponse private function completeSession(Session $session): RedirectResponse
{ {
$this->validateSessionCompletion($session);
$scoringService = new ScoringService; $scoringService = new ScoringService;
$score = $scoringService->calculateScore($session); $score = $scoringService->calculateScore($session);
$result = $scoringService->determineResult($score); $result = $scoringService->determineResult($score);
@@ -125,10 +128,80 @@ private function completeSession(Session $session): RedirectResponse
return redirect()->route('sessions.result', $session); return redirect()->route('sessions.result', $session);
} }
/**
* Validate that all required fields are answered before session completion.
*/
private function validateSessionCompletion(Session $session): void
{
$session->load(['category.questionGroups.questions', 'answers']);
$errors = [];
foreach ($session->category->questionGroups as $questionGroup) {
foreach ($questionGroup->questions as $question) {
$answer = $session->answers->firstWhere('question_id', $question->id);
$this->validateRadioAnswer($question, $answer, $errors);
$this->validateDetailsAnswer($question, $answer, $errors);
}
}
if (Arr::exists($errors, 0)) {
throw ValidationException::withMessages([
'complete' => $errors,
]);
}
}
/**
* Validate that radio button questions have an answer selected.
*/
private function validateRadioAnswer($question, $answer, array &$errors): void
{
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($hasRadioButtons && (! $answer || $answer->value === null)) {
$errors[] = "Question '{$question->text}' requires an answer.";
}
}
/**
* Validate that questions with required details have text values provided.
*/
private function validateDetailsAnswer($question, $answer, array &$errors): void
{
$details = $question->details;
$hasRadioButtons = $question->has_yes || $question->has_no || $question->has_na;
if ($details === 'required') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details to be provided.";
}
}
if ($details === 'req_on_yes' && $answer && $answer->value === 'yes') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'Yes'.";
}
}
if ($details === 'req_on_no' && $answer && $answer->value === 'no') {
if (empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires details when answered 'No'.";
}
}
if (! $hasRadioButtons && $details !== null && $details !== '' && $details !== 'optional') {
if (! $answer || empty(trim(Arr::get($answer->toArray(), 'text_value', '')))) {
$errors[] = "Question '{$question->text}' requires a text response.";
}
}
}
/** /**
* Display the final session result. * Display the final session result.
*/ */
public function result(Session $session): Response public function result(Session $session): InertiaResponse
{ {
$session->load('category'); $session->load('category');
@@ -139,4 +212,30 @@ public function result(Session $session): Response
'categoryName' => $session->category->name, 'categoryName' => $session->category->name,
]); ]);
} }
/**
* Generate and download a PDF export of the completed session result.
*/
public function pdf(Session $session): Response
{
abort_unless($session->user_id === auth()->id(), 403);
abort_unless($session->status === 'completed', 403);
$session->load(['user', 'category', 'answers.question']);
$questionGroups = $session->category
->questionGroups()
->with(['questions' => fn ($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
ActivityLogger::log('session_pdf_downloaded', $session->user_id, sessionId: $session->id, categoryId: $session->category_id);
$pdf = Pdf::loadView('pdf.session-result', [
'session' => $session,
'questionGroups' => $questionGroups,
]);
return $pdf->download("go-no-go-{$session->id}.pdf");
}
} }

View File

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

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Session;
use App\Services\ActivityLogger;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
final class CloseSessionsJob implements ShouldQueue
{
use Queueable;
/**
* Find all in-progress sessions idle for more than 12 hours and mark them as
* 'unfinished' status, logging each closure individually via ActivityLogger.
*/
public function handle(): void
{
Session::query()
->where('status', 'in_progress')
->where('created_at', '<', now()->subHours(12))
->each(function (Session $session): void {
$session->update([
'status' => 'unfinished',
'completed_at' => now(),
]);
ActivityLogger::log(
'session_auto_closed',
$session->user_id,
sessionId: $session->id,
categoryId: $session->category_id,
metadata: ['reason' => 'idle_12h'],
);
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class LogAppVersionJob implements ShouldQueue
{
use Queueable;
/**
* Log the application version.
*/
public function handle(): void
{
$version = config('app.version', 'unknown');
Log::info("Application version: {$version}");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,9 @@
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule): void {
$schedule->job(\App\Jobs\CloseSessionsJob::class)->hourly();
})
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,

View File

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

645
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c5908b1cf6b95103d6009afd8de09581", "content-hash": "0c0fc2f7a9b1735524227daa79192e95",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -61,6 +61,83 @@
}, },
"time": "2025-11-19T17:15:36+00:00" "time": "2025-11-19T17:15:36+00:00"
}, },
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9|^10",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-02-13T15:07:54+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.14.4", "version": "0.14.4",
@@ -695,6 +772,161 @@
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2024-02-05T11:56:58+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
},
"time": "2025-10-29T12:43:30+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
@@ -3300,6 +3532,73 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -5182,6 +5481,205 @@
], ],
"time": "2025-02-18T12:50:31+00:00" "time": "2025-02-18T12:50:31+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.1.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.28 || 2.1.25",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.7",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6",
"phpunit/phpunit": "8.5.46",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.1.7",
"rector/type-perfect": "1.0.0 || 2.1.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.2.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0"
},
"time": "2025-09-14T07:37:21+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",
@@ -7842,6 +8340,149 @@
], ],
"time": "2026-01-01T22:13:48+00:00" "time": "2026-01-01T22:13:48+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",
@@ -10804,7 +11445,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"

View File

@@ -15,6 +15,8 @@
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'Laravel'),
'version' => '1.0.0',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment

159
config/fortify.php Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,413 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Seeds question groups and questions for the Audit category.
* Assumes the Audit category already exists (created by CategorySeeder).
*/
final class AuditQuestionSeeder extends Seeder
{
/**
* Seed all Audit question groups and their questions.
*/
public function run(): void
{
$categoryId = DB::table('categories')->where('name', 'Audit')->value('id');
if ($categoryId === null) {
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Audit',
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->seedOpportunityDetails($categoryId);
$this->seedClientBackgroundAndHistory($categoryId);
$this->seedFinancialInformation($categoryId);
$this->seedRegulatoryCompliance($categoryId);
$this->seedRiskAssessment($categoryId);
$this->seedResourceAllocation($categoryId);
$this->seedReportngRequirements($categoryId);
}
/**
* Seed Group 1: Opportunity Details (not scored, no answer options).
*/
private function seedOpportunityDetails(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Opportunity Details',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What sort of audit opportunity is it?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'How many locations involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'List any locations included in this opportunity where we do not have a Baker Tilly firm.',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Where is the client HQ?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 4,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Who is the competition?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 5,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 2: Client Background and History (scored, Yes/No options).
*/
private function seedClientBackgroundAndHistory(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Client Background and History',
'sort_order' => 2,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What is the client\'s business and industry?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'There have been no significant changes in the client\'s business operations or structure recently?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Does the sector and/or client come with a reputation which we are comfortable that Baker Tilly is associated with?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => null,
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any previous audit reports or findings that need to be considered?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 3: Financial Information (scored, Yes/No options).
*/
private function seedFinancialInformation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Financial Information',
'sort_order' => 3,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Has the client provided financial statements or balance sheet?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are the client\'s financial statements complete and accurate?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 4: Regulatory Compliance (scored, Yes/No options).
*/
private function seedRegulatoryCompliance(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Regulatory Compliance',
'sort_order' => 4,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Does the client comply with all relevant regulatory requirements and standards?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'The client has no pending legal or regulatory issues that you know of that could impact the audit?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'The client has been subject to no regulatory investigations or penalties?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 5: Risk Assessment (scored, Yes/No options).
*/
private function seedRiskAssessment(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Risk Assessment',
'sort_order' => 5,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'There are no key risks associated with the audit?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have you completed a conflict check?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are you and other BTI member firms independent withi the meaning of local and IESBA rules?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 6: Resource Allocation (scored, mixed Q1 has no answer options and is not scored).
*/
private function seedResourceAllocation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Resource Allocation',
'sort_order' => 6,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What resources are required for the audit (personnel, time, budget)?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Does your firm have the scale, seniority and degree of expertise available at the riht time to report in accordance with the client\'s schedule?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 7: Reportng Requirements (scored, Yes/No options group name preserves Excel typo).
*/
private function seedReportngRequirements(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Reportng Requirements',
'sort_order' => 7,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Do we understand reporting rules, regulatory environment and stakeholder expectations?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@@ -18,8 +18,11 @@ public function run(): void
{ {
$this->call([ $this->call([
JonathanSeeder::class, JonathanSeeder::class,
CategorySeeder::class, AuditQuestionSeeder::class,
QuestionSeeder::class, DigitalSolutionsQuestionSeeder::class,
LegalQuestionSeeder::class,
OutsourceQuestionSeeder::class,
TaxQuestionSeeder::class,
]); ]);
} }
} }

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Seeds question groups and questions for the Digital Solutions category.
* Creates the category if it does not already exist.
*/
final class DigitalSolutionsQuestionSeeder extends Seeder
{
/**
* Seed all Digital Solutions question groups and their questions.
*/
public function run(): void
{
$categoryId = DB::table('categories')->where('name', 'Digital Solutions')->value('id');
if ($categoryId === null) {
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Digital Solutions',
'sort_order' => 4,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->seedOpportunityDetails($categoryId);
$this->seedClientBackgroundAndHistory($categoryId);
$this->seedRegulatoryCompliance($categoryId);
$this->seedRiskAssessment($categoryId);
$this->seedResourceAllocation($categoryId);
$this->seedTechnologyAndInnovationFit($categoryId);
}
/**
* Seed Group 1: Opportunity Details (not scored, no answer options, all details required).
*/
private function seedOpportunityDetails(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Opportunity Details',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What sort of digital consulting opportunity is it?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'How many locations involved in this opportunity and are there any locations where we do not have digital capabilites in the local Baker Tilly firm.',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Where is the client HQ? please share more about the clients industry and digital maturity level.',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 3,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Who are the competitors in this space?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 4,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 2: Client Background and History (scored, Yes/No/NA options).
*/
private function seedClientBackgroundAndHistory(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Client Background and History',
'sort_order' => 2,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Have we previously worked with this client, and was the experience positive?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have we conducted a reputational risk check on the client (negative press, ethical concerns, etc.)?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 3: Regulatory Compliance (scored, Yes/No/NA options).
*/
private function seedRegulatoryCompliance(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Regulatory Compliance',
'sort_order' => 3,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Does the project involve cross-border data transfers, and if so, are necessary safeguards in place?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Does the client have no pending legal, tax or regulatory issues that [you know of] which could impact this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 4: Risk Assessment (scored, Yes/No/NA options).
*/
private function seedRiskAssessment(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Risk Assessment',
'sort_order' => 4,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Is there a clear understanding of the project scope, responsibilities, and deliverables?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do we have the necessary delivery tools (platforms, technology, security measures etc.) to support this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have we completed a conflict check?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Can we meet the service-level agreements (SLAs) without overcommitting our resources?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there no special expectations or requirements from the client that may pose a challenge?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 5,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 5: Resource Allocation (scored, Yes/No/NA options).
*/
private function seedResourceAllocation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Resource Allocation',
'sort_order' => 5,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Do you have the resources required for the opportunity (personnel, time, budget)?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do you have the right expertise and capacity across our network to deliver high-quality service?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 6: Technology & Innovation Fit (scored, Yes/No/NA options, semicolons in scoring instructions).
*/
private function seedTechnologyAndInnovationFit(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Technology & Innovation Fit',
'sort_order' => 6,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point; if you answer no, you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Are the technologies involved within our area of expertise, or do we have partnerships to support the implementation?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@@ -5,8 +5,8 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Role; use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
final class JonathanSeeder extends Seeder final class JonathanSeeder extends Seeder
{ {
@@ -17,12 +17,16 @@ public function run(): void
{ {
$adminRole = Role::where('name', 'admin')->first(); $adminRole = Role::where('name', 'admin')->first();
User::factory()->create([ DB::table('users')->insert([
'name' => 'Jonathan', 'name' => 'Jonathan',
'email' => 'jonathan@blijnder.nl', 'email' => 'jonathan.van.rij@agerion.nl',
'password' => bcrypt('secret'), 'password' => bcrypt('secret'),
'email_verified_at' => now(), 'email_verified_at' => now(),
'role_id' => $adminRole->id, 'role_id' => $adminRole->id,
'job_title' => 'Senior Developer',
'company_name' => 'Baker Tilly',
'created_at' => now(),
'updated_at' => now(),
]); ]);
} }
} }

View File

@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Seeds question groups and questions for the Legal category.
* Assumes the Legal category already exists or creates it (sort_order=5).
*/
final class LegalQuestionSeeder extends Seeder
{
/**
* Seed all Legal question groups and their questions.
*/
public function run(): void
{
$categoryId = DB::table('categories')->where('name', 'Legal')->value('id');
if ($categoryId === null) {
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Legal',
'sort_order' => 5,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->seedOpportunityDetails($categoryId);
$this->seedClientBackgroundAndHistory($categoryId);
$this->seedFinancialInformation($categoryId);
$this->seedRegulatoryCompliance($categoryId);
$this->seedRiskAssessmentForLegalOpportunities($categoryId);
$this->seedResourceAllocation($categoryId);
$this->seedStakeholderEngagement($categoryId);
$this->seedFeeQuote($categoryId);
}
/**
* Seed Group 1: Opportunity Details (mixed some text-only, some Yes/No).
*/
private function seedOpportunityDetails(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Opportunity Details',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What type of legal opportunity is it (e.g., litigation, corporate, M&A, regulatory)?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'How many locations involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do we have a presence or a reliable partner in all locations listed in this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Is the client budget realistic?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has the client requested any additional information from our firms?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 5,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What is the deadline to respond to the client on this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 6,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Where is the client HQ?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 7,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Who is the competition?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 8,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 2: Client Background and History (scored, mixed Yes/No and text-only).
*/
private function seedClientBackgroundAndHistory(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Client Background and History',
'sort_order' => 2,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What is the client\'s business and industry?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have there been any significant changes in the client\'s business operations or structure recently?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What is our competitive edge in this opportunity (e.g., prior experience with the client, unique expertise, pricing advantage)?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 3: Financial Information (scored, Yes/No options).
*/
private function seedFinancialInformation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Financial Information',
'sort_order' => 3,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Has the client provided enough financial information about their company?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any significant financial risks or uncertainties that you are aware of?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 4: Regulatory Compliance (scored, Yes/No options).
*/
private function seedRegulatoryCompliance(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Regulatory Compliance',
'sort_order' => 4,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Are there any pending legal or regulatory issues that you know of that could impact the opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has the client been subject to any regulatory investigations or penalties?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 5: Risk Assessment for Legal opportunities (scored, Yes/No options).
* Group name preserves Excel casing exactly lowercase 'o' in "opportunities".
*/
private function seedRiskAssessmentForLegalOpportunities(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Risk Assessment for Legal opportunities',
'sort_order' => 5,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Are there any potential risks or challenges associated with the opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has a conflict check been completed?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any potential conflicts of interest?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 6: Resource Allocation (mixed Q2 and Q5 are text-only, others Yes/No).
*/
private function seedResourceAllocation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Resource Allocation',
'sort_order' => 6,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Do we have the required skills and capacity within our firm to deliver this work, or would we need support from another firm?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What resources are required for the opportunity (personnel, time, budget)?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any constraints on the availability of your resources?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do you know of the any constraints on the availability of other firms included in this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Is the deadline to respond to the client is more than two weeks away. Our experience shows that anything shorter is often unrealistic to pursue.',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 5,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 7: Stakeholder Engagement (mixed Q1 is text-only, Q2 is Yes/No).
*/
private function seedStakeholderEngagement(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Stakeholder Engagement',
'sort_order' => 7,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Who are the key stakeholders involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any special expectations and requirements?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 8: Fee Quote (scored, Yes/No options).
*/
private function seedFeeQuote(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Fee Quote',
'sort_order' => 8,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Has the client provided sufficient information to enable a fee quote?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Seeds question groups and questions for the Outsource category.
* Creates the Outsource category if it does not already exist.
*/
final class OutsourceQuestionSeeder extends Seeder
{
/**
* Seed all Outsource question groups and their questions.
*/
public function run(): void
{
$categoryId = DB::table('categories')->where('name', 'Outsource')->value('id');
if ($categoryId === null) {
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Outsource',
'sort_order' => 2,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->seedOpportunityDetails($categoryId);
$this->seedClientBackgroundAndHistory($categoryId);
$this->seedRegulatoryCompliance($categoryId);
$this->seedRiskAssessment($categoryId);
$this->seedResourceAllocation($categoryId);
}
/**
* Seed Group 1: Opportunity Details (not scored, no answer options, details required).
*/
private function seedOpportunityDetails(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Opportunity Details',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What sort of outsourcing opportunity is it?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'How many locations involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'List any locations included in this opportunity where we do not have a Baker Tilly firm.',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 3,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Where is the client HQ?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 4,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What is the client\'s business and industry?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 5,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Who are the competitors in this space?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 6,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 2: Client Background and History (scored, Yes/No/NA options).
*/
private function seedClientBackgroundAndHistory(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Client Background and History',
'sort_order' => 2,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Have we previously worked with this client, and was the experience positive?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have we conducted a reputational risk check on the client (negative press, ethical concerns, etc.)?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 3: Regulatory Compliance (scored, Yes/No/NA options).
*/
private function seedRegulatoryCompliance(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Regulatory Compliance',
'sort_order' => 3,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Does the client comply with all relevant regulatory requirements?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has the client provided complete and accurate financial records for review?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Does the client have no pending legal, tax or regulatory issues that [you know of] which could impact this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 4: Risk Assessment (scored, Yes/No/NA options).
*/
private function seedRiskAssessment(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Risk Assessment',
'sort_order' => 4,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Is there a clear understanding on the scope, responsibilities and deliverables?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do we have the necessary delivery tools (platforms, technology, security measures etc.) to support this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have you completed a conflict check?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Can we meet the service-level agreements (SLAs) without overcommitting our resources?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there no special expectations or requirements from the client that may pose a challenge?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 5,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 5: Resource Allocation (scored, Yes/No/NA options).
*/
private function seedResourceAllocation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Resource Allocation',
'sort_order' => 5,
'description' => null,
'scoring_instructions' => 'If you answer yes, you will score 1 point, if you answer no you will score 0 points',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Do you have the resources required for the opportunity (personnel, time, budget)?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do you have the right expertise and capacity across our network to deliver high-quality service?',
'has_yes' => true,
'has_no' => true,
'has_na' => true,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Seeds question groups and questions for the Tax category.
* Assumes the Tax category is created if not already present.
*/
final class TaxQuestionSeeder extends Seeder
{
/**
* Seed all Tax question groups and their questions.
*/
public function run(): void
{
$categoryId = DB::table('categories')->where('name', 'Tax')->value('id');
if ($categoryId === null) {
$categoryId = DB::table('categories')->insertGetId([
'name' => 'Tax',
'sort_order' => 6,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->seedOpportunityDetails($categoryId);
$this->seedClientBackgroundAndHistory($categoryId);
$this->seedFinancialInformation($categoryId);
$this->seedRegulatoryCompliance($categoryId);
$this->seedRiskAssessment($categoryId);
$this->seedResourceAllocation($categoryId);
$this->seedStakeholderEngagement($categoryId);
}
/**
* Seed Group 1: Opportunity Details (mixed some scored with Yes/No, some text-only).
*/
private function seedOpportunityDetails(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Opportunity Details',
'sort_order' => 1,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What sort of opportunity is it?/Describe the Scope of Work',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'How many locations involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 2,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do we have a Baker Tilly firm in all locations within this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has the client requested any additional information from our firms?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 4,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What is the deadline to respond to the client on this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 5,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Where is the client HQ?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 6,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Who is the competition?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 7,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 2: Client Background and History (mixed Q1 text-only, Q2/Q3 scored Yes/No).
*/
private function seedClientBackgroundAndHistory(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Client Background and History',
'sort_order' => 2,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What is the client\'s business and industry?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Have there been any significant changes in the client\'s business operations or structure recently?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Is the client an existing client?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'required',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 3: Financial Information (scored, Yes/No options).
*/
private function seedFinancialInformation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Financial Information',
'sort_order' => 3,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Has the client provided enough financial information about their company?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any significant financial risks or uncertainties that you are aware of?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 4: Regulatory Compliance (scored, Yes/No options).
*/
private function seedRegulatoryCompliance(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Regulatory Compliance',
'sort_order' => 4,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Does the client comply with all relevant regulatory requirements and standards?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_no',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any pending legal or regulatory issues that you know of that could impact the opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Has the client been subject to any regulatory investigations or penalties?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 5: Risk Assessment (scored, Yes/No options).
*/
private function seedRiskAssessment(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Risk Assessment',
'sort_order' => 5,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Are there any potential risks or challenges associated with the opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 1,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any potential conflicts of interest?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'req_on_yes',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 6: Resource Allocation (mixed Q1/Q4 text-only, Q2/Q3 scored Yes/No).
*/
private function seedResourceAllocation(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Resource Allocation',
'sort_order' => 6,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'What resources are required for the opportunity (personnel, time, budget)?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any constraints on the availability of your resources?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Do you know of the any constraints on the availability of other firms included in this opportunity?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 3,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'What is the expected timeline for the opportunity, including any critical deadlines that must be met?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'optional',
'sort_order' => 4,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
/**
* Seed Group 7: Stakeholder Engagement (mixed Q1 text-only, Q2 scored Yes/No).
*/
private function seedStakeholderEngagement(int $categoryId): void
{
$groupId = DB::table('question_groups')->insertGetId([
'category_id' => $categoryId,
'name' => 'Stakeholder Engagement',
'sort_order' => 7,
'description' => null,
'scoring_instructions' => null,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('questions')->insert([
[
'question_group_id' => $groupId,
'text' => 'Who are the key stakeholders involved in this opportunity?',
'has_yes' => false,
'has_no' => false,
'has_na' => false,
'details' => 'required',
'sort_order' => 1,
'is_scored' => false,
'created_at' => now(),
'updated_at' => now(),
],
[
'question_group_id' => $groupId,
'text' => 'Are there any special expectations and requirements?',
'has_yes' => true,
'has_no' => true,
'has_na' => false,
'details' => 'optional',
'sort_order' => 2,
'is_scored' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
}
}

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

@@ -6,9 +6,9 @@ ### Score Legend
| Color | Points | Decision | | Color | Points | Decision |
|-------|--------|----------| |-------|--------|----------|
| 🟢 Green | 10+ Points | GO | | Green | 10+ Points | GO |
| 🟡 Yellow | 5-9 Points | Speak to SL or SSL leadership | | Yellow | 5-9 Points | Speak to SL or SSL leadership |
| 🔴 Red | 1-5 Points | NO GO | | Red | 1-5 Points | NO GO |
--- ---
@@ -25,11 +25,13 @@ ### Basic Information
## 1. Opportunity Details ## 1. Opportunity Details
> *Not scored*
| # | Question | Details | | # | Question | Details |
|---|----------|---------| |---|----------|---------|
| 8 | What sort of audit opportunity is it? | [insert details] | | 8 | What sort of audit opportunity is it? | [insert details] |
| 9 | How many locations involved in this opportunity? | [insert details] | | 9 | How many locations involved in this opportunity? | [insert details] |
| 10 | List any locations included in this opportunity where we do not have a Baker Tilly firm. | [if no insert details] | | 10 | List any locations included in this opportunity where we do not have a Baker Tilly firm. | [optional] |
| 11 | Where is the client HQ? | [insert details] | | 11 | Where is the client HQ? | [insert details] |
| 12 | Who is the competition? | [insert details] | | 12 | Who is the competition? | [insert details] |
@@ -39,12 +41,12 @@ ## 1. Client Background and History
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 14 | What is the client's business and industry? | | [insert details] | | 14 | What is the client's business and industry? | | [insert details] |
| 15 | There have been no significant changes in the client's business operations or structure recently? | - | [if no insert details] | | 15 | There have been no significant changes in the client's business operations or structure recently? | Yes / No | [if no insert details] |
| 16 | Does the sector and/or client come with a reputation which we are comfortable that Baker Tilly is associated with? | - | | | 16 | Does the sector and/or client come with a reputation which we are comfortable that Baker Tilly is associated with? | Yes / No | — |
| 17 | Are there any previous audit reports or findings that need to be considered? | | [if yes insert details] | | 17 | Are there any previous audit reports or findings that need to be considered? | Yes / No | [if yes insert details] |
--- ---
@@ -52,10 +54,10 @@ ## 2. Financial Information
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 19 | Has the client provided financial statements or balance sheet? | - | [insert details if needed] | | 19 | Has the client provided financial statements or balance sheet? | Yes / No | [insert details if needed] |
| 20 | Are the client's financial statements complete and accurate? | - | [insert details if needed] | | 20 | Are the client's financial statements complete and accurate? | Yes / No | [if yes insert details] |
--- ---
@@ -63,11 +65,11 @@ ## 3. Regulatory Compliance
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 22 | Does the client comply with all relevant regulatory requirements and standards? | - | [if no insert details] | | 22 | Does the client comply with all relevant regulatory requirements and standards? | Yes / No | [if no insert details] |
| 23 | The client has no pending legal or regulatory issues that you know of that could impact the audit? | | [if no insert details] | | 23 | The client has no pending legal or regulatory issues that you know of that could impact the audit? | Yes / No | [if no insert details] |
| 24 | The client has been subject to no regulatory investigations or penalties? | - | [if no insert details] | | 24 | The client has been subject to no regulatory investigations or penalties? | Yes / No | [if no insert details] |
--- ---
@@ -75,11 +77,11 @@ ## 4. Risk Assessment
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 26 | There are no key risks associated with the audit? | - | [if no insert details] | | 26 | There are no key risks associated with the audit? | Yes / No | [if no insert details] |
| 27 | Have you completed a conflict check? | - | [insert details] | | 27 | Have you completed a conflict check? | Yes / No | [insert details] |
| 28 | Are you and other BTI member firms independent with the meaning of local and IESBA rules? | - | [if no insert details] | | 28 | Are you and other BTI member firms independent withi the meaning of local and IESBA rules? | Yes / No | [if no insert details] |
--- ---
@@ -87,20 +89,20 @@ ## 5. Resource Allocation
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 30 | What resources are required for the audit (personnel, time, budget)? | - | [insert details if available] | | 30 | What resources are required for the audit (personnel, time, budget)? | | [insert details if available] |
| 31 | Does your firm have the scale, seniority and degree of expertise available at the right time to report in accordance with the client's schedule? | | [insert details if needed] | | 31 | Does your firm have the scale, seniority and degree of expertise available at the riht time to report in accordance with the client's schedule? | Yes / No | [insert details if needed] |
--- ---
## 6. Reporting Requirements ## 6. Reportng Requirements
> *If you answer yes, you will score 1 point, if you answer no you will score 0 points* > *If you answer yes, you will score 1 point, if you answer no you will score 0 points*
| # | Question | Yes / No / Not applicable | Insert details | | # | Question | Answer Options | Details |
|---|----------|---------------------------|----------------| |---|----------|----------------|---------|
| 33 | Do we understand reporting rules, regulatory environment and stakeholder expectations? | - | [insert details if needed] | | 33 | Do we understand reporting rules, regulatory environment and stakeholder expectations? | Yes / No | [insert details if needed] |
--- ---

BIN
no-pill-centered.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
pdf-preview-after.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

BIN
pdf-preview-before.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { Head } from '@inertiajs/vue3' import { Head } from '@inertiajs/vue3'
import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
@@ -98,8 +99,12 @@ const resultDisplay = computed(() => {
</dl> </dl>
</div> </div>
<!-- Again button --> <!-- Action buttons -->
<div class="flex justify-center"> <div class="flex justify-center gap-4">
<AppButton variant="ghost" :href="`/sessions/${session.id}/pdf`" external>
<ArrowDownTrayIcon class="h-5 w-5" />
Download PDF
</AppButton>
<AppButton size="lg" href="/" data-cy="start-new"> <AppButton size="lg" href="/" data-cy="start-new">
Again Again
</AppButton> </AppButton>

View File

@@ -1,10 +1,9 @@
<script setup> <script setup>
import { computed, reactive } from 'vue' import { computed, reactive, ref, watch, nextTick } from 'vue'
import { Head, useForm, router } from '@inertiajs/vue3' import { Head, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue' import AppLayout from '@/Layouts/AppLayout.vue'
import AppButton from '@/Components/AppButton.vue' import AppButton from '@/Components/AppButton.vue'
import QuestionCard from '@/Components/QuestionCard.vue' import QuestionCard from '@/Components/QuestionCard.vue'
import ScoreIndicator from '@/Components/ScoreIndicator.vue'
defineOptions({ layout: AppLayout }) defineOptions({ layout: AppLayout })
@@ -21,10 +20,6 @@ const props = defineProps({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
score: {
type: Number,
default: 0,
},
}) })
// Answer management // Answer management
@@ -44,6 +39,12 @@ const initializeAnswers = () => {
} }
initializeAnswers() initializeAnswers()
// Validation state
const validationErrors = ref({})
const processing = ref(false)
const showErrors = ref(false)
const questionRefs = ref({})
// Save a single answer with partial reload including score // Save a single answer with partial reload including score
let saveTimeout = null let saveTimeout = null
const saveAnswer = (questionId) => { const saveAnswer = (questionId) => {
@@ -56,7 +57,9 @@ const saveAnswer = (questionId) => {
}, { }, {
preserveScroll: true, preserveScroll: true,
preserveState: true, preserveState: true,
only: ['answers', 'score'], only: ['answers'],
onStart: () => { processing.value = true },
onFinish: () => { processing.value = false },
}) })
}, 500) }, 500)
} }
@@ -82,19 +85,90 @@ const saveComments = () => {
}, 1000) }, 1000)
} }
// Session completion // Validation function
const validate = () => {
const errors = {}
props.questionGroups.forEach(group => {
group.questions.forEach(question => {
const answer = answerData[question.id]
const hasRadioButtons = question.has_yes || question.has_no || question.has_na
// Rule 1: Radio button questions must have a selection
if (hasRadioButtons && answer.value === null) {
errors[question.id] = 'Please select an answer'
return
}
// Rule 2: Required text fields based on details
if (question.details === 'required' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
if (question.details === 'req_on_yes' && answer.value === 'yes' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
if (question.details === 'req_on_no' && answer.value === 'no' && !answer.text_value?.trim()) {
errors[question.id] = 'Please provide details'
return
}
// Rule 3: Text-only questions (no radio buttons, has required details)
if (!hasRadioButtons && question.details && question.details !== 'optional' && !answer.text_value?.trim()) {
errors[question.id] = 'Please enter a response'
return
}
})
})
validationErrors.value = errors
return Object.keys(errors).length === 0
}
// Watch answerData for changes and revalidate when errors are showing
watch(answerData, () => {
if (showErrors.value) {
validate()
}
}, { deep: true })
// Error count for summary banner
const errorCount = computed(() => {
return Object.values(validationErrors.value).filter(err => err !== null).length
})
// Session completion with validation
let completing = false let completing = false
const completeSession = () => { const completeSession = async () => {
showErrors.value = true
if (!validate()) {
// Scroll to first error
await nextTick()
const firstErrorQuestionId = Object.keys(validationErrors.value)[0]
const firstErrorElement = questionRefs.value[firstErrorQuestionId]
if (firstErrorElement) {
firstErrorElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return
}
completing = true completing = true
clearTimeout(saveTimeout)
router.put(`/sessions/${props.session.id}`, { router.put(`/sessions/${props.session.id}`, {
answers: { ...answerData },
complete: true, complete: true,
}, {
onStart: () => { processing.value = true },
onFinish: () => { processing.value = false },
}) })
} }
// Check if any scored answers have been given
const hasScoredAnswers = computed(() => {
return props.score > 0
})
</script> </script>
<template> <template>
@@ -103,10 +177,7 @@ const hasScoredAnswers = computed(() => {
<div class="max-w-3xl mx-auto px-4 py-10"> <div class="max-w-3xl mx-auto px-4 py-10">
<!-- Title area --> <!-- Title area -->
<div class="mb-10"> <div class="mb-10">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">{{ session.category.name }} Questionnaire</h1> <h1 class="text-2xl font-bold text-white">{{ session.category.name }} Questionnaire</h1>
<ScoreIndicator :score="score" :visible="hasScoredAnswers" />
</div>
<div class="h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent mt-4"></div> <div class="h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent mt-4"></div>
</div> </div>
@@ -141,15 +212,21 @@ const hasScoredAnswers = computed(() => {
<p v-if="group.scoring_instructions" class="text-amber-400 text-sm italic mb-4">{{ group.scoring_instructions }}</p> <p v-if="group.scoring_instructions" class="text-amber-400 text-sm italic mb-4">{{ group.scoring_instructions }}</p>
<div class="divide-y divide-white/[0.06]"> <div class="divide-y divide-white/[0.06]">
<QuestionCard <div
v-for="question in group.questions" v-for="question in group.questions"
:key="question.id" :key="question.id"
:ref="el => { if (el) questionRefs[question.id] = el }"
:data-question-id="question.id"
>
<QuestionCard
:question="question" :question="question"
:modelValue="answerData[question.id]" :modelValue="answerData[question.id]"
:error="showErrors ? validationErrors[question.id] : null"
@update:modelValue="updateAnswer(question.id, $event)" @update:modelValue="updateAnswer(question.id, $event)"
/> />
</div> </div>
</div> </div>
</div>
<!-- Additional Comments --> <!-- Additional Comments -->
<div class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8"> <div class="bg-white/[0.03] border border-white/[0.06] rounded-xl p-8">
@@ -164,10 +241,30 @@ const hasScoredAnswers = computed(() => {
</div> </div>
</div> </div>
<!-- Complete button - now enabled --> <!-- Complete button with validation summary -->
<div class="mt-12 pt-8 border-t border-white/[0.06]"> <div class="mt-12 pt-8 border-t border-white/[0.06]">
<div class="flex justify-end"> <!-- Validation summary banner -->
<AppButton size="lg" @click="completeSession" data-cy="complete-session"> <Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="showErrors && errorCount > 0" class="bg-red-500/10 border border-red-400/20 rounded-lg px-5 py-4 mb-6">
<p class="text-red-400 text-sm font-medium">
Please complete all required fields before submitting. {{ errorCount }} {{ errorCount === 1 ? 'question requires' : 'questions require' }} your attention.
</p>
</div>
</Transition>
<div class="flex justify-between items-center">
<AppButton variant="ghost" size="lg" :href="`/screening/${session.screening_id}/result`" data-cy="back-to-screening">
Back
</AppButton>
<AppButton size="lg" :loading="processing" @click="completeSession" data-cy="complete-session">
Complete Complete
</AppButton> </AppButton>
</div> </div>

View File

@@ -0,0 +1,618 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>Go / No Go Assessment Report</title>
<style>
/* ─── Reset & Base ─────────────────────────────────────────── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
background-color: #2b303a;
color: #ffffff;
line-height: 1.5;
}
/* ─── Page Layout ───────────────────────────────────────────── */
@page {
size: A4;
margin: 0;
}
.page-wrapper {
width: 210mm;
min-height: 297mm;
background-color: #2b303a;
padding: 0;
}
/* ─── Header ────────────────────────────────────────────────── */
.header {
background-color: #1e222b;
padding: 32pt 36pt 28pt 36pt;
border-bottom: 3pt solid #d1ec51;
}
.header-eyebrow {
font-size: 7pt;
font-weight: bold;
letter-spacing: 3pt;
text-transform: uppercase;
color: #d1ec51;
margin-bottom: 6pt;
}
.header-title {
font-size: 26pt;
font-weight: bold;
color: #ffffff;
letter-spacing: -0.5pt;
line-height: 1.1;
}
.header-title-accent {
color: #d1ec51;
}
.header-subtitle {
font-size: 11pt;
color: #9ca3af;
margin-top: 6pt;
letter-spacing: 0.5pt;
}
.header-category {
color: #00b7b3;
font-weight: bold;
}
/* ─── Content Area ──────────────────────────────────────────── */
.content {
padding: 28pt 36pt 36pt 36pt;
}
/* ─── Meta Block (User Info + Date + Result) ────────────────── */
.meta-block {
margin-bottom: 24pt;
}
/* Two-column table for user info + result */
.meta-table {
width: 100%;
border-collapse: collapse;
}
.meta-table td {
vertical-align: top;
padding: 0;
}
.meta-left {
width: 58%;
}
.meta-right {
width: 42%;
}
/* ─── User Info Card ────────────────────────────────────────── */
.card {
background-color: #1e222b;
border: 1pt solid #3a404d;
border-radius: 6pt;
padding: 16pt 18pt;
margin-right: 12pt;
}
.card-label {
font-size: 6.5pt;
font-weight: bold;
letter-spacing: 2.5pt;
text-transform: uppercase;
color: #d1ec51;
margin-bottom: 12pt;
padding-bottom: 8pt;
border-bottom: 1pt solid #3a404d;
}
.info-row {
margin-bottom: 8pt;
}
.info-row:last-child {
margin-bottom: 0;
}
.info-key {
font-size: 7pt;
color: #9ca3af;
letter-spacing: 1pt;
text-transform: uppercase;
margin-bottom: 1pt;
}
.info-value {
font-size: 9.5pt;
color: #ffffff;
font-weight: bold;
}
.info-value-secondary {
font-size: 9pt;
color: #d1d5db;
font-weight: normal;
}
/* ─── Date Block ────────────────────────────────────────────── */
.date-block {
background-color: #1e222b;
border: 1pt solid #3a404d;
border-radius: 6pt;
padding: 12pt 18pt;
margin-bottom: 12pt;
}
.date-label {
font-size: 6.5pt;
font-weight: bold;
letter-spacing: 2.5pt;
text-transform: uppercase;
color: #9ca3af;
margin-bottom: 3pt;
}
.date-value {
font-size: 11pt;
font-weight: bold;
color: #ffffff;
}
/* ─── Result Badge ──────────────────────────────────────────── */
.result-badge {
border-radius: 6pt;
padding: 16pt 18pt;
text-align: center;
}
.result-badge-go {
background-color: #14532d;
border: 2pt solid #22c55e;
}
.result-badge-consult {
background-color: #451a03;
border: 2pt solid #f59e0b;
}
.result-badge-no-go {
background-color: #450a0a;
border: 2pt solid #ef4444;
}
.result-label {
font-size: 6.5pt;
font-weight: bold;
letter-spacing: 2.5pt;
text-transform: uppercase;
margin-bottom: 6pt;
color: #9ca3af;
}
.result-text {
font-size: 18pt;
font-weight: bold;
letter-spacing: 1pt;
line-height: 1.1;
}
.result-text-go {
color: #4ade80;
}
.result-text-consult {
color: #fbbf24;
}
.result-text-no-go {
color: #f87171;
}
.result-score {
margin-top: 8pt;
font-size: 8.5pt;
color: #9ca3af;
}
.result-score-value {
font-weight: bold;
color: #d1ec51;
}
/* ─── Section Divider ───────────────────────────────────────── */
.section-divider {
margin: 20pt 0 16pt 0;
border: none;
border-top: 1pt solid #3a404d;
}
/* ─── Question Group ────────────────────────────────────────── */
.group-header {
margin-bottom: 12pt;
}
.group-number {
display: inline;
font-size: 7pt;
font-weight: bold;
letter-spacing: 2pt;
text-transform: uppercase;
color: #00b7b3;
}
.group-name {
font-size: 13pt;
font-weight: bold;
color: #ffffff;
margin-top: 3pt;
line-height: 1.2;
}
.group-description {
font-size: 8.5pt;
color: #9ca3af;
margin-top: 4pt;
font-style: italic;
}
/* ─── Question Item ─────────────────────────────────────────── */
.question-list {
margin-bottom: 8pt;
}
.question-item {
background-color: #1e222b;
border: 1pt solid #3a404d;
border-radius: 5pt;
margin-bottom: 6pt;
overflow: hidden;
}
.question-item-table {
width: 100%;
border-collapse: collapse;
}
.question-body {
padding: 10pt 14pt;
width: 72%;
vertical-align: top;
}
.question-answer-cell {
width: 28%;
padding: 10pt 14pt;
border-left: 1pt solid #3a404d;
text-align: center;
vertical-align: middle;
background-color: #242830;
}
.question-text {
font-size: 9pt;
color: #e5e7eb;
line-height: 1.45;
}
.question-text-value {
margin-top: 5pt;
font-size: 8pt;
color: #9ca3af;
font-style: italic;
padding: 5pt 8pt;
background-color: #2b303a;
border-left: 2pt solid #3a404d;
border-radius: 2pt;
}
/* Answer pill */
.answer-pill {
display: inline;
font-size: 8pt;
font-weight: bold;
letter-spacing: 1pt;
text-transform: uppercase;
padding: 6pt 9pt;
border-radius: 20pt;
}
.answer-yes {
background-color: #14532d;
color: #4ade80;
border: 1pt solid #22c55e;
}
.answer-no {
background-color: #450a0a;
color: #f87171;
border: 1pt solid #ef4444;
}
.answer-na {
background-color: #1e222b;
color: #9ca3af;
border: 1pt solid #4b5563;
}
.answer-missing {
font-size: 7.5pt;
color: #6b7280;
font-style: italic;
}
/* ─── Additional Comments ───────────────────────────────────── */
.comments-section {
margin-top: 8pt;
}
.comments-card {
background-color: #1e222b;
border: 1pt solid #3a404d;
border-left: 3pt solid #d1ec51;
border-radius: 5pt;
padding: 14pt 16pt;
}
.comments-label {
font-size: 6.5pt;
font-weight: bold;
letter-spacing: 2.5pt;
text-transform: uppercase;
color: #d1ec51;
margin-bottom: 8pt;
}
.comments-text {
font-size: 9pt;
color: #d1d5db;
line-height: 1.6;
}
/* ─── Footer ────────────────────────────────────────────────── */
.footer {
margin-top: 28pt;
padding-top: 12pt;
border-top: 1pt solid #3a404d;
text-align: center;
}
.footer-text {
font-size: 7pt;
color: #4b5563;
letter-spacing: 0.5pt;
}
.footer-accent {
color: #d1ec51;
}
/* ─── Page break control ────────────────────────────────────── */
.no-break {
page-break-inside: avoid;
}
.group-block {
page-break-inside: avoid;
}
</style>
</head>
<body>
<div class="page-wrapper">
{{-- ═══ HEADER ═══════════════════════════════════════════════════════ --}}
<div class="header">
<div class="header-eyebrow">Assessment Report</div>
<div class="header-title">
Go&nbsp;<span class="header-title-accent">/</span>&nbsp;No Go
</div>
<div class="header-subtitle">
Category:&nbsp;
<span class="header-category">{{ $session->category->name ?? 'Unknown Category' }}</span>
</div>
</div>
{{-- ═══ CONTENT ════════════════════════════════════════════════════════ --}}
<div class="content">
{{-- ─── Meta Block ─────────────────────────────────────────────── --}}
<div class="meta-block">
<table class="meta-table">
<tr>
{{-- Left: user info --}}
<td class="meta-left">
<div class="card">
<div class="card-label">Submitted By</div>
<div class="info-row">
<div class="info-key">Name</div>
<div class="info-value">{{ $session->user->name ?? '—' }}</div>
</div>
<div class="info-row">
<div class="info-key">Email</div>
<div class="info-value-secondary">{{ $session->user->email ?? '—' }}</div>
</div>
@if (!empty($session->user->company_name))
<div class="info-row">
<div class="info-key">Company</div>
<div class="info-value-secondary">{{ $session->user->company_name }}</div>
</div>
@endif
@if (!empty($session->user->job_title))
<div class="info-row">
<div class="info-key">Job Title</div>
<div class="info-value-secondary">{{ $session->user->job_title }}</div>
</div>
@endif
@if (!empty($session->user->department))
<div class="info-row">
<div class="info-key">Department</div>
<div class="info-value-secondary">{{ $session->user->department }}</div>
</div>
@endif
</div>
</td>
{{-- Right: date + result --}}
<td class="meta-right">
{{-- Completion date --}}
<div class="date-block">
<div class="date-label">Completed</div>
<div class="date-value">
@if ($session->completed_at)
{{ $session->completed_at->format('d M Y, H:i') }}
@else
@endif
</div>
</div>
{{-- Result badge --}}
@php
$result = $session->result ?? 'no_go';
$badgeClass = match ($result) {
'go' => 'result-badge-go',
'consult_leadership' => 'result-badge-consult',
default => 'result-badge-no-go',
};
$textClass = match ($result) {
'go' => 'result-text-go',
'consult_leadership' => 'result-text-consult',
default => 'result-text-no-go',
};
$resultLabel = match ($result) {
'go' => 'GO',
'consult_leadership' => 'Consult Leadership',
default => 'NO GO',
};
@endphp
<div class="result-badge {{ $badgeClass }}">
<div class="result-label">Decision</div>
<div class="result-text {{ $textClass }}">{{ $resultLabel }}</div>
@if (isset($session->score))
<div class="result-score">
Score:&nbsp;<span class="result-score-value">{{ $session->score }}&nbsp;pts</span>
</div>
@endif
</div>
</td>
</tr>
</table>
</div>
{{-- ─── Question Groups ─────────────────────────────────────── --}}
@php
// Key answers by question_id for fast lookup
$answersMap = $session->answers->keyBy('question_id');
@endphp
@foreach ($questionGroups as $groupIndex => $group)
<hr class="section-divider">
<div class="group-block no-break">
{{-- Group heading --}}
<div class="group-header">
<div class="group-number">Group {{ $groupIndex + 1 }}</div>
<div class="group-name">{{ $group->name }}</div>
@if (!empty($group->description))
<div class="group-description">{{ $group->description }}</div>
@endif
</div>
{{-- Questions --}}
<div class="question-list">
@forelse ($group->questions as $question)
@php
$answer = $answersMap->get($question->id);
$answerValue = $answer?->value;
$textValue = $answer?->text_value;
$pillClass = match ($answerValue) {
'yes' => 'answer-pill answer-yes',
'no' => 'answer-pill answer-no',
'na' => 'answer-pill answer-na',
default => '',
};
$pillLabel = match ($answerValue) {
'yes' => 'Yes',
'no' => 'No',
'na' => 'N/A',
default => null,
};
@endphp
<div class="question-item no-break">
<table class="question-item-table">
<tr>
<td class="question-body"@if ($pillLabel === null) colspan="2"@endif>
<div class="question-text">{{ $question->text }}</div>
@if (!empty($textValue))
<div class="question-text-value">{{ $textValue }}</div>
@endif
</td>
@if ($pillLabel !== null)
<td class="question-answer-cell">
<span class="{{ $pillClass }}">{{ $pillLabel }}</span>
</td>
@endif
</tr>
</table>
</div>
@empty
<div style="color: #6b7280; font-size: 8.5pt; font-style: italic; padding: 8pt 0;">
No questions in this group.
</div>
@endforelse
</div>
</div>
@endforeach
{{-- ─── Additional Comments ─────────────────────────────────── --}}
@if (!empty($session->additional_comments))
<hr class="section-divider">
<div class="comments-section no-break">
<div class="comments-card">
<div class="comments-label">Additional Comments</div>
<div class="comments-text">{{ $session->additional_comments }}</div>
</div>
</div>
@endif
{{-- ─── Footer ──────────────────────────────────────────────── --}}
<div class="footer">
<div class="footer-text">
Generated by&nbsp;<span class="footer-accent">Go / No Go</span>
&nbsp;&bull;&nbsp;
{{ now()->format('d M Y') }}
</div>
</div>
</div>{{-- /content --}}
</div>{{-- /page-wrapper --}}
</body>
</html>

View File

@@ -1,8 +1,15 @@
<?php <?php
use App\Jobs\CloseSessionsJob;
use App\Jobs\LogAppVersionJob;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::job(new LogAppVersionJob)->hourly();
Schedule::job(new CloseSessionsJob)->hourly();

View File

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

BIN
selected-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

BIN
validation-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
validation-summary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB