Repo rebuild
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Modules\Company\Models\Company;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user with an isolated company tenant.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
'company_name' => ['nullable', 'string', 'max:255'],
|
||||
])->validate();
|
||||
|
||||
return DB::transaction(function () use ($input): User {
|
||||
$companyName = $input['company_name'] ?? ($input['name'] . ' Company');
|
||||
|
||||
$company = Company::create([
|
||||
'name' => $companyName,
|
||||
'slug' => Str::slug($companyName . '-' . Str::random(6)),
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
]);
|
||||
|
||||
return User::create([
|
||||
'company_id' => $company->id,
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'is_company_admin' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class Logout
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke()
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
Session::invalidate();
|
||||
Session::regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Appearance settings')]
|
||||
class Appearance extends Component
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeleteUserForm extends Component
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public string $password = '';
|
||||
|
||||
/**
|
||||
* Delete the currently authenticated user.
|
||||
*/
|
||||
public function deleteUser(Logout $logout): void
|
||||
{
|
||||
$this->validate([
|
||||
'password' => $this->currentPasswordRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user && Schema::hasTable(config('permission.table_names.model_has_roles', 'model_has_roles'))) {
|
||||
$user->syncRoles([]);
|
||||
}
|
||||
|
||||
tap($user, $logout(...))->delete();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Profile settings')]
|
||||
class Profile extends Component
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile information for the currently authenticated user.
|
||||
*/
|
||||
public function updateProfileInformation(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $this->validate($this->profileRules($user->id));
|
||||
|
||||
$user->fill($validated);
|
||||
|
||||
if ($user->isDirty('email')) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
Flux::toast(variant: 'success', text: __('Profile updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email verification notification to the current user.
|
||||
*/
|
||||
public function resendVerificationNotification(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
|
||||
Flux::toast(text: __('A new verification link has been sent to your email address.'));
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function hasUnverifiedEmail(): bool
|
||||
{
|
||||
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function showDeleteUser(): bool
|
||||
{
|
||||
return ! Auth::user() instanceof MustVerifyEmail
|
||||
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Exception;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Passkeys\Actions\DeletePasskey;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Security settings')]
|
||||
class Security extends Component
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
#[Locked]
|
||||
public bool $canManageTwoFactor;
|
||||
|
||||
#[Locked]
|
||||
public bool $twoFactorEnabled;
|
||||
|
||||
#[Locked]
|
||||
public bool $requiresConfirmation;
|
||||
|
||||
#[Locked]
|
||||
public string $qrCodeSvg = '';
|
||||
|
||||
#[Locked]
|
||||
public string $manualSetupKey = '';
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public bool $showVerificationStep = false;
|
||||
|
||||
#[Validate('required|string|size:6', onUpdate: false)]
|
||||
public string $code = '';
|
||||
|
||||
#[Locked]
|
||||
public bool $canManagePasskeys;
|
||||
|
||||
#[Locked]
|
||||
public array $passkeys = [];
|
||||
|
||||
public bool $showDeleteModal = false;
|
||||
|
||||
#[Locked]
|
||||
public ?int $deletingPasskeyId = null;
|
||||
|
||||
#[Locked]
|
||||
public string $deletingPasskeyName = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
|
||||
{
|
||||
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
|
||||
|
||||
if ($this->canManageTwoFactor) {
|
||||
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
|
||||
$disableTwoFactorAuthentication(auth()->user());
|
||||
}
|
||||
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
|
||||
}
|
||||
|
||||
$this->canManagePasskeys = Features::canManagePasskeys();
|
||||
|
||||
if ($this->canManagePasskeys) {
|
||||
$this->loadPasskeys();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the password for the currently authenticated user.
|
||||
*/
|
||||
public function updatePassword(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'current_password' => $this->currentPasswordRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->update([
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
Flux::toast(variant: 'success', text: __('Password updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user's passkeys.
|
||||
*/
|
||||
public function loadPasskeys(): void
|
||||
{
|
||||
$this->passkeys = Auth::user()->passkeys()
|
||||
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($passkey) => [
|
||||
'id' => $passkey->id,
|
||||
'name' => $passkey->name,
|
||||
'authenticator' => $passkey->authenticator,
|
||||
'created_at_diff' => $passkey->created_at->diffForHumans(),
|
||||
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete confirmation modal.
|
||||
*/
|
||||
public function confirmDelete(int $passkeyId): void
|
||||
{
|
||||
$passkey = Auth::user()->passkeys()->findOrFail($passkeyId);
|
||||
|
||||
$this->deletingPasskeyId = $passkey->id;
|
||||
$this->deletingPasskeyName = $passkey->name;
|
||||
$this->showDeleteModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the passkey.
|
||||
*/
|
||||
public function deletePasskey(DeletePasskey $deletePasskey): void
|
||||
{
|
||||
if (! $this->deletingPasskeyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$passkey = $user->passkeys()->findOrFail($this->deletingPasskeyId);
|
||||
|
||||
$deletePasskey($user, $passkey);
|
||||
|
||||
$this->closeDeleteModal();
|
||||
$this->loadPasskeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the delete confirmation modal.
|
||||
*/
|
||||
public function closeDeleteModal(): void
|
||||
{
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingPasskeyId = null;
|
||||
$this->deletingPasskeyName = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable two-factor authentication for the user.
|
||||
*/
|
||||
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
|
||||
{
|
||||
$enableTwoFactorAuthentication(auth()->user());
|
||||
|
||||
if (! $this->requiresConfirmation) {
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
|
||||
$this->loadSetupData();
|
||||
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the two-factor authentication setup data for the user.
|
||||
*/
|
||||
private function loadSetupData(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
|
||||
$this->manualSetupKey = decrypt($user->two_factor_secret);
|
||||
} catch (Exception) {
|
||||
$this->addError('setupData', 'Failed to fetch setup data.');
|
||||
|
||||
$this->reset('qrCodeSvg', 'manualSetupKey');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the two-factor verification step if necessary.
|
||||
*/
|
||||
public function showVerificationIfNecessary(): void
|
||||
{
|
||||
if ($this->requiresConfirmation) {
|
||||
$this->showVerificationStep = true;
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm two-factor authentication for the user.
|
||||
*/
|
||||
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
$this->twoFactorEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset two-factor verification state.
|
||||
*/
|
||||
public function resetVerification(): void
|
||||
{
|
||||
$this->reset('code', 'showVerificationStep');
|
||||
|
||||
$this->resetErrorBag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable two-factor authentication for the user.
|
||||
*/
|
||||
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
|
||||
{
|
||||
$disableTwoFactorAuthentication(auth()->user());
|
||||
|
||||
$this->twoFactorEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the two-factor authentication modal.
|
||||
*/
|
||||
public function closeModal(): void
|
||||
{
|
||||
$this->reset(
|
||||
'code',
|
||||
'manualSetupKey',
|
||||
'qrCodeSvg',
|
||||
'showModal',
|
||||
'showVerificationStep',
|
||||
);
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
if (! $this->requiresConfirmation) {
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current modal configuration state.
|
||||
*/
|
||||
#[Computed]
|
||||
public function modalConfig(): array
|
||||
{
|
||||
if ($this->twoFactorEnabled) {
|
||||
return [
|
||||
'title' => __('Two-factor authentication enabled'),
|
||||
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
|
||||
'buttonText' => __('Close'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->showVerificationStep) {
|
||||
return [
|
||||
'title' => __('Verify authentication code'),
|
||||
'description' => __('Enter the 6-digit code from your authenticator app.'),
|
||||
'buttonText' => __('Continue'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => __('Enable two-factor authentication'),
|
||||
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
|
||||
'buttonText' => __('Continue'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings\TwoFactor;
|
||||
|
||||
use Exception;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class RecoveryCodes extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public array $recoveryCodes = [];
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadRecoveryCodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new recovery codes for the user.
|
||||
*/
|
||||
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
|
||||
{
|
||||
$generateNewRecoveryCodes(auth()->user());
|
||||
|
||||
$this->loadRecoveryCodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the recovery codes for the user.
|
||||
*/
|
||||
private function loadRecoveryCodes(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
|
||||
try {
|
||||
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
|
||||
} catch (Exception) {
|
||||
$this->addError('recoveryCodes', 'Failed to load recovery codes');
|
||||
|
||||
$this->recoveryCodes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class LicenseKey extends \Modules\Platform\app\Models\LicenseKey {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Payment extends \Modules\Platform\app\Models\Payment {}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Modules\Company\Models\Company;
|
||||
use Modules\Company\Models\CompanyDirectorAssignment;
|
||||
use Modules\Company\Models\CompanyDivision;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'email', 'password', 'is_platform_admin', 'is_company_admin', 'status',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_platform_admin' => 'boolean',
|
||||
'is_company_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
public function divisions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(CompanyDivision::class, 'company_division_user')
|
||||
->withPivot(['company_id', 'role', 'is_director'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function directorAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(CompanyDirectorAssignment::class);
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)
|
||||
->explode(' ')
|
||||
->take(2)
|
||||
->map(fn ($word) => Str::substr((string) $word, 0, 1))
|
||||
->implode('');
|
||||
}
|
||||
|
||||
public function isMpbaPlatformAdmin(): bool
|
||||
{
|
||||
return $this->is_platform_admin && $this->company?->is_system_company === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\LazyLoadingViolationException;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
|
||||
throw new LazyLoadingViolationException(
|
||||
$model,
|
||||
$relation,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure default behaviors for production-ready applications.
|
||||
*/
|
||||
protected function configureDefaults(): void
|
||||
{
|
||||
Date::use(CarbonImmutable::class);
|
||||
|
||||
DB::prohibitDestructiveCommands(
|
||||
app()->isProduction(),
|
||||
);
|
||||
|
||||
Password::defaults(
|
||||
fn (): ?Password => app()->isProduction()
|
||||
? Password::min(12)
|
||||
->mixedCase()
|
||||
->letters()
|
||||
->numbers()
|
||||
->symbols()
|
||||
->uncompromised()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('livewire.auth.login'));
|
||||
Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
|
||||
Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
|
||||
Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
|
||||
Fortify::registerView(fn () => view('livewire.auth.register'));
|
||||
Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()) . '|' . $request->ip(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user = null) {
|
||||
return in_array(optional($user)->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Jobs;
|
||||
use Stancl\Tenancy\Listeners;
|
||||
use Stancl\Tenancy\Middleware;
|
||||
|
||||
class TenancyServiceProvider extends ServiceProvider
|
||||
{
|
||||
// By default, no namespace is used to support the callable array syntax.
|
||||
public static string $controllerNamespace = '';
|
||||
|
||||
public function events()
|
||||
{
|
||||
return [
|
||||
// Tenant events
|
||||
Events\CreatingTenant::class => [],
|
||||
Events\TenantCreated::class => [
|
||||
JobPipeline::make([
|
||||
Jobs\CreateDatabase::class,
|
||||
Jobs\MigrateDatabase::class,
|
||||
// Jobs\SeedDatabase::class,
|
||||
|
||||
// Your own jobs to prepare the tenant.
|
||||
// Provision API keys, create S3 buckets, anything you want!
|
||||
|
||||
])->send(function (Events\TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||
],
|
||||
Events\SavingTenant::class => [],
|
||||
Events\TenantSaved::class => [],
|
||||
Events\UpdatingTenant::class => [],
|
||||
Events\TenantUpdated::class => [],
|
||||
Events\DeletingTenant::class => [],
|
||||
Events\TenantDeleted::class => [
|
||||
JobPipeline::make([
|
||||
Jobs\DeleteDatabase::class,
|
||||
])->send(function (Events\TenantDeleted $event) {
|
||||
return $event->tenant;
|
||||
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||
],
|
||||
|
||||
// Domain events
|
||||
Events\CreatingDomain::class => [],
|
||||
Events\DomainCreated::class => [],
|
||||
Events\SavingDomain::class => [],
|
||||
Events\DomainSaved::class => [],
|
||||
Events\UpdatingDomain::class => [],
|
||||
Events\DomainUpdated::class => [],
|
||||
Events\DeletingDomain::class => [],
|
||||
Events\DomainDeleted::class => [],
|
||||
|
||||
// Database events
|
||||
Events\DatabaseCreated::class => [],
|
||||
Events\DatabaseMigrated::class => [],
|
||||
Events\DatabaseSeeded::class => [],
|
||||
Events\DatabaseRolledBack::class => [],
|
||||
Events\DatabaseDeleted::class => [],
|
||||
|
||||
// Tenancy events
|
||||
Events\InitializingTenancy::class => [],
|
||||
Events\TenancyInitialized::class => [
|
||||
Listeners\BootstrapTenancy::class,
|
||||
],
|
||||
|
||||
Events\EndingTenancy::class => [],
|
||||
Events\TenancyEnded::class => [
|
||||
Listeners\RevertToCentralContext::class,
|
||||
],
|
||||
|
||||
Events\BootstrappingTenancy::class => [],
|
||||
Events\TenancyBootstrapped::class => [],
|
||||
Events\RevertingToCentralContext::class => [],
|
||||
Events\RevertedToCentralContext::class => [],
|
||||
|
||||
// Resource syncing
|
||||
Events\SyncedResourceSaved::class => [
|
||||
Listeners\UpdateSyncedResource::class,
|
||||
],
|
||||
|
||||
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
|
||||
Events\SyncedResourceChangedInForeignDatabase::class => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot()
|
||||
{
|
||||
$this->bootEvents();
|
||||
$this->mapRoutes();
|
||||
|
||||
$this->makeTenancyMiddlewareHighestPriority();
|
||||
}
|
||||
|
||||
protected function bootEvents()
|
||||
{
|
||||
foreach ($this->events() as $event => $listeners) {
|
||||
foreach ($listeners as $listener) {
|
||||
if ($listener instanceof JobPipeline) {
|
||||
$listener = $listener->toListener();
|
||||
}
|
||||
|
||||
Event::listen($event, $listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function mapRoutes()
|
||||
{
|
||||
$this->app->booted(function () {
|
||||
if (file_exists(base_path('routes/tenant.php'))) {
|
||||
Route::namespace(static::$controllerNamespace)
|
||||
->group(base_path('routes/tenant.php'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function makeTenancyMiddlewareHighestPriority()
|
||||
{
|
||||
$tenancyMiddleware = [
|
||||
// Even higher priority than the initialization middleware
|
||||
Middleware\PreventAccessFromCentralDomains::class,
|
||||
|
||||
Middleware\InitializeTenancyByDomain::class,
|
||||
Middleware\InitializeTenancyBySubdomain::class,
|
||||
Middleware\InitializeTenancyByDomainOrSubdomain::class,
|
||||
Middleware\InitializeTenancyByPath::class,
|
||||
Middleware\InitializeTenancyByRequestData::class,
|
||||
];
|
||||
|
||||
foreach (array_reverse($tenancyMiddleware) as $middleware) {
|
||||
$this->app[Kernel::class]->prependToMiddlewarePriority($middleware);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Charts;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class DashboardChartService
|
||||
{
|
||||
/**
|
||||
* Build all dashboard charts in a Chart.js-friendly shape.
|
||||
*
|
||||
* Every chart method is defensive because the legacy WMS/M3 data source is
|
||||
* an ODBC connection and some environments do not have IBM i available.
|
||||
* The dashboard still renders cleanly with zeroed data instead of failing.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function dashboard(): array
|
||||
{
|
||||
return [
|
||||
'metrics' => $this->metrics(),
|
||||
'charts' => [
|
||||
'pickingLastSevenDays' => $this->pickingLastSevenDays(),
|
||||
'dispatchesLastSevenDays' => $this->dispatchesLastSevenDays(),
|
||||
'stockMovementsByType' => $this->stockMovementsByType(),
|
||||
'lowStockByWarehouse' => $this->lowStockByWarehouse(),
|
||||
'warehouseUtilisation' => $this->warehouseUtilisation(),
|
||||
'systemHealth' => $this->systemHealth(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{label: string, value: int|string, hint: string, icon: string}>
|
||||
*/
|
||||
private function metrics(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'label' => 'Open pick waves',
|
||||
'value' => $this->safeCount('ICM688M1', "A4PICKSTS IN ('B', 'P')"),
|
||||
'hint' => 'Active or in-progress pick work',
|
||||
'icon' => 'fa-box-open',
|
||||
],
|
||||
[
|
||||
'label' => 'Pending manifests',
|
||||
'value' => $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')"),
|
||||
'hint' => 'Waiting for close or confirmation',
|
||||
'icon' => 'fa-truck-loading',
|
||||
],
|
||||
[
|
||||
'label' => 'M3 errors',
|
||||
'value' => $this->safeCount('ICM699M', "SHERROR = 'X'"),
|
||||
'hint' => 'Messages needing attention',
|
||||
'icon' => 'fa-exclamation-triangle',
|
||||
],
|
||||
[
|
||||
'label' => 'Warehouses',
|
||||
'value' => $this->safeCount('IC670M'),
|
||||
'hint' => 'Known warehouse records',
|
||||
'icon' => 'fa-warehouse',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pickingLastSevenDays(): array
|
||||
{
|
||||
$labels = $this->lastDaysLabels(7);
|
||||
$data = $this->emptySeries($labels);
|
||||
|
||||
try {
|
||||
$rows = DB::connection('odbc')->select(
|
||||
'SELECT DATE(A5PICKCMTS) AS PICK_DATE, COUNT(*) AS TOTAL
|
||||
FROM EWDAT.ICM688M2
|
||||
WHERE A5PICKCMTS >= CURRENT DATE - 7 DAYS
|
||||
GROUP BY DATE(A5PICKCMTS)
|
||||
ORDER BY PICK_DATE',
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$key = Carbon::parse($row->PICK_DATE)->format('d M');
|
||||
if (array_key_exists($key, $data)) {
|
||||
$data[$key] = (int) $row->TOTAL;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->lineChart($labels, array_values($data), 'Pick lines completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function dispatchesLastSevenDays(): array
|
||||
{
|
||||
$labels = $this->lastDaysLabels(7);
|
||||
$data = $this->emptySeries($labels);
|
||||
|
||||
try {
|
||||
$rows = DB::connection('odbc')->select(
|
||||
"SELECT SHDT AS SHIP_DATE, COUNT(DISTINCT SHMACPKR) AS TOTAL
|
||||
FROM EWDAT.ICM699M
|
||||
WHERE SHTYPE = 'M3-Res-out'
|
||||
AND SHSOURCE = 'CloseShipmentCoChargeAPI'
|
||||
AND SHDATA LIKE 'Success%'
|
||||
AND SHDT >= VARCHAR_FORMAT(CURRENT DATE - 7 DAYS, 'YYYYMMDD')
|
||||
GROUP BY SHDT
|
||||
ORDER BY SHDT",
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$key = Carbon::createFromFormat('Ymd', (string) $row->SHIP_DATE)->format('d M');
|
||||
if (array_key_exists($key, $data)) {
|
||||
$data[$key] = (int) $row->TOTAL;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->barChart($labels, array_values($data), 'Dispatches closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function stockMovementsByType(): array
|
||||
{
|
||||
$labels = ['Transfer', 'Replenishment', 'Packing', 'Shipment', 'Adjustment'];
|
||||
$data = [0, 0, 0, 0, 0];
|
||||
|
||||
try {
|
||||
$rows = DB::connection('odbc')->select(
|
||||
"SELECT TRIM(SHSOURCE) AS SOURCE_NAME, COUNT(*) AS TOTAL
|
||||
FROM EWDAT.ICM699M
|
||||
WHERE SHDT >= VARCHAR_FORMAT(CURRENT DATE - 30 DAYS, 'YYYYMMDD')
|
||||
GROUP BY TRIM(SHSOURCE)
|
||||
ORDER BY TOTAL DESC
|
||||
FETCH FIRST 5 ROWS ONLY",
|
||||
);
|
||||
|
||||
if (! empty($rows)) {
|
||||
$labels = [];
|
||||
$data = [];
|
||||
foreach ($rows as $row) {
|
||||
$labels[] = ucwords(strtolower(str_replace(['API', '_', '-'], [' API', ' ', ' '], trim($row->SOURCE_NAME))));
|
||||
$data[] = (int) $row->TOTAL;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->doughnutChart($labels, $data, 'Movement messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lowStockByWarehouse(): array
|
||||
{
|
||||
$labels = ['WH 1', 'WH 2', 'WH 3', 'WH 4', 'WH 5'];
|
||||
$data = [0, 0, 0, 0, 0];
|
||||
|
||||
try {
|
||||
$rows = DB::connection('odbc')->select(
|
||||
'SELECT TRIM(AT9WHS) AS WAREHOUSE, COUNT(*) AS TOTAL
|
||||
FROM EWDAT.ICM709M
|
||||
WHERE AT9QTY <= AT9WRN
|
||||
GROUP BY TRIM(AT9WHS)
|
||||
ORDER BY TOTAL DESC
|
||||
FETCH FIRST 6 ROWS ONLY',
|
||||
);
|
||||
|
||||
if (! empty($rows)) {
|
||||
$labels = [];
|
||||
$data = [];
|
||||
foreach ($rows as $row) {
|
||||
$labels[] = 'WH ' . trim($row->WAREHOUSE);
|
||||
$data[] = (int) $row->TOTAL;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->barChart($labels, $data, 'Low-stock lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function warehouseUtilisation(): array
|
||||
{
|
||||
$labels = ['Available', 'Allocated', 'Packed'];
|
||||
$data = [0, 0, 0];
|
||||
|
||||
try {
|
||||
$rows = DB::connection('odbc')->select(
|
||||
'SELECT
|
||||
SUM(A5RSVQTY) AS RESERVED_QTY,
|
||||
SUM(A5PACKED) AS PACKED_QTY,
|
||||
SUM(A5RSVQTY - A5PACKED) AS REMAINING_QTY
|
||||
FROM EWDAT.ICM688M2
|
||||
WHERE A5RSVQTY > 0',
|
||||
);
|
||||
|
||||
if (! empty($rows)) {
|
||||
$data = [
|
||||
max(0, (int) $rows[0]->REMAINING_QTY),
|
||||
max(0, (int) $rows[0]->RESERVED_QTY),
|
||||
max(0, (int) $rows[0]->PACKED_QTY),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->doughnutChart($labels, $data, 'Quantity status');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function systemHealth(): array
|
||||
{
|
||||
$labels = ['Success', 'Errors', 'Pending'];
|
||||
$data = [0, 0, 0];
|
||||
|
||||
try {
|
||||
$success = $this->safeCount('ICM699M', "SHTYPE = 'M3-Res-out' AND SHDATA LIKE 'Success%'");
|
||||
$errors = $this->safeCount('ICM699M', "SHERROR = 'X'");
|
||||
$pending = $this->safeCount('ICM705M1', "APSPARE2 IN ('', 'P')");
|
||||
$data = [$success, $errors, $pending];
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
}
|
||||
|
||||
return $this->doughnutChart($labels, $data, 'Operational status');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function lastDaysLabels(int $days): array
|
||||
{
|
||||
$labels = [];
|
||||
for ($offset = $days - 1; $offset >= 0; $offset--) {
|
||||
$labels[] = Carbon::today()->subDays($offset)->format('d M');
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function emptySeries(array $labels): array
|
||||
{
|
||||
return array_fill_keys($labels, 0);
|
||||
}
|
||||
|
||||
private function safeCount(string $table, ?string $where = null): int
|
||||
{
|
||||
try {
|
||||
$sql = 'SELECT COUNT(*) AS TOTAL FROM EWDAT.' . $table;
|
||||
if ($where !== null && trim($where) !== '') {
|
||||
$sql .= ' WHERE ' . $where;
|
||||
}
|
||||
|
||||
$rows = DB::connection('odbc')->select($sql);
|
||||
|
||||
return isset($rows[0]->TOTAL) ? (int) $rows[0]->TOTAL : 0;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logChartException($exception);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
* @param array<int, int> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lineChart(array $labels, array $data, string $label): array
|
||||
{
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'datasets' => [[
|
||||
'label' => $label,
|
||||
'data' => $data,
|
||||
'tension' => 0.35,
|
||||
'fill' => true,
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
* @param array<int, int> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function barChart(array $labels, array $data, string $label): array
|
||||
{
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'datasets' => [[
|
||||
'label' => $label,
|
||||
'data' => $data,
|
||||
'borderRadius' => 8,
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
* @param array<int, int> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function doughnutChart(array $labels, array $data, string $label): array
|
||||
{
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'datasets' => [[
|
||||
'label' => $label,
|
||||
'data' => $data,
|
||||
'borderWidth' => 2,
|
||||
'hoverOffset' => 8,
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
private function logChartException(\Throwable $exception): void
|
||||
{
|
||||
Log::debug('Dashboard chart data unavailable.', [
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class AppVersion
|
||||
{
|
||||
public static function gitHash(): string
|
||||
{
|
||||
return trim(Process::run('git rev-parse --short HEAD')->output()) ?: 'unknown';
|
||||
}
|
||||
|
||||
public static function gitBranch(): string
|
||||
{
|
||||
return trim(Process::run('git rev-parse --abbrev-ref HEAD')->output()) ?: 'unknown';
|
||||
}
|
||||
|
||||
public static function buildDate(): string
|
||||
{
|
||||
return now()->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user