Compare commits
21 Commits
0327b95568
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7f1d8b1c | |||
| 17db4962c4 | |||
| 88895872c4 | |||
| 1cb157da42 | |||
| dbafa6c99c | |||
| a373b60750 | |||
| 124c707634 | |||
| a046c017fa | |||
| 6fce8d8436 | |||
| d73064a718 | |||
| c9a2ad0451 | |||
| 20f66dddaa | |||
| 61bd625c07 | |||
| bdc567745a | |||
| 7f380303ab | |||
| 29a94899da | |||
| ede31b15cb | |||
| aad1d8a2b2 | |||
| 3cddb1c609 | |||
| e44ef5fddc | |||
| e98ca8f00c |
BIN
.playwright-mcp/page-2026-03-19T11-19-05-308Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-19T11-19-05-308Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
.playwright-mcp/page-2026-03-19T12-08-19-912Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-19T12-08-19-912Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
74
app/Configs/Content.php
Normal file
74
app/Configs/Content.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Configs;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Nova\Fields\Markdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config Field class for the "content" config group.
|
||||||
|
* Defines fields, defaults, and Nova fields for content settings.
|
||||||
|
*/
|
||||||
|
final class Content
|
||||||
|
{
|
||||||
|
public string $title = 'Content';
|
||||||
|
|
||||||
|
public string $description = 'Content settings';
|
||||||
|
|
||||||
|
public string $key = 'content';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the field definitions with their type and default value for this config group.
|
||||||
|
*/
|
||||||
|
public function getFieldKeys(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'disclaimer' => [
|
||||||
|
'type' => 'markdown',
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default value for the given field key, or null if the key does not exist.
|
||||||
|
*/
|
||||||
|
public function getDefault(string $key): mixed
|
||||||
|
{
|
||||||
|
$fields = $this->getFieldKeys();
|
||||||
|
|
||||||
|
if (! Arr::has($fields, $key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Arr::get($fields, "{$key}.default");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns Nova field instances for this config group, each wired with
|
||||||
|
* resolveUsing (read from json_value with default fallback) and
|
||||||
|
* fillUsing (write back into json_value) callbacks.
|
||||||
|
*/
|
||||||
|
public function getNovaFields(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Markdown::make('Disclaimer', 'disclaimer')
|
||||||
|
->resolveUsing(function (mixed $value, mixed $resource): string {
|
||||||
|
$jsonValue = Arr::get((array) $resource->json_value, 'disclaimer');
|
||||||
|
|
||||||
|
if ($jsonValue === null || $jsonValue === '') {
|
||||||
|
return (string) $this->getDefault('disclaimer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $jsonValue;
|
||||||
|
})
|
||||||
|
->fillUsing(function (mixed $request, mixed $model, string $attribute, string $requestAttribute): void {
|
||||||
|
$current = Arr::wrap((array) $model->json_value);
|
||||||
|
Arr::set($current, $attribute, $request->{$requestAttribute});
|
||||||
|
$model->json_value = $current;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,50 +8,55 @@
|
|||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
||||||
final class SocialiteController extends Controller
|
final class SocialiteController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Redirect the user to the Azure AD authentication page.
|
* Redirect the user to the Azure AD authentication page.
|
||||||
|
* Logs the Azure config (client_id prefix, redirect URI, tenant) and generated redirect URL.
|
||||||
*/
|
*/
|
||||||
public function redirect(): RedirectResponse
|
public function redirect(): RedirectResponse
|
||||||
{
|
{
|
||||||
return Socialite::driver('azure')->redirect();
|
$azureConfig = config('services.azure');
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Initiating redirect', [
|
||||||
|
'client_id_prefix' => substr((string) Arr::get($azureConfig, 'client_id', ''), 0, 4),
|
||||||
|
'redirect_uri' => Arr::get($azureConfig, 'redirect'),
|
||||||
|
'tenant' => Arr::get($azureConfig, 'tenant'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = Socialite::driver('azure')->redirect();
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Redirect URL generated', [
|
||||||
|
'redirect_url' => $response->getTargetUrl(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the callback from Azure AD after authentication.
|
* Handle the callback from Azure AD after authentication.
|
||||||
|
* Logs request parameters, the resolved Azure user, and the upserted local user.
|
||||||
|
* Wraps the entire flow in a try/catch to capture and log any exceptions.
|
||||||
*/
|
*/
|
||||||
public function callback(): RedirectResponse
|
public function callback(): RedirectResponse
|
||||||
{
|
{
|
||||||
$azureUser = Socialite::driver('azure')->user();
|
try {
|
||||||
|
return $this->processCallback();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('[Azure SSO] Exception during callback', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
'exception' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
$user = User::query()->updateOrCreate(
|
throw $e;
|
||||||
['email' => $azureUser->getEmail()],
|
|
||||||
[
|
|
||||||
'name' => $azureUser->getName(),
|
|
||||||
'azure_id' => $azureUser->getId(),
|
|
||||||
'photo' => $azureUser->getAvatar(),
|
|
||||||
'job_title' => Arr::get($azureUser->user, 'jobTitle'),
|
|
||||||
'department' => Arr::get($azureUser->user, 'department'),
|
|
||||||
'company_name' => Arr::get($azureUser->user, 'companyName'),
|
|
||||||
'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($user->role_id === null) {
|
|
||||||
$user->update(['role_id' => Role::where('name', 'user')->first()->id]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auth()->login($user);
|
|
||||||
|
|
||||||
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
|
|
||||||
|
|
||||||
return redirect('/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,4 +73,75 @@ public function logout(Request $request): RedirectResponse
|
|||||||
|
|
||||||
return redirect('/');
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the full Azure AD callback flow: resolve the user, upsert the local record,
|
||||||
|
* assign a default role if needed, log the user in, and record the activity.
|
||||||
|
*/
|
||||||
|
private function processCallback(): RedirectResponse
|
||||||
|
{
|
||||||
|
Log::info('[Azure SSO] Callback received', [
|
||||||
|
'query_code' => substr((string) request()->query('code', ''), 0, 8).'…',
|
||||||
|
'query_state' => request()->query('state'),
|
||||||
|
'query_error' => request()->query('error'),
|
||||||
|
'query_error_description' => request()->query('error_description'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$azureUser = Socialite::driver('azure')->user();
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Azure user resolved', [
|
||||||
|
'azure_id' => $azureUser->getId(),
|
||||||
|
'email' => $azureUser->getEmail(),
|
||||||
|
'mail' => Arr::get($azureUser->user, 'mail'),
|
||||||
|
'name' => $azureUser->getName(),
|
||||||
|
'job_title' => Arr::get($azureUser->user, 'jobTitle'),
|
||||||
|
'department' => Arr::get($azureUser->user, 'department'),
|
||||||
|
'company' => Arr::get($azureUser->user, 'companyName'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Full Azure user dump', json_decode(json_encode($azureUser), true));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = User::query()->updateOrCreate(
|
||||||
|
['username' => $azureUser->getEmail()],
|
||||||
|
[
|
||||||
|
'name' => $azureUser->getName(),
|
||||||
|
'email' => $azureUser->user['mail'] ?? $azureUser->getEmail(),
|
||||||
|
'azure_id' => $azureUser->getId(),
|
||||||
|
'photo' => $azureUser->getAvatar(),
|
||||||
|
'job_title' => Arr::get($azureUser->user, 'jobTitle'),
|
||||||
|
'department' => Arr::get($azureUser->user, 'department'),
|
||||||
|
'company_name' => Arr::get($azureUser->user, 'companyName'),
|
||||||
|
'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Local user upserted', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'was_recent' => $user->wasRecentlyCreated,
|
||||||
|
'role_id' => $user->role_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($user->role_id === null) {
|
||||||
|
$user->update(['role_id' => Role::where('name', 'user')->first()->id]);
|
||||||
|
|
||||||
|
Log::info('[Azure SSO] Default role assigned', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'role_id' => $user->role_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth()->login($user);
|
||||||
|
|
||||||
|
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
Log::error('[Azure SSO] Database error during user upsert', ['message' => $e->getMessage(), 'email' => $azureUser->getEmail()]);
|
||||||
|
|
||||||
|
return redirect('/')->with('error', 'Something went wrong during sign-in. Please try again or contact support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,29 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\Config as ConfigService;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
final class LandingController extends Controller
|
final class LandingController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display the landing page.
|
* Display the landing page with a flag indicating whether a disclaimer is configured.
|
||||||
*/
|
*/
|
||||||
public function index(): Response
|
public function index(ConfigService $config): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Landing');
|
return Inertia::render('Landing', [
|
||||||
|
'hasDisclaimer' => $config->contentDisclaimer !== '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the disclaimer page with the configured markdown content.
|
||||||
|
*/
|
||||||
|
public function disclaimer(ConfigService $config): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Disclaimer', [
|
||||||
|
'content' => $config->contentDisclaimer,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,15 +103,17 @@ private function calculateAndUpdateScore(Screening $screening, array $answers):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the total score from the answers.
|
* Calculate the total score from the answers — yes = 1, unknown = 0.5, no = 0.
|
||||||
*/
|
*/
|
||||||
private function calculateScore(array $answers): int
|
private function calculateScore(array $answers): float
|
||||||
{
|
{
|
||||||
$score = 0;
|
$score = 0;
|
||||||
|
|
||||||
foreach ($answers as $value) {
|
foreach ($answers as $value) {
|
||||||
if ($value === 'yes') {
|
if ($value === 'yes') {
|
||||||
$score++;
|
$score++;
|
||||||
|
} elseif ($value === 'unknown') {
|
||||||
|
$score += 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,11 +205,14 @@ public function result(Session $session): InertiaResponse
|
|||||||
{
|
{
|
||||||
$session->load('category');
|
$session->load('category');
|
||||||
|
|
||||||
|
$durationSeconds = $session->created_at->diffInSeconds($session->completed_at);
|
||||||
|
|
||||||
return Inertia::render('Session/Result', [
|
return Inertia::render('Session/Result', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'score' => $session->score,
|
'score' => $session->score,
|
||||||
'result' => $session->result,
|
'result' => $session->result,
|
||||||
'categoryName' => $session->category->name,
|
'categoryName' => $session->category->name,
|
||||||
|
'durationSeconds' => $durationSeconds,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public function rules(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'answers' => ['required', 'array', 'size:10'],
|
'answers' => ['required', 'array', 'size:10'],
|
||||||
'answers.*' => ['required', 'string', 'in:yes,no'],
|
'answers.*' => ['required', 'string', 'in:yes,unknown,no'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ public function messages(): array
|
|||||||
'answers.size' => 'All 10 screening questions must be answered.',
|
'answers.size' => 'All 10 screening questions must be answered.',
|
||||||
'answers.*.required' => 'Each screening question must have an answer.',
|
'answers.*.required' => 'Each screening question must have an answer.',
|
||||||
'answers.*.string' => 'Each answer must be a valid text value.',
|
'answers.*.string' => 'Each answer must be a valid text value.',
|
||||||
'answers.*.in' => 'Each answer must be either "yes" or "no".',
|
'answers.*.in' => 'Each answer must be "yes", "I don\'t know", or "no".',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
app/Models/Config.php
Normal file
42
app/Models/Config.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
final class Config extends Model
|
||||||
|
{
|
||||||
|
/** The primary key column for config entries is a string key, not an auto-incrementing integer. */
|
||||||
|
protected $primaryKey = 'key';
|
||||||
|
|
||||||
|
/** Disable auto-incrementing since the primary key is a string. */
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
/** The primary key type is a string. */
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
/** Allow mass assignment on all columns. */
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the attribute cast definitions for this model,
|
||||||
|
* ensuring json_value is always hydrated as a PHP array.
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'json_value' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to filter config records by their string key identifier.
|
||||||
|
*/
|
||||||
|
public function scopeForKey(Builder $query, string $key): Builder
|
||||||
|
{
|
||||||
|
return $query->where('key', $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ final class User extends Authenticatable
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'username',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'azure_id',
|
'azure_id',
|
||||||
|
|||||||
@@ -12,16 +12,17 @@
|
|||||||
final class DownloadExcel extends BaseDownloadExcel
|
final class DownloadExcel extends BaseDownloadExcel
|
||||||
{
|
{
|
||||||
protected $onlyIndexFields = false;
|
protected $onlyIndexFields = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Model|mixed $row
|
* @param Model|mixed $row
|
||||||
*/
|
*/
|
||||||
public function map($row): array
|
public function map($row): array
|
||||||
{
|
{
|
||||||
$only = array_map('strval', $this->getOnly());
|
$only = array_map('strval', $this->getOnly());
|
||||||
$except = $this->getExcept();
|
$except = $this->getExcept();
|
||||||
|
|
||||||
if ($row instanceof Model) {
|
if ($row instanceof Model) {
|
||||||
if (!$this->onlyIndexFields && $except === null && (!is_array($only) || count($only) === 0)) {
|
if (! $this->onlyIndexFields && $except === null && (! is_array($only) || count($only) === 0)) {
|
||||||
$except = $row->getHidden();
|
$except = $row->getHidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +44,11 @@ public function map($row): array
|
|||||||
protected function replaceFieldValuesWhenOnResource(Model $model, array $only = []): array
|
protected function replaceFieldValuesWhenOnResource(Model $model, array $only = []): array
|
||||||
{
|
{
|
||||||
$resource = $this->resolveResource($model);
|
$resource = $this->resolveResource($model);
|
||||||
$fields = $this->resourceFields($resource);
|
$fields = $this->resourceFields($resource);
|
||||||
|
|
||||||
$row = [];
|
$row = [];
|
||||||
foreach ($fields as $field) {
|
foreach ($fields as $field) {
|
||||||
if (!$this->isExportableField($field)) {
|
if (! $this->isExportableField($field)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,16 @@
|
|||||||
|
|
||||||
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\ID;
|
use Laravel\Nova\Fields\ID;
|
||||||
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 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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Nova;
|
namespace App\Nova;
|
||||||
|
|
||||||
use Laravel\Nova\Fields\DateTime;
|
use App\Nova\Actions\DownloadExcel;
|
||||||
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\Text;
|
use Laravel\Nova\Fields\Text;
|
||||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||||
use App\Nova\Actions\DownloadExcel;
|
|
||||||
|
|
||||||
final class CategoryResource extends Resource
|
final class CategoryResource extends Resource
|
||||||
{
|
{
|
||||||
|
|||||||
139
app/Nova/ConfigResource.php
Normal file
139
app/Nova/ConfigResource.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Nova;
|
||||||
|
|
||||||
|
use Laravel\Nova\Fields\DateTime;
|
||||||
|
use Laravel\Nova\Fields\Text;
|
||||||
|
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||||
|
|
||||||
|
final class ConfigResource extends Resource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The model the resource corresponds to.
|
||||||
|
*
|
||||||
|
* @var class-string<\App\Models\Config>
|
||||||
|
*/
|
||||||
|
public static string $model = \App\Models\Config::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns that should be searched.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public static $search = ['key'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logical group associated with the resource.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public static $group = 'Other';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the displayable label of the resource.
|
||||||
|
*/
|
||||||
|
public static function label(): string
|
||||||
|
{
|
||||||
|
return 'Settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the displayable singular label of the resource.
|
||||||
|
*/
|
||||||
|
public static function singularLabel(): string
|
||||||
|
{
|
||||||
|
return 'Setting';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the display title from the config class title or falls back to the raw key.
|
||||||
|
*/
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return $this->getConfigClass()?->title ?? $this->resource->key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the fields displayed by the resource.
|
||||||
|
*
|
||||||
|
* @return array<int, \Laravel\Nova\Fields\Field|\Laravel\Nova\Panel|\Laravel\Nova\ResourceTool|\Illuminate\Http\Resources\MergeValue>
|
||||||
|
*/
|
||||||
|
public function fields(NovaRequest $request): array
|
||||||
|
{
|
||||||
|
return array_merge(
|
||||||
|
[
|
||||||
|
Text::make('Name', 'key')
|
||||||
|
->resolveUsing(fn ($value) => $this->getConfigClass()?->title ?? $value)
|
||||||
|
->exceptOnForms(),
|
||||||
|
|
||||||
|
Text::make('Description', 'key')
|
||||||
|
->resolveUsing(fn () => $this->getConfigClass()?->description ?? '')
|
||||||
|
->exceptOnForms(),
|
||||||
|
],
|
||||||
|
array_map(
|
||||||
|
fn ($field) => $field->hideFromIndex(),
|
||||||
|
$this->getConfigClass()?->getNovaFields() ?? []
|
||||||
|
),
|
||||||
|
[
|
||||||
|
DateTime::make('Created At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(),
|
||||||
|
DateTime::make('Updated At')->exceptOnForms()->hideFromIndex()->sortable()->filterable(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cards available for the request.
|
||||||
|
*
|
||||||
|
* @return array<int, \Laravel\Nova\Card>
|
||||||
|
*/
|
||||||
|
public function cards(NovaRequest $request): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the filters available for the resource.
|
||||||
|
*
|
||||||
|
* @return array<int, \Laravel\Nova\Filters\Filter>
|
||||||
|
*/
|
||||||
|
public function filters(NovaRequest $request): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lenses available for the resource.
|
||||||
|
*
|
||||||
|
* @return array<int, \Laravel\Nova\Lenses\Lens>
|
||||||
|
*/
|
||||||
|
public function lenses(NovaRequest $request): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actions available for the resource.
|
||||||
|
*
|
||||||
|
* @return array<int, \Laravel\Nova\Actions\Action>
|
||||||
|
*/
|
||||||
|
public function actions(NovaRequest $request): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the config class instance for this resource's key via the Config service.
|
||||||
|
*/
|
||||||
|
private function getConfigClass(): ?object
|
||||||
|
{
|
||||||
|
$key = $this->resource?->key;
|
||||||
|
|
||||||
|
if ($key === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(\App\Services\Config::class)->getConfigClassForGroup($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
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\Code;
|
use Laravel\Nova\Fields\Code;
|
||||||
use Laravel\Nova\Fields\DateTime;
|
use Laravel\Nova\Fields\DateTime;
|
||||||
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 App\Nova\Actions\DownloadExcel;
|
|
||||||
|
|
||||||
final class LogResource extends Resource
|
final class LogResource extends Resource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
|
|
||||||
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\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\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 App\Nova\Actions\DownloadExcel;
|
|
||||||
|
|
||||||
final class QuestionGroupResource extends Resource
|
final class QuestionGroupResource extends Resource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ public static function perPageViaRelationshipOptions()
|
|||||||
return [10, 25, 50];
|
return [10, 25, 50];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an "index" query for the given resource.
|
* Build an "index" query for the given resource.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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\Boolean;
|
use Laravel\Nova\Fields\Boolean;
|
||||||
use Laravel\Nova\Fields\DateTime;
|
use Laravel\Nova\Fields\DateTime;
|
||||||
@@ -11,7 +12,6 @@
|
|||||||
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 App\Nova\Actions\DownloadExcel;
|
|
||||||
|
|
||||||
final class ScreeningResource extends Resource
|
final class ScreeningResource extends Resource
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ final class User extends Resource
|
|||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
public static $search = [
|
public static $search = [
|
||||||
'id', 'name', 'email', 'department', 'job_title',
|
'id', 'name', 'username', 'email', 'department', 'job_title',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,12 +59,19 @@ public function fields(NovaRequest $request): array
|
|||||||
->rules('required', 'max:255')
|
->rules('required', 'max:255')
|
||||||
->help('The user\'s full name, imported from Azure AD when they first log in.'),
|
->help('The user\'s full name, imported from Azure AD when they first log in.'),
|
||||||
|
|
||||||
|
Text::make('Username')
|
||||||
|
->sortable()
|
||||||
|
->rules('required', 'max:255')
|
||||||
|
->creationRules('unique:users,username')
|
||||||
|
->updateRules('unique:users,username,{{resourceId}}')
|
||||||
|
->help('The user\'s Azure AD principal name (UPN), used to identify them when logging in via SSO.'),
|
||||||
|
|
||||||
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.'),
|
->help('The user\'s email address.'),
|
||||||
|
|
||||||
Text::make('Azure ID', 'azure_id')
|
Text::make('Azure ID', 'azure_id')
|
||||||
->onlyOnDetail()
|
->onlyOnDetail()
|
||||||
|
|||||||
71
app/Policies/ConfigPolicy.php
Normal file
71
app/Policies/ConfigPolicy.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Config;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
final class ConfigPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any config records.
|
||||||
|
* Config records are auto-created by the Config Service, so all users may view the list.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view a specific config record.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Config $config): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the config record.
|
||||||
|
* All users with Nova access may edit config values.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Config $config): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create config records.
|
||||||
|
* Config records are auto-created by the Config Service — manual creation is not permitted.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the config record.
|
||||||
|
* Config records are managed by the Config Service and must not be manually deleted.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Config $config): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the config record.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, Config $config): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the config record.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, Config $config): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ final class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->singleton(\App\Services\Config::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Nova\CategoryResource;
|
use App\Nova\CategoryResource;
|
||||||
use App\Nova\Dashboards\Main;
|
use App\Nova\ConfigResource;
|
||||||
use App\Nova\LogResource;
|
use App\Nova\LogResource;
|
||||||
use App\Nova\QuestionGroupResource;
|
use App\Nova\QuestionGroupResource;
|
||||||
use App\Nova\QuestionResource;
|
use App\Nova\QuestionResource;
|
||||||
@@ -29,7 +29,10 @@ public function boot(): void
|
|||||||
|
|
||||||
Nova::mainMenu(function (Request $request) {
|
Nova::mainMenu(function (Request $request) {
|
||||||
return [
|
return [
|
||||||
MenuSection::dashboard(Main::class)->icon('home'),
|
MenuSection::make('Main', [
|
||||||
|
MenuItem::link('Dashboard', '/dashboards/main'),
|
||||||
|
MenuItem::externalLink('To questionnaire ↗', '/'),
|
||||||
|
])->icon('home'),
|
||||||
|
|
||||||
MenuSection::make('Questionnaire', [
|
MenuSection::make('Questionnaire', [
|
||||||
MenuItem::resource(QuestionResource::class),
|
MenuItem::resource(QuestionResource::class),
|
||||||
@@ -46,6 +49,10 @@ public function boot(): void
|
|||||||
MenuSection::make('Users', [
|
MenuSection::make('Users', [
|
||||||
MenuItem::resource(\App\Nova\User::class),
|
MenuItem::resource(\App\Nova\User::class),
|
||||||
])->icon('users')->collapsible(),
|
])->icon('users')->collapsible(),
|
||||||
|
|
||||||
|
MenuSection::make('Settings', [
|
||||||
|
MenuItem::resource(ConfigResource::class),
|
||||||
|
])->icon('cog')->collapsible(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
126
app/Services/Config.php
Normal file
126
app/Services/Config.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Config as ConfigModel;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
/** Typed public property for the content group's disclaimer field. */
|
||||||
|
public string $contentDisclaimer = '';
|
||||||
|
|
||||||
|
/** Indexed config class instances keyed by their group key string. */
|
||||||
|
private array $configClasses = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-discovers Config Field classes from app/Configs/, loads or creates
|
||||||
|
* their database rows, and populates typed public properties with cast values.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->discoverConfigClasses();
|
||||||
|
$this->hydrateProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans app/Configs/ for PHP files, instantiates each class,
|
||||||
|
* and indexes them by their key property.
|
||||||
|
*/
|
||||||
|
private function discoverConfigClasses(): void
|
||||||
|
{
|
||||||
|
$files = glob(app_path('Configs/*.php')) ?: [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$className = 'App\\Configs\\'.pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
$instance = new $className;
|
||||||
|
$this->configClasses[$instance->key] = $instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates over all discovered config classes, ensures a DB row exists,
|
||||||
|
* resolves typed field values, and assigns them to public properties.
|
||||||
|
*/
|
||||||
|
private function hydrateProperties(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dbRecords = ConfigModel::all()->keyBy('key');
|
||||||
|
|
||||||
|
foreach ($this->configClasses as $key => $instance) {
|
||||||
|
$record = $this->ensureDbRecord($key, $dbRecords);
|
||||||
|
$jsonValue = Arr::wrap((array) ($record->json_value ?? []));
|
||||||
|
$this->assignFieldProperties($key, $instance, $jsonValue);
|
||||||
|
}
|
||||||
|
} catch (QueryException) {
|
||||||
|
// Silently skip if configs table does not yet exist during migrations.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures a database record exists for the given config key,
|
||||||
|
* creating one with an empty json_value if it does not.
|
||||||
|
*/
|
||||||
|
private function ensureDbRecord(string $key, Collection $dbRecords): ConfigModel
|
||||||
|
{
|
||||||
|
if ($dbRecords->has($key)) {
|
||||||
|
return $dbRecords->get($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigModel::firstOrCreate(
|
||||||
|
['key' => $key],
|
||||||
|
['json_value' => ''],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves each field value from json_value (with default fallback),
|
||||||
|
* applies type casting, and assigns to the matching typed public property.
|
||||||
|
*/
|
||||||
|
private function assignFieldProperties(string $key, object $instance, array $jsonValue): void
|
||||||
|
{
|
||||||
|
foreach ($instance->getFieldKeys() as $fieldKey => $definition) {
|
||||||
|
$type = Arr::get($definition, 'type', 'string');
|
||||||
|
$rawValue = Arr::get($jsonValue, $fieldKey);
|
||||||
|
|
||||||
|
if ($rawValue === null || $rawValue === '') {
|
||||||
|
$rawValue = $instance->getDefault($fieldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$castedValue = $this->castValue($rawValue, $type);
|
||||||
|
$propertyName = Str::camel($key).Str::studly($fieldKey);
|
||||||
|
|
||||||
|
if (property_exists($this, $propertyName)) {
|
||||||
|
$this->{$propertyName} = $castedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Casts a raw value to the PHP type defined by the config field type string.
|
||||||
|
*/
|
||||||
|
private function castValue(mixed $value, string $type): mixed
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'string', 'markdown' => (string) $value,
|
||||||
|
'int', 'integer' => (int) $value,
|
||||||
|
'bool', 'boolean' => (bool) $value,
|
||||||
|
'float' => (float) $value,
|
||||||
|
default => $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Config Field class instance for a given group key,
|
||||||
|
* or null when no class has been discovered for that key.
|
||||||
|
*/
|
||||||
|
public function getConfigClassForGroup(string $group): ?object
|
||||||
|
{
|
||||||
|
return Arr::get($this->configClasses, $group);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'actions' => [
|
'actions' => [
|
||||||
'resource' => \Laravel\Nova\Actions\ActionResource::class,
|
'resource' => null,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public function definition(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => fake()->name(),
|
||||||
|
'username' => fake()->unique()->userName(),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => self::$password ??= Hash::make('password'),
|
'password' => self::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public function up(): void
|
|||||||
Schema::create('screenings', function (Blueprint $table) {
|
Schema::create('screenings', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
$table->integer('score')->nullable();
|
$table->decimal('score', 4, 1)->nullable();
|
||||||
$table->boolean('passed')->nullable();
|
$table->boolean('passed')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates the configs table used to store database-driven key/value configuration.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('configs', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->json('json_value')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the configs table on rollback.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('configs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add the username column to the users table.
|
||||||
|
* Nullable to allow existing users to exist without a username.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->string('username')->unique()->nullable()->after('name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the username column from the users table.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
# Database Schema Documentation
|
# Database Schema Documentation
|
||||||
|
|
||||||
> Generated: 2026-02-03 05:38:33
|
> Generated: 2026-03-19 10:55:06
|
||||||
> Database: go-no-go
|
> Database: go-no-go
|
||||||
> Total Tables: 13
|
> Total Tables: 23
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [action_events](#action_events)
|
- [action_events](#action_events)
|
||||||
|
- [answers](#answers)
|
||||||
- [cache](#cache)
|
- [cache](#cache)
|
||||||
- [cache_locks](#cache_locks)
|
- [cache_locks](#cache_locks)
|
||||||
|
- [categories](#categories)
|
||||||
|
- [configs](#configs)
|
||||||
- [failed_jobs](#failed_jobs)
|
- [failed_jobs](#failed_jobs)
|
||||||
- [job_batches](#job_batches)
|
- [job_batches](#job_batches)
|
||||||
- [jobs](#jobs)
|
- [jobs](#jobs)
|
||||||
|
- [logs](#logs)
|
||||||
- [migrations](#migrations)
|
- [migrations](#migrations)
|
||||||
- [nova_field_attachments](#nova_field_attachments)
|
- [nova_field_attachments](#nova_field_attachments)
|
||||||
- [nova_notifications](#nova_notifications)
|
- [nova_notifications](#nova_notifications)
|
||||||
- [nova_pending_field_attachments](#nova_pending_field_attachments)
|
- [nova_pending_field_attachments](#nova_pending_field_attachments)
|
||||||
- [password_reset_tokens](#password_reset_tokens)
|
- [password_reset_tokens](#password_reset_tokens)
|
||||||
|
- [question_groups](#question_groups)
|
||||||
|
- [questionnaire_sessions](#questionnaire_sessions)
|
||||||
|
- [questions](#questions)
|
||||||
|
- [roles](#roles)
|
||||||
|
- [screening_answers](#screening_answers)
|
||||||
|
- [screenings](#screenings)
|
||||||
- [sessions](#sessions)
|
- [sessions](#sessions)
|
||||||
- [users](#users)
|
- [users](#users)
|
||||||
|
|
||||||
@@ -46,6 +56,25 @@ ## action_events
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## answers
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| session_id | bigint unsigned | NO | MUL | NULL | | → questionnaire_sessions.id |
|
||||||
|
| question_id | bigint unsigned | NO | MUL | NULL | | → questions.id |
|
||||||
|
| value | varchar(255) | YES | | NULL | | |
|
||||||
|
| text_value | text | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **answers_question_id_foreign**: `question_id` → `questions.id`
|
||||||
|
- **answers_session_id_foreign**: `session_id` → `questionnaire_sessions.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## cache
|
## cache
|
||||||
|
|
||||||
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
@@ -66,6 +95,29 @@ ## cache_locks
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## categories
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| name | varchar(255) | NO | UNI | NULL | | |
|
||||||
|
| sort_order | int unsigned | NO | | 0 | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## configs
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| key | varchar(255) | NO | PRI | NULL | | |
|
||||||
|
| json_value | json | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## failed_jobs
|
## failed_jobs
|
||||||
|
|
||||||
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
@@ -111,6 +163,26 @@ ## jobs
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## logs
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| user_id | bigint unsigned | YES | MUL | NULL | | → users.id |
|
||||||
|
| session_id | bigint unsigned | YES | MUL | NULL | | → questionnaire_sessions.id |
|
||||||
|
| category_id | bigint unsigned | YES | MUL | NULL | | → categories.id |
|
||||||
|
| action | varchar(100) | NO | | NULL | | |
|
||||||
|
| metadata | json | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **logs_category_id_foreign**: `category_id` → `categories.id`
|
||||||
|
- **logs_session_id_foreign**: `session_id` → `questionnaire_sessions.id`
|
||||||
|
- **logs_user_id_foreign**: `user_id` → `users.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## migrations
|
## migrations
|
||||||
|
|
||||||
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
@@ -175,6 +247,116 @@ ## password_reset_tokens
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## question_groups
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| category_id | bigint unsigned | NO | MUL | NULL | | → categories.id |
|
||||||
|
| name | varchar(255) | NO | | NULL | | |
|
||||||
|
| sort_order | int unsigned | NO | | 0 | | |
|
||||||
|
| description | text | YES | | NULL | | |
|
||||||
|
| scoring_instructions | text | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **question_groups_category_id_foreign**: `category_id` → `categories.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## questionnaire_sessions
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| user_id | bigint unsigned | NO | MUL | NULL | | → users.id |
|
||||||
|
| category_id | bigint unsigned | NO | MUL | NULL | | → categories.id |
|
||||||
|
| screening_id | bigint unsigned | YES | MUL | NULL | | → screenings.id |
|
||||||
|
| status | varchar(50) | NO | | in_progress | | |
|
||||||
|
| score | int | YES | | NULL | | |
|
||||||
|
| result | varchar(50) | YES | | NULL | | |
|
||||||
|
| additional_comments | text | YES | | NULL | | |
|
||||||
|
| completed_at | timestamp | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **questionnaire_sessions_category_id_foreign**: `category_id` → `categories.id`
|
||||||
|
- **questionnaire_sessions_screening_id_foreign**: `screening_id` → `screenings.id`
|
||||||
|
- **questionnaire_sessions_user_id_foreign**: `user_id` → `users.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## questions
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| question_group_id | bigint unsigned | NO | MUL | NULL | | → question_groups.id |
|
||||||
|
| text | text | NO | | NULL | | |
|
||||||
|
| has_yes | tinyint(1) | NO | | 0 | | |
|
||||||
|
| has_no | tinyint(1) | NO | | 0 | | |
|
||||||
|
| has_na | tinyint(1) | NO | | 0 | | |
|
||||||
|
| details | varchar(50) | YES | | NULL | | |
|
||||||
|
| sort_order | int unsigned | NO | | 0 | | |
|
||||||
|
| is_scored | tinyint(1) | NO | | 0 | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **questions_question_group_id_foreign**: `question_group_id` → `question_groups.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## roles
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| name | varchar(255) | NO | UNI | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## screening_answers
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| screening_id | bigint unsigned | NO | MUL | NULL | | → screenings.id |
|
||||||
|
| question_number | int unsigned | NO | | NULL | | |
|
||||||
|
| value | varchar(10) | NO | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **screening_answers_screening_id_foreign**: `screening_id` → `screenings.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## screenings
|
||||||
|
|
||||||
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| user_id | bigint unsigned | NO | MUL | NULL | | → users.id |
|
||||||
|
| score | decimal(4,1) | YES | | NULL | | |
|
||||||
|
| passed | tinyint(1) | YES | | NULL | | |
|
||||||
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **screenings_user_id_foreign**: `user_id` → `users.id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## sessions
|
## sessions
|
||||||
|
|
||||||
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
@@ -193,10 +375,18 @@ ## users
|
|||||||
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
| Field | Type | Null | Key | Default | Extra | Foreign Key |
|
||||||
|-------|------|------|-----|---------|-------|-------------|
|
|-------|------|------|-----|---------|-------|-------------|
|
||||||
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | |
|
||||||
|
| role_id | bigint unsigned | NO | MUL | 1 | | → roles.id |
|
||||||
| name | varchar(255) | NO | | NULL | | |
|
| name | varchar(255) | NO | | NULL | | |
|
||||||
|
| username | varchar(255) | NO | UNI | NULL | | |
|
||||||
| email | varchar(255) | NO | UNI | NULL | | |
|
| email | varchar(255) | NO | UNI | NULL | | |
|
||||||
|
| azure_id | varchar(255) | YES | UNI | NULL | | |
|
||||||
|
| photo | varchar(255) | YES | | NULL | | |
|
||||||
|
| job_title | varchar(255) | YES | | NULL | | |
|
||||||
|
| department | varchar(255) | YES | | NULL | | |
|
||||||
|
| company_name | varchar(255) | YES | | NULL | | |
|
||||||
|
| phone | varchar(255) | YES | | NULL | | |
|
||||||
| email_verified_at | timestamp | YES | | NULL | | |
|
| email_verified_at | timestamp | YES | | NULL | | |
|
||||||
| password | varchar(255) | NO | | NULL | | |
|
| password | varchar(255) | YES | | NULL | | |
|
||||||
| two_factor_secret | text | YES | | NULL | | |
|
| two_factor_secret | text | YES | | NULL | | |
|
||||||
| two_factor_recovery_codes | text | YES | | NULL | | |
|
| two_factor_recovery_codes | text | YES | | NULL | | |
|
||||||
| two_factor_confirmed_at | timestamp | YES | | NULL | | |
|
| two_factor_confirmed_at | timestamp | YES | | NULL | | |
|
||||||
@@ -204,5 +394,9 @@ ## users
|
|||||||
| created_at | timestamp | YES | | NULL | | |
|
| created_at | timestamp | YES | | NULL | | |
|
||||||
| updated_at | timestamp | YES | | NULL | | |
|
| updated_at | timestamp | YES | | NULL | | |
|
||||||
|
|
||||||
|
### Foreign Key Constraints
|
||||||
|
|
||||||
|
- **users_role_id_foreign**: `role_id` → `roles.id`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
413
database/seeders/AuditQuestionSeeder.php
Normal file
413
database/seeders/AuditQuestionSeeder.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
345
database/seeders/DigitalSolutionsQuestionSeeder.php
Normal file
345
database/seeders/DigitalSolutionsQuestionSeeder.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,14 +17,17 @@ 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',
|
||||||
|
'username' => 'jonathan.van.rij@agerion.nl',
|
||||||
'email' => 'jonathan.van.rij@agerion.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',
|
'job_title' => 'Senior Developer',
|
||||||
'company_name' => 'Baker Tilly',
|
'company_name' => 'Baker Tilly',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
506
database/seeders/LegalQuestionSeeder.php
Normal file
506
database/seeders/LegalQuestionSeeder.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
database/seeders/OutsourceQuestionSeeder.php
Normal file
349
database/seeders/OutsourceQuestionSeeder.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
449
database/seeders/TaxQuestionSeeder.php
Normal file
449
database/seeders/TaxQuestionSeeder.php
Normal 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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Code Style Reviewer
|
|
||||||
|
|
||||||
Reviews code against project standards including Laravel Pint formatting, strict types declarations, final class declarations, proper use of `Illuminate\Support\Arr` for array operations, adherence to naming conventions, and alignment with CLAUDE.md instructions.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This agent is launched as the **final step** of every task that involves code changes. It ensures all written code complies with project coding standards.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Cypress Code Writer
|
|
||||||
|
|
||||||
Writes, updates, and debugs Cypress E2E tests for user workflows.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent when writing new Cypress E2E tests, updating existing test files, debugging failing tests, adding test coverage for user workflows, or creating test utilities.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Docs Writer
|
|
||||||
|
|
||||||
Creates and updates documentation files and maintains the documentation index.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent to create new documentation, update existing docs when code changes, and keep `docs/index.md` current with all documentation files.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Laravel Blade Code Writer
|
|
||||||
|
|
||||||
Creates and modifies Laravel Blade templates and views.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent when working with Blade templates in `resources/views/`, including creating new templates, fixing layout issues, implementing UI changes, or refactoring template code.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Laravel Config Code Writer
|
|
||||||
|
|
||||||
Manages database-driven config groups, fields, and the Config Service.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent when creating new Config Field classes, updating the Config Service, modifying the Config Model, updating the Nova Config Resource, creating migrations for new config groups, or debugging config value resolution.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Laravel Nova Code Writer
|
|
||||||
|
|
||||||
Creates and modifies Laravel Nova 5 resources, actions, metrics, and dashboards.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent for ALL Nova-related code work in the `App\Nova` namespace, including resources, fields, actions, metrics, lenses, filters, cards, and dashboards.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Laravel PHP Code Writer
|
|
||||||
|
|
||||||
Writes and refactors PHP code (controllers, models, services, migrations) except Nova resources.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent for all PHP code work outside of Nova resources: controllers, models, services, migrations, middleware, form requests, policies, commands, jobs, and any other PHP files.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# PHPUnit Code Writer
|
|
||||||
|
|
||||||
Creates, runs, and validates PHPUnit tests with mocking support.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent to write new test classes, fix failing tests (test issues only), create test seeders with DB facade, set up mocking routes for external API calls, or validate test coverage for PHP classes.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Vue Code Writer
|
|
||||||
|
|
||||||
Builds Vue.js components in the Laravel + Inertia.js stack.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Use this agent when creating new Vue components, modifying existing ones, implementing Inertia page components, handling form submissions with useForm, integrating with Laravel backend endpoints, or fixing Vue-related bugs.
|
|
||||||
|
|
||||||
## Relevant Documentation
|
|
||||||
|
|
||||||
- `docs/index.md` - Master index of all project documentation
|
|
||||||
@@ -14,29 +14,3 @@ ### Root Level
|
|||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- `docs/theming-templating-vue.md` - Design tokens, Tailwind config, layout, shared Vue components, RadioButtonGroup pill buttons, icon and scoring color standards
|
- `docs/theming-templating-vue.md` - Design tokens, Tailwind config, layout, shared Vue components, RadioButtonGroup pill buttons, icon and scoring color standards
|
||||||
|
|
||||||
### Agents
|
|
||||||
|
|
||||||
- `docs/agents/code-style-reviewer.md` - Project-specific notes for the code style review agent
|
|
||||||
- `docs/agents/cypress-code-writer.md` - Project-specific notes for the Cypress E2E test writer agent
|
|
||||||
- `docs/agents/docs-writer.md` - Project-specific notes for the documentation writer agent
|
|
||||||
- `docs/agents/laravel-blade-code-writer.md` - Project-specific notes for the Blade template agent
|
|
||||||
- `docs/agents/laravel-config-code-writer.md` - Project-specific notes for the config management agent
|
|
||||||
- `docs/agents/laravel-nova-code-writer.md` - Project-specific notes for the Nova resource agent
|
|
||||||
- `docs/agents/laravel-php-code-writer.md` - Project-specific notes for the Laravel PHP code agent
|
|
||||||
- `docs/agents/phpunit-code-writer.md` - Project-specific notes for the PHPUnit test agent
|
|
||||||
- `docs/agents/vue-code-writer.md` - Project-specific notes for the Vue.js component agent
|
|
||||||
|
|
||||||
## Available Sub-Agents
|
|
||||||
|
|
||||||
These are the sub-agents available globally that can be used in this project:
|
|
||||||
|
|
||||||
- `code-style-reviewer` - Reviews code against project standards including Laravel Pint, strict types, and naming conventions
|
|
||||||
- `cypress-code-writer` - Writes, updates, and debugs Cypress E2E tests for user workflows
|
|
||||||
- `docs-writer` - Creates and updates documentation files and maintains the documentation index
|
|
||||||
- `laravel-blade-code-writer` - Creates and modifies Laravel Blade templates and views
|
|
||||||
- `laravel-config-code-writer` - Manages database-driven config groups, fields, and the Config Service
|
|
||||||
- `laravel-nova-code-writer` - Creates and modifies Laravel Nova 5 resources, actions, metrics, and dashboards
|
|
||||||
- `laravel-php-code-writer` - Writes and refactors PHP code (controllers, models, services, migrations) except Nova resources
|
|
||||||
- `phpunit-code-writer` - Creates, runs, and validates PHPUnit tests with mocking support
|
|
||||||
- `vue-code-writer` - Builds Vue.js components in the Laravel + Inertia.js stack
|
|
||||||
|
|||||||
@@ -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] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
33
docs/updates.md
Normal file
33
docs/updates.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Updates — March 16, 2026
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### Disclaimer Page
|
||||||
|
A new disclaimer page has been added to the application. When a disclaimer text is configured, users must read and accept it before they can start a screening. The disclaimer page renders formatted content (including headings, lists, links, and tables) and is accessible via a link on the landing page.
|
||||||
|
|
||||||
|
- A checkbox appears on the landing page requiring users to agree to the disclaimer before continuing
|
||||||
|
- The disclaimer content can be managed by administrators through the admin panel
|
||||||
|
|
||||||
|
### Admin Settings Panel
|
||||||
|
A new **Settings** section has been added to the admin panel. This provides a central place for administrators to manage application-wide content, starting with the disclaimer text. Settings are stored in the database and take effect immediately — no deployments or restarts needed.
|
||||||
|
|
||||||
|
### "I Don't Know" Answer Option
|
||||||
|
The pre-screening questionnaire now includes a third answer option: **"I don't know"**, in addition to "Yes" and "No". This gives users a more honest way to respond when they are uncertain, and is scored at half a point.
|
||||||
|
|
||||||
|
### Session Duration Tracking
|
||||||
|
The session result page now shows how long it took to complete a questionnaire. The duration is displayed in a human-readable format (e.g. "4m 32s" or "1h 15m").
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- A **Back button** has been added to every page in the application, making it easy to navigate to the previous step at any time
|
||||||
|
- A **sticky Back button** appears in the top-right corner when you scroll down on longer pages, so you never have to scroll back up to navigate away
|
||||||
|
- Visiting a page that doesn't exist now redirects you back to the landing page instead of showing an error
|
||||||
|
|
||||||
|
### Disclaimer Link Behavior
|
||||||
|
The disclaimer link on the landing page now opens in the same tab for a smoother experience.
|
||||||
|
|
||||||
|
### Visual Polish
|
||||||
|
- Removed heavy background panels from question cards and session details for a cleaner, lighter look
|
||||||
|
- Improved the layout of session details on the result page (category, completion date, and duration are now evenly spaced)
|
||||||
|
- Auto-save actions (answers and comments) no longer show a loading indicator, reducing visual noise while filling in questionnaires
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@inertiajs/vue3": "^2.3.13",
|
"@inertiajs/vue3": "^2.3.13",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"vue": "^3.5.27"
|
"vue": "^3.5.27"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3257,6 +3258,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||||
|
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@inertiajs/vue3": "^2.3.13",
|
"@inertiajs/vue3": "^2.3.13",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"vue": "^3.5.27"
|
"vue": "^3.5.27"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
resources/excel/BTI Go no go checklist - Legal-1.xlsx
Normal file
BIN
resources/excel/BTI Go no go checklist - Legal-1.xlsx
Normal file
Binary file not shown.
BIN
resources/excel/BTI Go no go checklist - Tax.-1.xlsx
Normal file
BIN
resources/excel/BTI Go no go checklist - Tax.-1.xlsx
Normal file
Binary file not shown.
BIN
resources/excel/BTI Go no go checklist - audit v2-1.xlsx
Normal file
BIN
resources/excel/BTI Go no go checklist - audit v2-1.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
118
resources/js/Components/FlashNotification.vue
Normal file
118
resources/js/Components/FlashNotification.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { usePage } from '@inertiajs/vue3'
|
||||||
|
import { CheckCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/20/solid'
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const type = ref('success') // 'success' | 'error'
|
||||||
|
|
||||||
|
let autoDismissTimer = null
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (autoDismissTimer) {
|
||||||
|
clearTimeout(autoDismissTimer)
|
||||||
|
autoDismissTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible.value = false
|
||||||
|
clearTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFlash(flash) {
|
||||||
|
clearTimer()
|
||||||
|
|
||||||
|
if (flash?.error) {
|
||||||
|
message.value = flash.error
|
||||||
|
type.value = 'error'
|
||||||
|
visible.value = true
|
||||||
|
// Error messages stay until manually dismissed
|
||||||
|
} else if (flash?.success) {
|
||||||
|
message.value = flash.success
|
||||||
|
type.value = 'success'
|
||||||
|
visible.value = true
|
||||||
|
// Auto-dismiss success after 8 seconds
|
||||||
|
autoDismissTimer = setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
}, 8000)
|
||||||
|
} else {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for flash prop changes on each Inertia page visit
|
||||||
|
watch(
|
||||||
|
() => page.props.flash,
|
||||||
|
(flash) => showFlash(flash),
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-300 ease-out"
|
||||||
|
enter-from-class="-translate-y-full opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-200 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="-translate-y-full opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
:class="[
|
||||||
|
'relative z-40 w-full shadow-lg',
|
||||||
|
type === 'error'
|
||||||
|
? 'bg-rose-600'
|
||||||
|
: 'bg-emerald-600',
|
||||||
|
]"
|
||||||
|
role="alert"
|
||||||
|
:aria-live="type === 'error' ? 'assertive' : 'polite'"
|
||||||
|
>
|
||||||
|
<div class="px-6 py-3 flex items-center gap-3 max-w-7xl mx-auto">
|
||||||
|
<!-- Icon -->
|
||||||
|
<component
|
||||||
|
:is="type === 'error' ? ExclamationTriangleIcon : CheckCircleIcon"
|
||||||
|
class="w-5 h-5 text-white/90 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p class="flex-1 text-sm font-medium text-white leading-snug">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Dismiss button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto shrink-0 rounded p-1 text-white/70 hover:text-white hover:bg-white/10 transition-colors focus:outline-none focus:ring-2 focus:ring-white/40"
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar for success auto-dismiss -->
|
||||||
|
<div
|
||||||
|
v-if="type === 'success'"
|
||||||
|
class="absolute bottom-0 left-0 h-[2px] bg-white/30 animate-[shrink_8s_linear_forwards]"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes shrink {
|
||||||
|
from { width: 100%; }
|
||||||
|
to { width: 0%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,15 +18,41 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
// Array of option values that should be individually disabled.
|
||||||
|
// An already-selected option is never disabled via this prop.
|
||||||
|
disabledOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
// Tooltip text shown when hovering over a disabled option.
|
||||||
|
disabledTooltip: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when an option should be treated as disabled.
|
||||||
|
* An option that is already selected (checked) is never individually disabled
|
||||||
|
* via disabledOptions — it remains selectable so the user can keep it.
|
||||||
|
*/
|
||||||
|
const isOptionDisabled = (value) => {
|
||||||
|
if (props.disabled) return true
|
||||||
|
if (props.modelValue === value) return false
|
||||||
|
return props.disabledOptions.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleChange = (value) => {
|
const handleChange = (value) => {
|
||||||
|
// Safety guard: do not emit for disabled options
|
||||||
|
if (isOptionDisabled(value)) return
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSegmentClasses = (index) => {
|
const getSegmentClasses = (option, index) => {
|
||||||
|
const optionDisabled = isOptionDisabled(option.value)
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
'inline-flex',
|
'inline-flex',
|
||||||
'items-center',
|
'items-center',
|
||||||
@@ -40,11 +66,6 @@ const getSegmentClasses = (index) => {
|
|||||||
'duration-200',
|
'duration-200',
|
||||||
'bg-white/5',
|
'bg-white/5',
|
||||||
'text-gray-400',
|
'text-gray-400',
|
||||||
'cursor-pointer',
|
|
||||||
'hover:bg-white/10',
|
|
||||||
'hover:text-gray-200',
|
|
||||||
'peer-checked:hover:bg-primary-dark',
|
|
||||||
'peer-checked:hover:text-gray-900',
|
|
||||||
'peer-checked:bg-primary',
|
'peer-checked:bg-primary',
|
||||||
'peer-checked:text-gray-900',
|
'peer-checked:text-gray-900',
|
||||||
'peer-checked:font-semibold',
|
'peer-checked:font-semibold',
|
||||||
@@ -54,16 +75,25 @@ const getSegmentClasses = (index) => {
|
|||||||
'peer-focus-visible:ring-offset-surface',
|
'peer-focus-visible:ring-offset-surface',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (optionDisabled) {
|
||||||
|
// Disabled: visually subtle, no pointer, no hover effects
|
||||||
|
classes.push('opacity-35', 'cursor-not-allowed')
|
||||||
|
} else {
|
||||||
|
// Enabled: pointer cursor and hover effects
|
||||||
|
classes.push(
|
||||||
|
'cursor-pointer',
|
||||||
|
'hover:bg-white/10',
|
||||||
|
'hover:text-gray-200',
|
||||||
|
'peer-checked:hover:bg-primary-dark',
|
||||||
|
'peer-checked:hover:text-gray-900',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// All except last: add divider
|
// All except last: add divider
|
||||||
if (index < props.options.length - 1) {
|
if (index < props.options.length - 1) {
|
||||||
classes.push('border-r', 'border-white/10')
|
classes.push('border-r', 'border-white/10')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disabled state
|
|
||||||
if (props.disabled) {
|
|
||||||
classes.push('disabled:opacity-50', 'disabled:cursor-not-allowed')
|
|
||||||
}
|
|
||||||
|
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -80,12 +110,12 @@ const getSegmentClasses = (index) => {
|
|||||||
:name="name"
|
:name="name"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
:checked="modelValue === option.value"
|
:checked="modelValue === option.value"
|
||||||
:disabled="disabled"
|
:disabled="isOptionDisabled(option.value)"
|
||||||
:data-cy="option.value === 'not_applicable' ? 'na' : option.value"
|
:data-cy="option.value === 'not_applicable' ? 'na' : option.value"
|
||||||
@change="handleChange(option.value)"
|
@change="handleChange(option.value)"
|
||||||
class="sr-only peer"
|
class="sr-only peer"
|
||||||
/>
|
/>
|
||||||
<span :class="getSegmentClasses(index)">
|
<span :class="getSegmentClasses(option, index)" :title="isOptionDisabled(option.value) ? disabledTooltip : ''">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import PageHeader from '@/Components/PageHeader.vue'
|
import PageHeader from '@/Components/PageHeader.vue'
|
||||||
|
import FlashNotification from '@/Components/FlashNotification.vue'
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const pageTitle = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
<PageHeader :title="pageTitle" />
|
<PageHeader :title="pageTitle" />
|
||||||
|
<FlashNotification />
|
||||||
|
|
||||||
<!-- Growth symbol watermark -->
|
<!-- Growth symbol watermark -->
|
||||||
<img
|
<img
|
||||||
|
|||||||
257
resources/js/Pages/Disclaimer.vue
Normal file
257
resources/js/Pages/Disclaimer.vue
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Head } from '@inertiajs/vue3'
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||||
|
import AppButton from '@/Components/AppButton.vue'
|
||||||
|
|
||||||
|
defineOptions({ layout: AppLayout })
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsedContent = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked(props.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomBackButtonRef = ref(null)
|
||||||
|
const showStickyBack = ref(false)
|
||||||
|
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!bottomBackButtonRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
([entry]) => { showStickyBack.value = !entry.isIntersecting },
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Disclaimer" />
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto px-4 py-10">
|
||||||
|
<!-- Sticky back button — appears when bottom back button scrolls out of view -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-4"
|
||||||
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0"
|
||||||
|
leave-to-class="opacity-0 translate-x-4"
|
||||||
|
>
|
||||||
|
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
|
||||||
|
<AppButton variant="ghost" size="lg" href="/">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Page heading -->
|
||||||
|
<div class="mb-10">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Disclaimer</h1>
|
||||||
|
<div class="h-px bg-gradient-to-r from-primary/40 via-primary/10 to-transparent mt-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rendered markdown content -->
|
||||||
|
<div class="prose-dark" v-html="parsedContent" />
|
||||||
|
|
||||||
|
<!-- Bottom back button -->
|
||||||
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
|
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose-dark :deep(h1) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(h2) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(h3) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(h4) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(p) {
|
||||||
|
color: #d1d5db; /* text-gray-300 */
|
||||||
|
line-height: 1.75;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(a) {
|
||||||
|
color: #d1ec51; /* text-primary */
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(a:hover) {
|
||||||
|
color: #b5d136; /* text-primary-dark */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ul) {
|
||||||
|
color: #d1d5db;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ul li) {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ul li::before) {
|
||||||
|
content: '—';
|
||||||
|
color: #d1ec51; /* text-primary */
|
||||||
|
position: absolute;
|
||||||
|
left: -1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ol) {
|
||||||
|
color: #d1d5db;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
counter-reset: ol-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ol li) {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
counter-increment: ol-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(ol li::before) {
|
||||||
|
content: counter(ol-counter) '.';
|
||||||
|
color: #d1ec51; /* text-primary */
|
||||||
|
position: absolute;
|
||||||
|
left: -2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(blockquote) {
|
||||||
|
border-left: 3px solid #d1ec51; /* border-primary */
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: #9ca3af; /* text-gray-400 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(blockquote p) {
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(pre) {
|
||||||
|
background-color: rgba(31, 41, 55, 0.5); /* bg-gray-800/50 */
|
||||||
|
border: 1px solid rgba(75, 85, 99, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(pre code) {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(code) {
|
||||||
|
background-color: rgba(31, 41, 55, 0.5); /* bg-gray-800/50 */
|
||||||
|
border: 1px solid rgba(75, 85, 99, 0.3);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: #d1ec51; /* text-primary */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(strong) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(em) {
|
||||||
|
color: #d1d5db;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #4b5563; /* border-gray-600 */
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(th) {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-bottom: 2px solid #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(td) {
|
||||||
|
color: #d1d5db;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-bottom: 1px solid rgba(75, 85, 99, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-dark :deep(tr:last-child td) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Head, router, usePage } from '@inertiajs/vue3'
|
import { Head, router, usePage } from '@inertiajs/vue3'
|
||||||
|
import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/20/solid'
|
||||||
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 props = defineProps({
|
||||||
|
hasDisclaimer: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
|
|
||||||
|
const disclaimerAgreed = ref(false)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => {
|
const isAuthenticated = computed(() => {
|
||||||
return page.props.auth?.user != null
|
return page.props.auth?.user != null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canContinue = computed(() => {
|
||||||
|
return !props.hasDisclaimer || disclaimerAgreed.value
|
||||||
|
})
|
||||||
|
|
||||||
const userInfo = computed(() => {
|
const userInfo = computed(() => {
|
||||||
const user = page.props.auth?.user
|
const user = page.props.auth?.user
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
@@ -32,6 +43,8 @@ const userInfo = computed(() => {
|
|||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
router.post('/screening')
|
router.post('/screening')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => router.post('/logout')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -51,9 +64,78 @@ 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 v-if="isAuthenticated" size="lg" @click="handleContinue" data-cy="start-screening">
|
|
||||||
|
<!-- Disclaimer checkbox — only shown when authenticated and a disclaimer exists -->
|
||||||
|
<div v-if="isAuthenticated && hasDisclaimer" class="flex items-start justify-center gap-3 mb-6">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer select-none group">
|
||||||
|
<!-- Hidden native checkbox -->
|
||||||
|
<input
|
||||||
|
v-model="disclaimerAgreed"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<!-- Custom checkbox -->
|
||||||
|
<span
|
||||||
|
class="
|
||||||
|
mt-0.5 flex-shrink-0
|
||||||
|
w-5 h-5 rounded
|
||||||
|
border-2 border-gray-500
|
||||||
|
bg-transparent
|
||||||
|
flex items-center justify-center
|
||||||
|
transition-all duration-200
|
||||||
|
peer-checked:bg-primary peer-checked:border-primary
|
||||||
|
group-hover:border-gray-300
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!-- Checkmark — visible when checked via peer -->
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 text-gray-900 opacity-0 transition-opacity duration-200 peer-checked:opacity-100"
|
||||||
|
:class="{ 'opacity-100': disclaimerAgreed }"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 6l3 3 5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<!-- Label text -->
|
||||||
|
<span class="text-sm text-gray-400 text-left leading-relaxed">
|
||||||
|
I have read and agree to the
|
||||||
|
<a
|
||||||
|
href="/disclaimer"
|
||||||
|
class="text-primary underline underline-offset-2 hover:text-primary-dark transition-colors duration-150"
|
||||||
|
>
|
||||||
|
disclaimer
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
size="lg"
|
||||||
|
:disabled="!canContinue"
|
||||||
|
@click="handleContinue"
|
||||||
|
data-cy="start-screening"
|
||||||
|
>
|
||||||
Continue
|
Continue
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
<button
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
type="button"
|
||||||
|
class="mt-3 flex items-center gap-1.5 mx-auto text-sm text-gray-500 hover:text-gray-300 transition-colors duration-150 cursor-pointer"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<ArrowRightStartOnRectangleIcon class="w-4 h-4" />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
<AppButton v-else size="lg" href="/login" external>
|
<AppButton v-else size="lg" href="/login" external>
|
||||||
Log in
|
Log in
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Head, router } from '@inertiajs/vue3'
|
import { Head, router } from '@inertiajs/vue3'
|
||||||
|
import { ArrowLeftIcon } 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'
|
||||||
|
|
||||||
@@ -34,12 +36,47 @@ const handleStartCategory = (categoryId) => {
|
|||||||
screening_id: props.screening.id,
|
screening_id: props.screening.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bottomBackButtonRef = ref(null)
|
||||||
|
const showStickyBack = ref(false)
|
||||||
|
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!bottomBackButtonRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
([entry]) => { showStickyBack.value = !entry.isIntersecting },
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Screening Result" />
|
<Head title="Screening Result" />
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<!-- Sticky back button -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-4"
|
||||||
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0"
|
||||||
|
leave-to-class="opacity-0 translate-x-4"
|
||||||
|
>
|
||||||
|
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
|
||||||
|
<AppButton variant="ghost" size="lg" href="/" data-cy="sticky-back">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-6">Pre-Screening Result</h1>
|
<h1 class="text-3xl font-bold text-white mb-6">Pre-Screening Result</h1>
|
||||||
|
|
||||||
<!-- Score Display -->
|
<!-- Score Display -->
|
||||||
@@ -57,21 +94,14 @@ const handleStartCategory = (categoryId) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Failed: Show Again button -->
|
|
||||||
<div v-if="!passed" class="flex justify-center">
|
|
||||||
<AppButton size="lg" href="/">
|
|
||||||
Again
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Passed: Show category picker -->
|
<!-- Passed: Show category picker -->
|
||||||
<div v-else data-cy="category-select">
|
<div v-if="passed" data-cy="category-select">
|
||||||
<h2 class="text-2xl font-semibold text-white mb-4">Select a Category</h2>
|
<h2 class="text-2xl font-semibold text-white mb-4">Select a Category</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="category in categories"
|
v-for="category in categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
class="bg-surface/50 rounded-lg p-4 flex items-center justify-between hover:bg-surface/70 transition-colors"
|
class="rounded-lg p-4 flex items-center justify-between hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
<span class="text-white font-medium">{{ category.name }}</span>
|
<span class="text-white font-medium">{{ category.name }}</span>
|
||||||
<AppButton size="md" @click="handleStartCategory(category.id)">
|
<AppButton size="md" @click="handleStartCategory(category.id)">
|
||||||
@@ -80,5 +110,13 @@ const handleStartCategory = (categoryId) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom back button -->
|
||||||
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
|
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/" data-cy="back-to-landing">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Head, useForm } from '@inertiajs/vue3'
|
import { Head, useForm } from '@inertiajs/vue3'
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||||
|
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid'
|
||||||
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 RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
|
import RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
|
||||||
|
|
||||||
defineOptions({ layout: AppLayout })
|
defineOptions({ layout: AppLayout })
|
||||||
|
|
||||||
|
const MAX_MAYBE_ANSWERS = 8
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
screening: {
|
screening: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -32,23 +36,83 @@ const handleSubmit = () => {
|
|||||||
form.put(`/screening/${props.screening.id}`)
|
form.put(`/screening/${props.screening.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentScore = computed(() => {
|
||||||
|
return Object.values(form.answers).reduce((score, value) => {
|
||||||
|
if (value === 'yes') return score + 1
|
||||||
|
if (value === 'unknown') return score + 0.5
|
||||||
|
return score
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(currentScore, (score) => {
|
||||||
|
console.log('Current screening score:', score)
|
||||||
|
})
|
||||||
|
|
||||||
const allAnswered = computed(() => {
|
const allAnswered = computed(() => {
|
||||||
return Object.values(form.answers).every(v => v !== null)
|
return Object.values(form.answers).every(v => v !== null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unknownCount = computed(() => {
|
||||||
|
return Object.values(form.answers).filter(v => v === 'unknown').length
|
||||||
|
})
|
||||||
|
|
||||||
|
const maybeLimitReached = computed(() => {
|
||||||
|
return unknownCount.value >= MAX_MAYBE_ANSWERS
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomBackButtonRef = ref(null)
|
||||||
|
const showStickyBack = ref(false)
|
||||||
|
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!bottomBackButtonRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
([entry]) => { showStickyBack.value = !entry.isIntersecting },
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Pre-Screening Questions" />
|
<Head title="Pre-Screening Questions" />
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<!-- Sticky back button -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-4"
|
||||||
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0"
|
||||||
|
leave-to-class="opacity-0 translate-x-4"
|
||||||
|
>
|
||||||
|
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
|
||||||
|
<AppButton variant="ghost" size="lg" href="/" data-cy="sticky-back">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Pre-Screening Questions</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Pre-Screening Questions</h1>
|
||||||
<p class="text-gray-400 mb-8">Answer all 10 questions to proceed. Each "Yes" answer scores 1 point. You need at least 5 points to pass.</p>
|
<p class="text-gray-400 mb-8">Please answer all questions to proceed.</p>
|
||||||
|
|
||||||
|
<p v-if="maybeLimitReached" class="text-amber-400/80 text-sm mb-6 flex items-center gap-2">
|
||||||
|
<ExclamationTriangleIcon class="h-4 w-4 shrink-0" />
|
||||||
|
You've used all {{ MAX_MAYBE_ANSWERS }} "I don't know" answers. Please choose Yes or No for the remaining questions.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4 mb-8">
|
<div class="space-y-4 mb-8">
|
||||||
<div
|
<div
|
||||||
v-for="(question, index) in questions"
|
v-for="(question, index) in questions"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="bg-surface/50 rounded-lg p-5"
|
class="rounded-lg p-5"
|
||||||
:data-cy="`screening-answer-${index + 1}`"
|
:data-cy="`screening-answer-${index + 1}`"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
@@ -60,8 +124,11 @@ const allAnswered = computed(() => {
|
|||||||
:name="`question-${index + 1}`"
|
:name="`question-${index + 1}`"
|
||||||
:options="[
|
:options="[
|
||||||
{ value: 'yes', label: 'Yes' },
|
{ value: 'yes', label: 'Yes' },
|
||||||
|
{ value: 'unknown', label: 'I don\'t know' },
|
||||||
{ value: 'no', label: 'No' },
|
{ value: 'no', label: 'No' },
|
||||||
]"
|
]"
|
||||||
|
:disabled-options="maybeLimitReached && form.answers[index + 1] !== 'unknown' ? ['unknown'] : []"
|
||||||
|
disabled-tooltip="You can only select "I don't know" for a maximum of 8 questions"
|
||||||
/>
|
/>
|
||||||
<p v-if="form.errors[`answers.${index + 1}`]" class="text-red-500 text-sm mt-1">
|
<p v-if="form.errors[`answers.${index + 1}`]" class="text-red-500 text-sm mt-1">
|
||||||
{{ form.errors[`answers.${index + 1}`] }}
|
{{ form.errors[`answers.${index + 1}`] }}
|
||||||
@@ -71,10 +138,17 @@ const allAnswered = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="mt-12 pt-8 border-t border-white/[0.06]">
|
||||||
<AppButton size="lg" @click="handleSubmit" :loading="form.processing" :disabled="!allAnswered || form.processing" data-cy="submit-screening">
|
<div class="flex justify-between items-center">
|
||||||
Submit
|
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" href="/" data-cy="back-to-landing">
|
||||||
</AppButton>
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
|
<AppButton size="lg" @click="handleSubmit" :loading="form.processing" :disabled="!allAnswered || form.processing" data-cy="submit-screening">
|
||||||
|
Submit
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
durationSeconds: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => {
|
||||||
|
const total = props.durationSeconds
|
||||||
|
const hours = Math.floor(total / 3600)
|
||||||
|
const minutes = Math.floor((total % 3600) / 60)
|
||||||
|
const seconds = total % 60
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
})
|
})
|
||||||
|
|
||||||
const resultDisplay = computed(() => {
|
const resultDisplay = computed(() => {
|
||||||
@@ -85,16 +104,24 @@ const resultDisplay = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session Details -->
|
<!-- Session Details -->
|
||||||
<div class="bg-surface/50 rounded-lg p-6 mb-8">
|
<div class="rounded-lg p-6 mb-8">
|
||||||
<h2 class="text-xl font-semibold text-white mb-4">Session Details</h2>
|
<h2 class="text-xl font-semibold text-white mb-4">Session Details</h2>
|
||||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
<dl class="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-gray-400">Category</dt>
|
<dt class="text-gray-400">Category</dt>
|
||||||
<dd class="text-white font-medium">{{ categoryName }}</dd>
|
<dd class="text-white font-medium">{{ categoryName }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex justify-center">
|
||||||
<dt class="text-gray-400">Completed</dt>
|
<div>
|
||||||
<dd class="text-white font-medium">{{ new Date(session.completed_at).toLocaleDateString() }}</dd>
|
<dt class="text-gray-400">Completed</dt>
|
||||||
|
<dd class="text-white font-medium">{{ new Date(session.completed_at).toLocaleDateString() }}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-400">Duration</dt>
|
||||||
|
<dd class="text-white font-medium">{{ formattedDuration }}</dd>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch, nextTick } from 'vue'
|
import { computed, onMounted, onUnmounted, 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 { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||||
import QuestionCard from '@/Components/QuestionCard.vue'
|
import QuestionCard from '@/Components/QuestionCard.vue'
|
||||||
|
|
||||||
defineOptions({ layout: AppLayout })
|
defineOptions({ layout: AppLayout })
|
||||||
@@ -39,6 +40,24 @@ const initializeAnswers = () => {
|
|||||||
}
|
}
|
||||||
initializeAnswers()
|
initializeAnswers()
|
||||||
|
|
||||||
|
const bottomBackButtonRef = ref(null)
|
||||||
|
const showStickyBack = ref(false)
|
||||||
|
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!bottomBackButtonRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
([entry]) => { showStickyBack.value = !entry.isIntersecting },
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
|
observer.observe(bottomBackButtonRef.value.$el || bottomBackButtonRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
// Validation state
|
// Validation state
|
||||||
const validationErrors = ref({})
|
const validationErrors = ref({})
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
@@ -57,6 +76,7 @@ const saveAnswer = (questionId) => {
|
|||||||
}, {
|
}, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
preserveState: true,
|
preserveState: true,
|
||||||
|
showProgress: false,
|
||||||
only: ['answers'],
|
only: ['answers'],
|
||||||
onStart: () => { processing.value = true },
|
onStart: () => { processing.value = true },
|
||||||
onFinish: () => { processing.value = false },
|
onFinish: () => { processing.value = false },
|
||||||
@@ -81,6 +101,7 @@ const saveComments = () => {
|
|||||||
additionalComments.put(`/sessions/${props.session.id}`, {
|
additionalComments.put(`/sessions/${props.session.id}`, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
preserveState: true,
|
preserveState: true,
|
||||||
|
showProgress: false,
|
||||||
})
|
})
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
@@ -175,6 +196,27 @@ const completeSession = async () => {
|
|||||||
<Head :title="`${session.category.name} Questionnaire`" />
|
<Head :title="`${session.category.name} Questionnaire`" />
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto px-4 py-10">
|
<div class="max-w-3xl mx-auto px-4 py-10">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-x-4"
|
||||||
|
enter-to-class="opacity-100 translate-x-0"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 translate-x-0"
|
||||||
|
leave-to-class="opacity-0 translate-x-4"
|
||||||
|
>
|
||||||
|
<div v-if="showStickyBack" class="fixed top-[88px] right-6 z-40">
|
||||||
|
<AppButton
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
:href="`/screening/${session.screening_id}/result`"
|
||||||
|
data-cy="sticky-back"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Title area -->
|
<!-- Title area -->
|
||||||
<div class="mb-10">
|
<div class="mb-10">
|
||||||
<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>
|
||||||
@@ -259,7 +301,12 @@ const completeSession = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-between items-center">
|
||||||
|
<AppButton ref="bottomBackButtonRef" variant="ghost" size="lg" :href="`/screening/${session.screening_id}/result`" data-cy="back-to-screening">
|
||||||
|
<ArrowLeftIcon class="h-5 w-5" />
|
||||||
|
Back
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
<AppButton size="lg" :loading="processing" @click="completeSession" data-cy="complete-session">
|
<AppButton size="lg" :loading="processing" @click="completeSession" data-cy="complete-session">
|
||||||
Complete
|
Complete
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
// Landing page (public)
|
// Landing page (public)
|
||||||
Route::get('/', [LandingController::class, 'index'])->name('landing');
|
Route::get('/', [LandingController::class, 'index'])->name('landing');
|
||||||
|
Route::get('/disclaimer', [LandingController::class, 'disclaimer'])->name('disclaimer');
|
||||||
|
|
||||||
// Authentication routes
|
// Authentication routes
|
||||||
Route::get('/login', [SocialiteController::class, 'redirect'])->name('login');
|
Route::get('/login', [SocialiteController::class, 'redirect'])->name('login');
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
Route::post('/logout', [SocialiteController::class, 'logout'])->name('logout')->middleware('auth');
|
Route::post('/logout', [SocialiteController::class, 'logout'])->name('logout')->middleware('auth');
|
||||||
|
|
||||||
// Questionnaire routes (authenticated)
|
// Questionnaire routes (authenticated)
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->missing(fn () => redirect('/'))->group(function () {
|
||||||
// Screening routes
|
// Screening routes
|
||||||
Route::post('/screening', [ScreeningController::class, 'store'])->name('screening.store');
|
Route::post('/screening', [ScreeningController::class, 'store'])->name('screening.store');
|
||||||
Route::get('/screening/{screening}', [ScreeningController::class, 'show'])->name('screening.show');
|
Route::get('/screening/{screening}', [ScreeningController::class, 'show'])->name('screening.show');
|
||||||
@@ -34,12 +35,13 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dev auto-login route
|
// Dev auto-login route (local and testing environments only)
|
||||||
Route::get('/login-for-testing', function () {
|
if (app()->environment(['local', 'testing'])) {
|
||||||
$user = \App\Models\User::where('email', 'jonathan.van.rij@agerion.nl')->first();
|
Route::get('/login-jonathan', function () {
|
||||||
|
$user = \App\Models\User::where('username', 'jonathan.van.rij@agerion.nl')->first();
|
||||||
|
|
||||||
auth()->login($user);
|
auth()->login($user);
|
||||||
|
|
||||||
return redirect('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
@@ -31,13 +32,18 @@ public function test_callback_creates_new_user_and_logs_in(): void
|
|||||||
$this->markTestSkipped('Skipped due to application bug: password field is NOT NULL but controller passes null');
|
$this->markTestSkipped('Skipped due to application bug: password field is NOT NULL but controller passes null');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_callback_matches_existing_user_by_email(): void
|
public function test_callback_matches_existing_user_by_username(): void
|
||||||
{
|
{
|
||||||
$existingUser = User::factory()->create([
|
DB::table('users')->insert([
|
||||||
'email' => 'existing@example.com',
|
'username' => 'existing@example.com',
|
||||||
|
'email' => 'real@example.com',
|
||||||
'name' => 'Original Name',
|
'name' => 'Original Name',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$existingUser = User::where('username', 'existing@example.com')->first();
|
||||||
|
|
||||||
$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');
|
||||||
@@ -45,6 +51,7 @@ public function test_callback_matches_existing_user_by_email(): void
|
|||||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||||
$socialiteUser->shouldReceive('offsetExists')->andReturn(false);
|
$socialiteUser->shouldReceive('offsetExists')->andReturn(false);
|
||||||
$socialiteUser->user = [
|
$socialiteUser->user = [
|
||||||
|
'mail' => 'real@example.com',
|
||||||
'jobTitle' => null,
|
'jobTitle' => null,
|
||||||
'department' => null,
|
'department' => null,
|
||||||
'companyName' => null,
|
'companyName' => null,
|
||||||
@@ -62,11 +69,12 @@ public function test_callback_matches_existing_user_by_email(): void
|
|||||||
$this->get('/auth/callback')
|
$this->get('/auth/callback')
|
||||||
->assertRedirect('/');
|
->assertRedirect('/');
|
||||||
|
|
||||||
$this->assertEquals(1, User::where('email', 'existing@example.com')->count());
|
$this->assertEquals(1, User::where('username', 'existing@example.com')->count());
|
||||||
|
|
||||||
$existingUser->refresh();
|
$existingUser->refresh();
|
||||||
|
|
||||||
$this->assertEquals('Updated Name', $existingUser->name);
|
$this->assertEquals('Updated Name', $existingUser->name);
|
||||||
|
$this->assertEquals('real@example.com', $existingUser->email);
|
||||||
$this->assertAuthenticatedAs($existingUser);
|
$this->assertAuthenticatedAs($existingUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +90,18 @@ public function test_logout_logs_out_and_redirects_to_landing(): void
|
|||||||
|
|
||||||
public function test_login_jonathan_works_in_testing_env(): void
|
public function test_login_jonathan_works_in_testing_env(): void
|
||||||
{
|
{
|
||||||
User::factory()->create([
|
DB::table('users')->insert([
|
||||||
|
'username' => 'jonathan.van.rij@agerion.nl',
|
||||||
'email' => 'jonathan@blijnder.nl',
|
'email' => 'jonathan@blijnder.nl',
|
||||||
'name' => 'Jonathan',
|
'name' => 'Jonathan',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get('/login-for-testing')
|
$this->get('/login-jonathan')
|
||||||
->assertRedirect('/');
|
->assertRedirect('/');
|
||||||
|
|
||||||
$user = User::where('email', 'jonathan@blijnder.nl')->first();
|
$user = User::where('username', 'jonathan.van.rij@agerion.nl')->first();
|
||||||
|
|
||||||
$this->assertAuthenticatedAs($user);
|
$this->assertAuthenticatedAs($user);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user