Files
hr/app/Filament/Pages/ImportWizard.php
2026-07-31 18:08:08 +05:00

335 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Enums\ImportType;
use App\Enums\NavigationGroup;
use App\Jobs\ProcessImportJob;
use App\Services\Import\ImportService;
use BackedEnum;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use UnitEnum;
class ImportWizard extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowUpTray;
protected static string|UnitEnum|null $navigationGroup = NavigationGroup::System;
protected static ?int $navigationSort = 2;
protected static ?string $slug = 'import-wizard';
/**
* @var array<string, mixed>|null
*/
public ?array $data = [];
/** @var array<int, string> */
public array $headers = [];
public ?string $storedFilePath = null;
/** @var array<string, mixed>|null */
public ?array $importResult = null;
public static function getNavigationLabel(): string
{
return __('hr.resources.import_wizard');
}
public function getTitle(): string|Htmlable
{
return __('hr.import.title');
}
public function mount(): void
{
$this->form->fill([
'import_type' => ImportType::Employees->value,
'column_mapping' => [],
]);
$this->refreshImportResult();
}
public function defaultForm(Schema $schema): Schema
{
return $schema
->statePath('data')
->columns(1);
}
public function form(Schema $schema): Schema
{
return $schema->components([
Wizard::make([
Step::make(__('hr.import.upload'))
->description(__('hr.import.upload_description'))
->schema([
Select::make('import_type')
->label(__('hr.import.import_type'))
->options(ImportType::class)
->required()
->live()
->afterStateUpdated(fn () => $this->resetColumnMapping()),
FileUpload::make('file')
->label(__('hr.import.excel_file'))
->disk(config('hr.file_uploads.disk'))
->directory(config('hr.file_uploads.directory') . '/imports')
->acceptedFileTypes([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
])
->required()
->afterStateUpdated(fn () => $this->loadHeaders()),
]),
Step::make(__('hr.import.map_columns'))
->description(__('hr.import.map_columns_description'))
->schema([
Section::make(__('hr.import.column_mapping'))
->schema(fn (): array => $this->getColumnMappingFields()),
]),
Step::make(__('hr.import.preview_step'))
->description(__('hr.import.preview_description'))
->schema([
Placeholder::make('preview_table')
->label(__('hr.import.preview'))
->content(fn (): HtmlString => new HtmlString($this->getPreviewHtml())),
]),
Step::make(__('hr.import.import_step'))
->description(__('hr.import.import_description'))
->schema([
Placeholder::make('import_results')
->label(__('hr.import.import_results'))
->content(fn (): HtmlString => new HtmlString($this->getResultsHtml())),
]),
])
->alpineSubmitHandler('$wire.runImport()')
->submitAction(new HtmlString('<button type="submit" class="fi-btn fi-btn-primary">' . e(__('hr.import.start_import')) . '</button>'))
->skippable(false),
]);
}
public function content(Schema $schema): Schema
{
return $schema->components([
Form::make([EmbeddedSchema::make('form')])
->id('import-wizard-form')
->livewireSubmitHandler('runImport')
->footer([
Actions::make([])
->alignment(Alignment::Start)
->key('import-wizard-actions'),
]),
]);
}
public function runImport(): void
{
$this->form->validate();
$filePath = $this->resolveStoredFilePath();
if ($filePath === null) {
Notification::make()
->title(__('hr.import.upload_required_title'))
->body(__('hr.import.upload_required_body'))
->danger()
->send();
return;
}
$importType = ImportType::from($this->data['import_type']);
$columnMapping = $this->data['column_mapping'] ?? [];
ProcessImportJob::dispatch(
$importType,
$filePath,
$columnMapping,
Auth::id(),
);
Notification::make()
->title(__('hr.import.import_queued_title'))
->body(__('hr.import.import_queued_body'))
->success()
->send();
}
public function refreshImportResult(): void
{
$result = ProcessImportJob::getCachedResult(Auth::id());
$this->importResult = $result?->toArray();
}
public function loadHeaders(): void
{
$path = $this->resolveStoredFilePath();
if ($path === null) {
$this->headers = [];
return;
}
$this->headers = app(ImportService::class)->readHeaders($path);
$this->resetColumnMapping();
}
protected function resetColumnMapping(): void
{
$this->data['column_mapping'] = [];
}
/**
* @return array<int, Select|Placeholder>
*/
protected function getColumnMappingFields(): array
{
if ($this->headers === []) {
return [
Placeholder::make('mapping_hint')
->content(__('hr.import.mapping_hint')),
];
}
$importType = ImportType::from($this->data['import_type'] ?? ImportType::Employees->value);
$headerOptions = array_combine($this->headers, $this->headers);
return collect($importType->fields())
->map(function (string $label, string $field) use ($headerOptions): Select {
return Select::make("column_mapping.{$field}")
->label($label)
->options($headerOptions)
->searchable()
->nullable();
})
->values()
->all();
}
protected function getPreviewHtml(): string
{
$path = $this->resolveStoredFilePath();
if ($path === null) {
return '<p class="text-sm text-gray-500">' . e(__('hr.import.upload_to_preview')) . '</p>';
}
$importType = ImportType::from($this->data['import_type'] ?? ImportType::Employees->value);
$importService = app(ImportService::class);
$rows = $importService->readRows($path, 10);
$mappedRows = $importService->mapRows($rows, $this->data['column_mapping'] ?? []);
$validation = $importService->validatePreview($importType, $mappedRows);
if ($mappedRows === []) {
return '<p class="text-sm text-gray-500">' . e(__('hr.import.no_rows')) . '</p>';
}
$fields = array_keys($importType->fields());
$html = '<div class="overflow-x-auto"><table class="fi-ta-table w-full text-sm"><thead><tr>';
$html .= '<th class="px-3 py-2 text-left">' . e(__('hr.import.row')) . '</th>';
foreach ($fields as $field) {
$html .= '<th class="px-3 py-2 text-left">' . e($importType->fields()[$field]) . '</th>';
}
$html .= '<th class="px-3 py-2 text-left">' . e(__('hr.import.validation')) . '</th></tr></thead><tbody>';
foreach ($mappedRows as $index => $row) {
$errors = $validation[$index]['errors'] ?? [];
$html .= '<tr class="border-t border-gray-200 dark:border-gray-700">';
$html .= '<td class="px-3 py-2">' . ($index + 2) . '</td>';
foreach ($fields as $field) {
$html .= '<td class="px-3 py-2">' . e((string) ($row[$field] ?? '—')) . '</td>';
}
$html .= '<td class="px-3 py-2">';
if ($errors === []) {
$html .= '<span class="text-success-600">' . e(__('hr.import.valid')) . '</span>';
} else {
$html .= '<span class="text-danger-600">' . e(implode(' ', $errors)) . '</span>';
}
$html .= '</td></tr>';
}
$html .= '</tbody></table></div>';
return $html;
}
protected function getResultsHtml(): string
{
$this->refreshImportResult();
if ($this->importResult === null) {
return '<p class="text-sm text-gray-500">' . e(__('hr.import.submit_to_see_results')) . '</p>';
}
$html = '<div class="space-y-2 text-sm">';
$html .= '<p><strong>' . e(__('hr.import.total_rows')) . ':</strong> ' . $this->importResult['total'] . '</p>';
$html .= '<p><strong>' . e(__('hr.import.imported')) . ':</strong> ' . $this->importResult['imported'] . '</p>';
$html .= '<p><strong>' . e(__('hr.import.skipped_duplicates')) . ':</strong> ' . $this->importResult['skipped'] . '</p>';
$html .= '<p><strong>' . e(__('hr.import.failed')) . ':</strong> ' . $this->importResult['failed'] . '</p>';
if (($this->importResult['errors'] ?? []) !== []) {
$html .= '<div class="mt-3"><strong>' . e(__('hr.import.errors')) . ':</strong><ul class="list-disc ps-5">';
foreach ($this->importResult['errors'] as $error) {
$html .= '<li>' . e($error) . '</li>';
}
$html .= '</ul></div>';
}
$html .= '</div>';
return $html;
}
protected function resolveStoredFilePath(): ?string
{
$file = $this->data['file'] ?? null;
if ($file === null || $file === []) {
return null;
}
$relativePath = is_array($file) ? ($file[0] ?? null) : $file;
if ($relativePath === null) {
return null;
}
$disk = Storage::disk(config('hr.file_uploads.disk'));
$this->storedFilePath = $disk->path($relativePath);
return $this->storedFilePath;
}
}