Files
solidtime/app/Filament/Resources/OrganizationResource.php
Constantin Graf b9e3f55537 Store admin panel import uploads on private disk and delete them after import
The Filament import upload was stored on the public disk and never deleted.
2026-10-14 10:45:15 +02:00

274 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Filament\Resources\OrganizationResource\Pages\CreateOrganization;
use App\Filament\Resources\OrganizationResource\Pages\EditOrganization;
use App\Filament\Resources\OrganizationResource\Pages\ListOrganizations;
use App\Filament\Resources\OrganizationResource\Pages\ViewOrganization;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
use App\Service\DeletionService;
use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService;
use App\Service\TimezoneService;
use Brick\Money\IsoCurrencyProvider;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToReadFile;
class OrganizationResource extends Resource
{
protected static ?string $model = Organization::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?int $navigationSort = 7;
public static function form(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Toggle::make('personal_team')
->label('Is personal?')
->hiddenOn(['create'])
->required(),
Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(),
Select::make('date_format')
->options(DateFormat::toSelectArray())
->required(),
Select::make('currency_format')
->options(CurrencyFormat::toSelectArray())
->required(),
Select::make('interval_format')
->options(IntervalFormat::toSelectArray())
->required(),
Select::make('number_format')
->options(NumberFormat::toSelectArray())
->required(),
Select::make('time_format')
->options(TimeFormat::toSelectArray())
->required(),
Select::make('currency')
->label('Currency')
->options(function (): array {
$currencies = IsoCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
}
return $select;
})
->required()
->searchable(),
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
'max:2147483647',
])
->numeric(),
DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable(),
IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
TextColumn::make('owner.email')
->sortable(),
TextColumn::make('currency'),
TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
//
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
Action::make('Export')
->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) {
try {
$file = app(ExportService::class)->export($record);
Notification::make()
->title('Export successful')
->success()
->persistent()
->send();
return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip');
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Export failed')
->danger()
->body('Message: '.$exception->getMessage())
->persistent()
->send();
}
}),
Action::make('Import')
->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data): void {
try {
try {
$file = Storage::disk(config('filesystems.private'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
if ($file === null) {
throw new ImportException('File not found');
}
/** @var string $timezone */
$timezone = $data['timezone'];
/** @var ReportDto $report */
$report = app(ImportService::class)->import(
$record,
$data['type'],
$file,
$timezone
);
Notification::make()
->title('Import successful')
->success()
->body(
'Imported time entries: '.$report->timeEntriesCreated.'<br>'.
'Imported clients: '.$report->clientsCreated.'<br>'.
'Imported projects: '.$report->projectsCreated.'<br>'.
'Imported tasks: '.$report->tasksCreated.'<br>'.
'Imported tags: '.$report->tagsCreated.'<br>'.
'Imported users: '.$report->usersCreated
)
->persistent()
->send();
} catch (ImportException $exception) {
report($exception);
Notification::make()
->title('Import failed, changes rolled back')
->danger()
->body('Message: '.$exception->getMessage())
->persistent()
->send();
} finally {
Storage::disk(config('filesystems.private'))->delete($data['file']);
}
})
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->schema([
FileUpload::make('file')
->label('File')
->disk(config('filesystems.private'))
->directory('admin-import-uploads')
->visibility('private')
->required(),
Select::make('type')
->required()
->options(function (): array {
$select = [];
foreach (app(ImporterProvider::class)->getImporterKeys() as $key) {
$select[$key] = $key;
}
return $select;
}),
Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable()
->required(),
]),
])
->toolbarActions([
]);
}
public static function getRelations(): array
{
return [
UsersRelationManager::class,
InvitationsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListOrganizations::route('/'),
'create' => CreateOrganization::route('/create'),
'edit' => EditOrganization::route('/{record}/edit'),
'view' => ViewOrganization::route('/{record}'),
];
}
}