Refactor employee management by implementing automatic employee number generation, updating national ID handling, and enhancing localization for gender and nationality fields across various resources. Adjust form components and validation logic to improve user experience and data integrity.
This commit is contained in:
@@ -8,14 +8,14 @@ enum Gender: string
|
||||
{
|
||||
case Male = 'male';
|
||||
case Female = 'female';
|
||||
case Other = 'other';
|
||||
// case Other = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Male => __('enums.gender.male'),
|
||||
self::Female => __('enums.gender.female'),
|
||||
self::Other => __('enums.gender.other'),
|
||||
// self::Other => __('enums.gender.other'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum Gender: string
|
||||
return match ($this) {
|
||||
self::Male => 'info',
|
||||
self::Female => 'pink',
|
||||
self::Other => 'gray',
|
||||
// self::Other => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ enum Gender: string
|
||||
return match ($this) {
|
||||
self::Male => 'heroicon-o-user',
|
||||
self::Female => 'heroicon-o-user',
|
||||
self::Other => 'heroicon-o-users',
|
||||
// self::Other => 'heroicon-o-users',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Employee;
|
||||
use App\Support\Countries;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
@@ -39,7 +40,7 @@ class EmployeesExport implements FromQuery, WithHeadings, WithMapping
|
||||
return [
|
||||
'Employee Number',
|
||||
'Full Name',
|
||||
'National ID',
|
||||
'Nationality',
|
||||
'Gender',
|
||||
'Birth Date',
|
||||
'Phone',
|
||||
@@ -63,7 +64,7 @@ class EmployeesExport implements FromQuery, WithHeadings, WithMapping
|
||||
return [
|
||||
$employee->employee_number,
|
||||
$employee->full_name,
|
||||
$employee->national_id,
|
||||
Countries::name($employee->national_id) ?? $employee->national_id,
|
||||
$employee->gender?->value,
|
||||
$employee->birth_date?->toDateString(),
|
||||
$employee->phone,
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace App\Filament\Resources\Employees\Schemas;
|
||||
use App\Enums\EmploymentStatus;
|
||||
use App\Enums\Gender;
|
||||
use App\Filament\Support\HrForm;
|
||||
use App\Support\Countries;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -30,24 +31,32 @@ class EmployeeForm
|
||||
->imageEditor(),
|
||||
TextInput::make('employee_number')
|
||||
->label(__('hr.fields.employee_number'))
|
||||
->required()
|
||||
->maxLength(50)
|
||||
->unique(ignoreRecord: true),
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->visibleOn('edit'),
|
||||
TextInput::make('full_name')
|
||||
->label(__('hr.fields.full_name'))
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('national_id')
|
||||
Select::make('national_id')
|
||||
->label(__('hr.fields.national_id'))
|
||||
->maxLength(50),
|
||||
->options(fn (): array => Countries::options())
|
||||
->searchable()
|
||||
->native(false)
|
||||
->default(Countries::default())
|
||||
->required(),
|
||||
|
||||
Select::make('gender')
|
||||
->label(__('hr.fields.gender'))
|
||||
->options(Gender::class)
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
DatePicker::make('birth_date')
|
||||
->label(__('hr.fields.birth_date'))
|
||||
->required(),
|
||||
->required()
|
||||
->native(false),
|
||||
|
||||
TextInput::make('phone')
|
||||
->label(__('hr.fields.phone'))
|
||||
->tel()
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Enums\Gender;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Vacation;
|
||||
use App\Services\Employee\EmployeeStatisticsService;
|
||||
use App\Support\Countries;
|
||||
use Filament\Infolists\Components\ImageEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@@ -42,6 +43,7 @@ class EmployeeInfolist
|
||||
->label(__('hr.fields.full_name')),
|
||||
TextEntry::make('national_id')
|
||||
->label(__('hr.fields.national_id'))
|
||||
->formatStateUsing(fn (?string $state): string => Countries::name($state) ?? $state ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('gender')
|
||||
->label(__('hr.fields.gender'))
|
||||
@@ -153,16 +155,16 @@ class EmployeeInfolist
|
||||
TextEntry::make('leave_balance_taken')
|
||||
->label(__('hr.fields.days_taken'))
|
||||
->state(fn (Employee $record): int => app(EmployeeStatisticsService::class)->summary($record)->vacationTaken)
|
||||
->suffix(' ' . __('hr.messages.days_suffix')),
|
||||
->suffix(' '.__('hr.messages.days_suffix')),
|
||||
TextEntry::make('leave_balance_remaining')
|
||||
->label(__('hr.fields.days_remaining'))
|
||||
->state(fn (Employee $record): int => app(EmployeeStatisticsService::class)->summary($record)->vacationRemaining)
|
||||
->suffix(' ' . __('hr.messages.days_suffix'))
|
||||
->suffix(' '.__('hr.messages.days_suffix'))
|
||||
->color('success'),
|
||||
TextEntry::make('leave_balance_total')
|
||||
->label(__('hr.fields.annual_allowance'))
|
||||
->state(fn (): int => (int) config('hr.annual_leave_days'))
|
||||
->suffix(' ' . __('hr.messages.days_suffix')),
|
||||
->suffix(' '.__('hr.messages.days_suffix')),
|
||||
])
|
||||
->columns(3),
|
||||
Section::make(__('hr.sections.upcoming_approved_leave'))
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Filament\Resources\Employees\Tables;
|
||||
|
||||
use App\Enums\EmploymentStatus;
|
||||
use App\Enums\Gender;
|
||||
use App\Exports\EmployeesExport;
|
||||
use App\Filament\Support\ExportExcelAction;
|
||||
use App\Models\Department;
|
||||
use App\Models\Employee;
|
||||
use App\Filament\Support\ExportExcelAction;
|
||||
use App\Support\Countries;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -37,6 +39,7 @@ class EmployeesTable
|
||||
->sortable(),
|
||||
TextColumn::make('national_id')
|
||||
->label(__('hr.fields.national_id'))
|
||||
->formatStateUsing(fn (?string $state): ?string => Countries::name($state) ?? $state)
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('gender')
|
||||
@@ -126,7 +129,7 @@ class EmployeesTable
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
ExportExcelAction::bulk('exportSelected', \App\Exports\EmployeesExport::class, 'employees.xlsx'),
|
||||
ExportExcelAction::bulk('exportSelected', EmployeesExport::class, 'employees.xlsx'),
|
||||
BulkAction::make('changeDepartment')
|
||||
->label(__('hr.actions.change_department'))
|
||||
->icon('heroicon-o-building-office-2')
|
||||
|
||||
30
app/Http/Middleware/SetLocale.php
Normal file
30
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$locale = $request->user()->locale;
|
||||
|
||||
if (isset($locale)) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,38 @@ class Employee extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (Employee $employee): void {
|
||||
if (blank($employee->employee_number)) {
|
||||
$employee->employee_number = static::generateUniqueEmployeeNumber();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateUniqueEmployeeNumber(): string
|
||||
{
|
||||
$prefix = (string) config('hr.employee_number.prefix', 'EMP-');
|
||||
$padding = (int) config('hr.employee_number.padding', 5);
|
||||
|
||||
$latestNumber = static::withTrashed()
|
||||
->where('employee_number', 'like', $prefix.'%')
|
||||
->pluck('employee_number')
|
||||
->map(function (string $number) use ($prefix): ?int {
|
||||
if (! preg_match('/^'.preg_quote($prefix, '/').'(\d+)$/', $number, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
})
|
||||
->filter()
|
||||
->max();
|
||||
|
||||
$next = ($latestNumber ?? 0) + 1;
|
||||
|
||||
return $prefix.str_pad((string) $next, $padding, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
|
||||
@@ -4,13 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
|
||||
use App\Enums\NavigationGroup;
|
||||
use App\Enums\NavigationGroup as NavigationGroupEnum;
|
||||
use App\Filament\Pages\Dashboard;
|
||||
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
|
||||
use Filament\FontProviders\GoogleFontProvider;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationGroup as FilamentNavigationGroup;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@@ -20,7 +22,6 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Filament\FontProviders\GoogleFontProvider;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
@@ -39,7 +40,14 @@ class AdminPanelProvider extends PanelProvider
|
||||
'gray' => Color::Slate,
|
||||
])
|
||||
->font('Inter', provider: GoogleFontProvider::class)
|
||||
->navigationGroups(NavigationGroup::class)
|
||||
->navigationGroups(
|
||||
collect(NavigationGroupEnum::cases())
|
||||
->mapWithKeys(fn (NavigationGroupEnum $case): array => [
|
||||
$case->name => FilamentNavigationGroup::make()
|
||||
->label(fn (): ?string => $case->getLabel()),
|
||||
])
|
||||
->all(),
|
||||
)
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
@@ -49,7 +57,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
->widgets([])
|
||||
->plugins([
|
||||
FilamentShieldPlugin::make()
|
||||
->navigationGroup(NavigationGroup::System),
|
||||
->navigationGroup(NavigationGroupEnum::System),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Department;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Position;
|
||||
use App\Models\Shift;
|
||||
use App\Support\Countries;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -22,9 +23,9 @@ class EmployeeImportTransformer
|
||||
public function toAttributes(EmployeeRowDto $dto): array
|
||||
{
|
||||
return [
|
||||
'employee_number' => $dto->employeeNumber,
|
||||
'employee_number' => $dto->employeeNumber !== '' ? $dto->employeeNumber : null,
|
||||
'full_name' => $dto->fullName,
|
||||
'national_id' => $dto->nationalId,
|
||||
'national_id' => $this->resolveNationality($dto->nationalId),
|
||||
'gender' => $this->resolveGender($dto->gender),
|
||||
'birth_date' => $this->parseDate($dto->birthDate),
|
||||
'phone' => $dto->phone ?? config('hr.default_phone'),
|
||||
@@ -45,10 +46,6 @@ class EmployeeImportTransformer
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if ($dto->employeeNumber === '') {
|
||||
$errors[] = 'Employee number is required.';
|
||||
}
|
||||
|
||||
if ($dto->fullName === '') {
|
||||
$errors[] = 'Full name is required.';
|
||||
}
|
||||
@@ -65,22 +62,42 @@ class EmployeeImportTransformer
|
||||
$errors[] = "Shift \"{$dto->shift}\" was not found.";
|
||||
}
|
||||
|
||||
if ($dto->nationalId && $this->resolveNationality($dto->nationalId) === null) {
|
||||
$errors[] = "Nationality \"{$dto->nationalId}\" was not found.";
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public function isDuplicate(EmployeeRowDto $dto): bool
|
||||
{
|
||||
return Employee::query()
|
||||
->where(function ($query) use ($dto): void {
|
||||
$query->where('employee_number', $dto->employeeNumber);
|
||||
$hasEmployeeNumber = $dto->employeeNumber !== '';
|
||||
$nationality = $this->resolveNationality($dto->nationalId);
|
||||
|
||||
if ($dto->nationalId) {
|
||||
$query->orWhere('national_id', $dto->nationalId);
|
||||
if (! $hasEmployeeNumber && $nationality === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Employee::query()
|
||||
->where(function ($query) use ($dto, $hasEmployeeNumber, $nationality): void {
|
||||
if ($hasEmployeeNumber) {
|
||||
$query->where('employee_number', $dto->employeeNumber);
|
||||
}
|
||||
|
||||
if ($nationality !== null) {
|
||||
$hasEmployeeNumber
|
||||
? $query->orWhere('national_id', $nationality)
|
||||
: $query->where('national_id', $nationality);
|
||||
}
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function resolveNationality(?string $value): ?string
|
||||
{
|
||||
return Countries::resolve($value);
|
||||
}
|
||||
|
||||
private function resolveGender(?string $value): ?Gender
|
||||
{
|
||||
if ($value === null) {
|
||||
|
||||
124
app/Support/Countries.php
Normal file
124
app/Support/Countries.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class Countries
|
||||
{
|
||||
public const DEFAULT = 'TM';
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function codes(): array
|
||||
{
|
||||
/** @var array{codes: array<int, string>} $config */
|
||||
$config = config('countries');
|
||||
|
||||
return $config['codes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(?string $locale = null): array
|
||||
{
|
||||
$locale ??= app()->getLocale();
|
||||
$options = [];
|
||||
|
||||
foreach (self::codes() as $code) {
|
||||
$name = self::name($code, $locale);
|
||||
|
||||
if ($name !== null) {
|
||||
$options[$code] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
uasort($options, fn (string $a, string $b): int => strcasecmp($a, $b));
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function name(?string $code, ?string $locale = null): ?string
|
||||
{
|
||||
if ($code === null || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = strtoupper($code);
|
||||
$locale ??= app()->getLocale();
|
||||
|
||||
$name = self::translation($code, $locale);
|
||||
|
||||
if ($name === null && $locale !== 'en') {
|
||||
$name = self::translation($code, 'en');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
public static function isValid(?string $code): bool
|
||||
{
|
||||
if ($code === null || $code === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtoupper($code), self::codes(), true);
|
||||
}
|
||||
|
||||
public static function resolve(?string $value, ?string $locale = null): ?string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
$upper = strtoupper($value);
|
||||
|
||||
if (self::isValid($upper)) {
|
||||
return $upper;
|
||||
}
|
||||
|
||||
$locale ??= app()->getLocale();
|
||||
|
||||
foreach (self::codes() as $code) {
|
||||
$name = self::name($code, $locale);
|
||||
|
||||
if ($name !== null && strcasecmp($name, $value) === 0) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($locale !== 'en') {
|
||||
foreach (self::codes() as $code) {
|
||||
$name = self::name($code, 'en');
|
||||
|
||||
if ($name !== null && strcasecmp($name, $value) === 0) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function translation(string $code, string $locale): ?string
|
||||
{
|
||||
$path = lang_path("{$locale}/countries.php");
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array<string, string> $names */
|
||||
$names = require $path;
|
||||
|
||||
return $names[$code] ?? null;
|
||||
}
|
||||
|
||||
public static function default(): string
|
||||
{
|
||||
return self::DEFAULT;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user