Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
eb7233dfba anchor cross-day calendar e2e tests to Wednesday and Thursday
The tests used today and tomorrow (or yesterday) and skipped when those fell outside the visible week. The Saturday skips were also wrong for the Monday week start e2e users get, so the tests failed whenever CI ran on a Sunday. Using Wednesday and Thursday of the current week keeps both days visible whichever day the suite runs, so the day-of-week skips are gone.
2026-09-28 17:18:40 +02:00
Gregor Vostrak
398a81798b move project task count into its own sortable column
Also removes the ring around the project color dot, shrinks it slightly and tightens its spacing to the name.
2026-09-28 17:18:40 +02:00
101 changed files with 1969 additions and 2922 deletions

View File

@@ -64,6 +64,3 @@ GOTENBERG_URL=http://localhost:3000
# Octane
OCTANE_SERVER=frankenphp
# Inertia
INERTIA_SSR_ENABLED=false

View File

@@ -88,6 +88,3 @@ FORWARD_DB_PORT=54329
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage
# This app does not ship a Node SSR bundle, so Inertia SSR must stay disabled.
INERTIA_SSR_ENABLED=false

View File

@@ -13,5 +13,3 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=pgsql
INERTIA_SSR_ENABLED=false

View File

@@ -7,7 +7,6 @@ namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Override;
/**
* User provider that only resolves non-placeholder users.
@@ -27,7 +26,7 @@ class ActiveUserProvider extends EloquentUserProvider
* @param Model|null $model
* @return Builder<Model>
*/
#[Override]
#[\Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);

View File

@@ -10,7 +10,6 @@ use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use stdClass;
class SelfHostDatabaseConsistency extends Command
{
@@ -102,7 +101,7 @@ class SelfHostDatabaseConsistency extends Command
}
/**
* @param Collection<int, stdClass> $problems
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{

View File

@@ -7,7 +7,7 @@ namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib4\Crypt\RSA;
use phpseclib3\Crypt\RSA;
class SelfHostGenerateKeysCommand extends Command
{

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Exceptions\Api;
use Override;
class EntityStillInUseApiException extends ApiException
{
private string $modelToDelete;
@@ -34,7 +32,7 @@ class EntityStillInUseApiException extends ApiException
/**
* Get the translated message for the exception.
*/
#[Override]
#[\Override]
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey(), [

View File

@@ -4,17 +4,13 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
use App\Filament\Resources\AuditResource\Pages\ListAudits;
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
@@ -23,32 +19,32 @@ class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static string|\UnitEnum|null $navigationGroup = 'System';
protected static ?string $navigationGroup = 'System';
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
TextInput::make('user_type')
return $form
->schema([
Forms\Components\TextInput::make('user_type')
->maxLength(255),
TextInput::make('user_id'),
TextInput::make('event')
Forms\Components\TextInput::make('user_id'),
Forms\Components\TextInput::make('event')
->required()
->maxLength(255),
TextInput::make('auditable_type')
Forms\Components\TextInput::make('auditable_type')
->required()
->maxLength(255),
TextInput::make('auditable_id')
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Textarea::make('url'),
TextInput::make('ip_address'),
TextInput::make('user_agent')
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')
->maxLength(1023),
TextInput::make('tags')
Forms\Components\TextInput::make('tags')
->maxLength(255),
]);
}
@@ -57,27 +53,27 @@ class AuditResource extends Resource
{
return $table
->columns([
TextColumn::make('user.name'),
TextColumn::make('event'),
TextColumn::make('auditable_type'),
TextColumn::make('auditable_id'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('event'),
Tables\Columns\TextColumn::make('auditable_type'),
Tables\Columns\TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->sortable()
->dateTime(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
->actions([
Tables\Actions\ViewAction::make(),
])
->toolbarActions([
->bulkActions([
])
->defaultSort('created_at', 'desc');
}
@@ -91,9 +87,9 @@ class AuditResource extends Resource
public static function getPages(): array
{
return [
'index' => ListAudits::route('/'),
'create' => CreateAudit::route('/create'),
'view' => ViewAudit::route('/{record}'),
'index' => Pages\ListAudits::route('/'),
'create' => Pages\CreateAudit::route('/create'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -4,18 +4,13 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ClientResource\Pages\CreateClient;
use App\Filament\Resources\ClientResource\Pages\EditClient;
use App\Filament\Resources\ClientResource\Pages\ListClients;
use App\Filament\Resources\ClientResource\Pages;
use App\Models\Client;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -23,16 +18,16 @@ class ClientResource extends Resource
{
protected static ?string $model = Client::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-briefcase';
protected static ?string $navigationIcon = 'heroicon-o-briefcase';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 4;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
TextInput::make('name')
->label('Name')
->required(),
@@ -48,17 +43,17 @@ class ClientResource extends Resource
{
return $table
->columns([
TextColumn::make('name')
Tables\Columns\TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
TextColumn::make('organization.name')
Tables\Columns\TextColumn::make('organization.name')
->sortable()
->label('Organization'),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->label('Created at')
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
@@ -73,12 +68,12 @@ class ClientResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -93,9 +88,9 @@ class ClientResource extends Resource
public static function getPages(): array
{
return [
'index' => ListClients::route('/'),
'create' => CreateClient::route('/create'),
'edit' => EditClient::route('/{record}/edit'),
'index' => Pages\ListClients::route('/'),
'create' => Pages\CreateClient::route('/create'),
'edit' => Pages\EditClient::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditClient extends EditRecord
@@ -15,7 +15,7 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords
@@ -15,7 +15,7 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -7,16 +7,16 @@ namespace App\Filament\Resources;
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
@@ -30,19 +30,19 @@ class FailedJobResource extends Resource
{
protected static ?string $model = FailedJob::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-circle';
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
protected static string|\UnitEnum|null $navigationGroup = 'System';
protected static ?string $navigationGroup = 'System';
public static function getNavigationBadge(): ?string
{
return (string) FailedJob::query()->count();
}
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
TextInput::make('uuid')->disabled()->columnSpan(4),
TextInput::make('failed_at')->disabled(),
TextInput::make('id')->disabled(),
@@ -74,7 +74,7 @@ class FailedJobResource extends Resource
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->toolbarActions([
->bulkActions([
BulkAction::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
@@ -91,7 +91,7 @@ class FailedJobResource extends Resource
}),
DeleteBulkAction::make(),
])
->recordActions([
->actions([
DeleteAction::make(),
ViewAction::make(),
Action::make('retry')

View File

@@ -5,21 +5,14 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages\EditOrganizationInvitation;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ListOrganizationInvitations;
use App\Filament\Resources\OrganizationInvitationResource\Pages\ViewOrganizationInvitation;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
@@ -29,18 +22,18 @@ class OrganizationInvitationResource extends Resource
protected static ?string $label = 'Invitations';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
return $form
->columns(1)
->components([
TextInput::make('email')
->schema([
Forms\Components\TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
@@ -52,11 +45,11 @@ class OrganizationInvitationResource extends Resource
->searchable(['name'])
->disabledOn(['edit'])
->required(),
DateTimePicker::make('created_at')
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -67,17 +60,17 @@ class OrganizationInvitationResource extends Resource
{
return $table
->columns([
TextColumn::make('organization.name')
Tables\Columns\TextColumn::make('organization.name')
->searchable()
->sortable(),
TextColumn::make('email')
Tables\Columns\TextColumn::make('email')
->sortable(),
TextColumn::make('role'),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
@@ -87,13 +80,13 @@ class OrganizationInvitationResource extends Resource
->filters([
//
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('resend')
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
@@ -113,9 +106,9 @@ class OrganizationInvitationResource extends Resource
public static function getPages(): array
{
return [
'index' => ListOrganizationInvitations::route('/'),
'edit' => EditOrganizationInvitation::route('/{record}/edit'),
'view' => ViewOrganizationInvitation::route('/{record}'),
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
@@ -15,7 +15,7 @@ class EditOrganizationInvitation extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -9,10 +9,7 @@ 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\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
@@ -24,19 +21,13 @@ 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;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage;
@@ -45,22 +36,22 @@ class OrganizationResource extends Resource
{
protected static ?string $model = Organization::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static ?string $navigationIcon = 'heroicon-o-building-office-2';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 7;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
return $form
->columns(1)
->components([
TextInput::make('name')
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Toggle::make('personal_team')
Forms\Components\Toggle::make('personal_team')
->label('Is personal?')
->hiddenOn(['create'])
->required(),
@@ -98,7 +89,7 @@ class OrganizationResource extends Resource
})
->required()
->searchable(),
TextInput::make('billable_rate')
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -108,11 +99,11 @@ class OrganizationResource extends Resource
'max:2147483647',
])
->numeric(),
DateTimePicker::make('created_at')
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -126,7 +117,7 @@ class OrganizationResource extends Resource
TextColumn::make('name')
->searchable()
->sortable(),
IconColumn::make('personal_team')
Tables\Columns\IconColumn::make('personal_team')
->boolean()
->label('Is personal?')
->sortable(),
@@ -147,9 +138,9 @@ class OrganizationResource extends Resource
->filters([
//
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
@@ -167,7 +158,7 @@ class OrganizationResource extends Resource
return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip');
} catch (Exception $exception) {
} catch (\Exception $exception) {
report($exception);
Notification::make()
->title('Export failed')
@@ -183,7 +174,7 @@ class OrganizationResource extends Resource
try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
if ($file === null) {
throw new Exception('File not found');
throw new \Exception('File not found');
}
/** @var string $timezone */
$timezone = $data['timezone'];
@@ -218,8 +209,8 @@ class OrganizationResource extends Resource
}
})
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->schema([
FileUpload::make('file')
->form([
Forms\Components\FileUpload::make('file')
->label('File')
->required(),
Select::make('type')
@@ -239,7 +230,7 @@ class OrganizationResource extends Resource
->required(),
]),
])
->toolbarActions([
->bulkActions([
]);
}
@@ -254,10 +245,10 @@ class OrganizationResource extends Resource
public static function getPages(): array
{
return [
'index' => ListOrganizations::route('/'),
'create' => CreateOrganization::route('/create'),
'edit' => EditOrganization::route('/{record}/edit'),
'view' => ViewOrganization::route('/{record}'),
'index' => Pages\ListOrganizations::route('/'),
'create' => Pages\CreateOrganization::route('/create'),
'edit' => Pages\EditOrganization::route('/{record}/edit'),
'view' => Pages\ViewOrganization::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use App\Filament\Resources\OrganizationResource\Actions\DeleteOrganization;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteOrganization::make(),
OrganizationResource\Actions\DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListOrganizations extends ListRecords
@@ -15,7 +15,7 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -9,17 +9,12 @@ use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DetachBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
@@ -30,10 +25,10 @@ class InvitationsRelationManager extends RelationManager
protected static ?string $title = 'Invitations';
public function form(Schema $schema): Schema
public function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
@@ -58,33 +53,33 @@ class InvitationsRelationManager extends RelationManager
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
TextColumn::make('email'),
TextColumn::make('role'),
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
])
->headerActions([
CreateAction::make()
Tables\Actions\CreateAction::make()
->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), auth()->user());
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user());
}),
])
->recordActions([
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(),
])),
EditAction::make(),
DeleteAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DetachBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}

View File

@@ -12,15 +12,14 @@ use App\Models\Organization;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\AttachAction;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Validation\Rule;
@@ -29,10 +28,10 @@ class UsersRelationManager extends RelationManager
{
protected static string $relationship = 'users';
public function form(Schema $schema): Schema
public function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
Select::make('role')
->options(Role::class),
TextInput::make('billable_rate')
@@ -76,17 +75,17 @@ class UsersRelationManager extends RelationManager
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
}),
])
->recordActions([
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(),
])),
EditAction::make()
Tables\Actions\EditAction::make()
->using(function (User $record, array $data): User {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
@@ -100,7 +99,7 @@ class UsersRelationManager extends RelationManager
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
@@ -114,7 +113,7 @@ class UsersRelationManager extends RelationManager
return $record;
}),
DetachAction::make()
Tables\Actions\DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
@@ -134,7 +133,7 @@ class UsersRelationManager extends RelationManager
}
}),
])
->toolbarActions([
->bulkActions([
]);
}
}

View File

@@ -4,19 +4,12 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages\CreateProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\EditProjectMember;
use App\Filament\Resources\ProjectMemberResource\Pages\ListProjectMembers;
use App\Filament\Resources\ProjectMemberResource\Pages\ViewProjectMembers;
use App\Filament\Resources\ProjectMemberResource\Pages;
use App\Models\ProjectMember;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Table;
class ProjectMemberResource extends Resource
@@ -25,11 +18,11 @@ class ProjectMemberResource extends Resource
protected static bool $shouldRegisterNavigation = false;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
TextInput::make('billable_rate')
return $form
->schema([
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -39,10 +32,10 @@ class ProjectMemberResource extends Resource
'max:2147483647',
])
->numeric(),
Select::make('user_id')
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->required(),
Select::make('member_id')
Forms\Components\Select::make('member_id')
->relationship('member', 'id')
->required(),
]);
@@ -52,17 +45,17 @@ class ProjectMemberResource extends Resource
{
return $table
->columns([
TextColumn::make('id')
Tables\Columns\TextColumn::make('id')
->label('ID'),
TextColumn::make('billable_rate')
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
TextColumn::make('project.name'),
TextColumn::make('user.name'),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -70,12 +63,12 @@ class ProjectMemberResource extends Resource
->filters([
//
])
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -90,10 +83,10 @@ class ProjectMemberResource extends Resource
public static function getPages(): array
{
return [
'index' => ListProjectMembers::route('/'),
'create' => CreateProjectMember::route('/create'),
'edit' => EditProjectMember::route('/{record}/edit'),
'view' => ViewProjectMembers::route('/{record}'),
'index' => Pages\ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord
@@ -15,7 +15,7 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProjectMembers extends ListRecords
@@ -15,7 +15,7 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,19 +4,14 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages\CreateProject;
use App\Filament\Resources\ProjectResource\Pages\EditProject;
use App\Filament\Resources\ProjectResource\Pages\ListProjects;
use App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -26,24 +21,24 @@ class ProjectResource extends Resource
{
protected static ?string $model = Project::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static ?string $navigationIcon = 'heroicon-o-folder';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 2;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
TextInput::make('name')
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
ColorPicker::make('color')
->label('Color')
->required(),
TextInput::make('billable_rate')
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
@@ -53,7 +48,7 @@ class ProjectResource extends Resource
'max:2147483647',
])
->numeric(),
Select::make('organization_id')
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
@@ -86,12 +81,12 @@ class ProjectResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -106,9 +101,9 @@ class ProjectResource extends Resource
public static function getPages(): array
{
return [
'index' => ListProjects::route('/'),
'create' => CreateProject::route('/create'),
'edit' => EditProject::route('/{record}/edit'),
'index' => Pages\ListProjects::route('/'),
'create' => Pages\CreateProject::route('/create'),
'edit' => Pages\EditProject::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProject extends EditRecord
@@ -15,7 +15,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListProjects extends ListRecords
@@ -15,7 +15,7 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember;
use Filament\Actions\Action;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class ProjectMembersRelationManager extends RelationManager
protected static string $relationship = 'members';
public function form(Schema $schema): Schema
public function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
]);
}
@@ -30,8 +30,8 @@ class ProjectMembersRelationManager extends RelationManager
return $table
->recordTitleAttribute('name')
->columns([
TextColumn::make('user.name'),
TextColumn::make('billable_rate')
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
])
@@ -40,7 +40,7 @@ class ProjectMembersRelationManager extends RelationManager
])
->headerActions([
])
->recordActions([
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
@@ -54,7 +54,7 @@ class ProjectMembersRelationManager extends RelationManager
]))
->openUrlInNewTab(),
])
->toolbarActions([
->bulkActions([
]);
}
}

View File

@@ -4,21 +4,16 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages\EditReport;
use App\Filament\Resources\ReportResource\Pages\ListReports;
use App\Filament\Resources\ReportResource\Pages\ViewReport;
use App\Filament\Resources\ReportResource\Pages;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -29,22 +24,22 @@ class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
return $form
->columns(1)
->components([
TextInput::make('name')
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
TextInput::make('description')
Forms\Components\TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
@@ -54,13 +49,13 @@ class ReportResource extends Resource
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Select::make('organization_id')
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
TextInput::make('share_secret')
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJsonField::make('properties')
@@ -114,18 +109,18 @@ class ReportResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->recordActions([
->actions([
Action::make('public-view')
->label('Public')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true),
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->toolbarActions([
->bulkActions([
]);
}
@@ -138,9 +133,9 @@ class ReportResource extends Resource
public static function getPages(): array
{
return [
'index' => ListReports::route('/'),
'edit' => EditReport::route('/{record}/edit'),
'view' => ViewReport::route('/{record}'),
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
@@ -15,7 +15,7 @@ class EditReport extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -4,18 +4,13 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TagResource\Pages\CreateTag;
use App\Filament\Resources\TagResource\Pages\EditTag;
use App\Filament\Resources\TagResource\Pages\ListTags;
use App\Filament\Resources\TagResource\Pages;
use App\Models\Tag;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -23,20 +18,20 @@ class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 5;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
TextInput::make('name')
->label('Name')
->required(),
Select::make('organization_id')
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->label('Organization')
->searchable(['name'])
@@ -48,17 +43,17 @@ class TagResource extends Resource
{
return $table
->columns([
TextColumn::make('name')
Tables\Columns\TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
TextColumn::make('organization.name')
Tables\Columns\TextColumn::make('organization.name')
->sortable()
->label('Organization'),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->label('Created at')
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->label('Updated at')
->sortable(),
])
@@ -73,12 +68,12 @@ class TagResource extends Resource
->relationship('organization', 'id')
->searchable(),
])
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -93,9 +88,9 @@ class TagResource extends Resource
public static function getPages(): array
{
return [
'index' => ListTags::route('/'),
'create' => CreateTag::route('/create'),
'edit' => EditTag::route('/{record}/edit'),
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTag extends EditRecord
@@ -15,7 +15,7 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TagResource\Pages;
use App\Filament\Resources\TagResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTags extends ListRecords
@@ -15,7 +15,7 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,18 +4,13 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages\CreateTask;
use App\Filament\Resources\TaskResource\Pages\EditTask;
use App\Filament\Resources\TaskResource\Pages\ListTasks;
use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -23,17 +18,17 @@ class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static ?string $navigationIcon = 'heroicon-o-list-bullet';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
TextInput::make('name')
return $form
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
@@ -52,16 +47,16 @@ class TaskResource extends Resource
{
return $table
->columns([
TextColumn::make('name')
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('project.name')
Tables\Columns\TextColumn::make('project.name')
->sortable(),
TextColumn::make('organization.name')
Tables\Columns\TextColumn::make('organization.name')
->sortable(),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->sortable(),
])
->filters([
@@ -75,12 +70,12 @@ class TaskResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -95,9 +90,9 @@ class TaskResource extends Resource
public static function getPages(): array
{
return [
'index' => ListTasks::route('/'),
'create' => CreateTask::route('/create'),
'edit' => EditTask::route('/{record}/edit'),
'index' => Pages\ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord
@@ -15,7 +15,7 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTasks extends ListRecords
@@ -15,7 +15,7 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,20 +4,16 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages\CreateTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\EditTimeEntry;
use App\Filament\Resources\TimeEntryResource\Pages\ListTimeEntries;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
@@ -27,16 +23,16 @@ class TimeEntryResource extends Resource
{
protected static ?string $model = TimeEntry::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static string|\UnitEnum|null $navigationGroup = 'Timetracking';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
TextInput::make('id')
->label('ID')
->readOnly()
@@ -115,12 +111,12 @@ class TimeEntryResource extends Resource
->searchable(),
])
->defaultSort('created_at', 'desc')
->recordActions([
EditAction::make(),
->actions([
Tables\Actions\EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
@@ -135,9 +131,9 @@ class TimeEntryResource extends Resource
public static function getPages(): array
{
return [
'index' => ListTimeEntries::route('/'),
'create' => CreateTimeEntry::route('/create'),
'edit' => EditTimeEntry::route('/{record}/edit'),
'index' => Pages\ListTimeEntries::route('/'),
'create' => Pages\CreateTimeEntry::route('/create'),
'edit' => Pages\EditTimeEntry::route('/{record}/edit'),
];
}
}

View File

@@ -6,7 +6,7 @@ namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions\DeleteAction;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTimeEntry extends EditRecord
@@ -16,7 +16,7 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTimeEntries extends ListRecords
@@ -15,7 +15,7 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -4,18 +4,12 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages\ListTokens;
use App\Filament\Resources\TokenResource\Pages\ViewToken;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Token;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
@@ -24,48 +18,48 @@ class TokenResource extends Resource
{
protected static ?string $model = Token::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static string|\UnitEnum|null $navigationGroup = 'Auth';
protected static ?string $navigationGroup = 'Auth';
protected static ?int $navigationSort = 6;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
return $schema
return $form
->columns(1)
->components([
TextInput::make('id')
->schema([
Forms\Components\TextInput::make('id')
->label('ID')
->disabled()
->visibleOn(['update', 'show'])
->readOnly()
->maxLength(255),
TextInput::make('name')
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Select::make('owner_id')
Forms\Components\Select::make('owner_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Select::make('client_id')
Forms\Components\Select::make('client_id')
->label('Client')
->relationship(name: 'client', titleAttribute: 'name')
->searchable(['name'])
->required(),
Toggle::make('revoked')
Forms\Components\Toggle::make('revoked')
->label('Revoked')
->required(),
DateTimePicker::make('expires_at')
Forms\Components\DateTimePicker::make('expires_at')
->label('Expires At')
->disabled(),
DateTimePicker::make('created_at')
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->disabled(),
DateTimePicker::make('updated_at')
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->disabled(),
]);
@@ -75,32 +69,32 @@ class TokenResource extends Resource
{
return $table
->columns([
TextColumn::make('name')
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('user.name')
Tables\Columns\TextColumn::make('user.name')
->searchable()
->sortable(),
TextColumn::make('client.name')
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
IconColumn::make('personal_access_client')
Tables\Columns\IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean()
->label('API token?'),
IconColumn::make('revoked')
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
->sortable(),
TextColumn::make('expires_at')
Tables\Columns\TextColumn::make('expires_at')
->dateTime()
->sortable(),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -126,10 +120,10 @@ class TokenResource extends Resource
TernaryFilter::make('revoked')
->label('Revoked?'),
])
->recordActions([
ViewAction::make(),
->actions([
Tables\Actions\ViewAction::make(),
])
->toolbarActions([
->bulkActions([
]);
}
@@ -142,8 +136,8 @@ class TokenResource extends Resource
public static function getPages(): array
{
return [
'index' => ListTokens::route('/'),
'view' => ViewToken::route('/{record}'),
'index' => Pages\ListTokens::route('/'),
'view' => Pages\ViewToken::route('/{record}'),
];
}
}

View File

@@ -6,10 +6,7 @@ namespace App\Filament\Resources;
use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages\CreateUser;
use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Filament\Resources\UserResource\Pages\ViewUser;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User;
@@ -18,18 +15,12 @@ use App\Service\TimezoneService;
use App\Service\UserService;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
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;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
@@ -37,26 +28,26 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Actions\Impersonate;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user';
protected static ?string $navigationIcon = 'heroicon-o-user';
protected static string|\UnitEnum|null $navigationGroup = 'Users';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 6;
public static function form(Schema $schema): Schema
public static function form(Form $form): Form
{
/** @var User|null $record */
$record = $schema->getRecord();
$record = $form->getRecord();
return $schema
return $form
->columns(1)
->components([
->schema([
TextInput::make('id')
->label('ID')
->disabled()
@@ -78,23 +69,23 @@ class UserResource extends Resource
'email:rfc,strict',
])
->maxLength(255),
Toggle::make('is_placeholder')
Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder?')
->hiddenOn(['create'])
->disabledOn(['edit']),
DateTimePicker::make('email_verified_at')
Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At')
->hiddenOn(['create'])
->nullable(),
Toggle::make('is_email_verified')
Forms\Components\Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Select::make('timezone')
Forms\Components\Select::make('timezone')
->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
->searchable()
->required(),
Select::make('week_start')
Forms\Components\Select::make('week_start')
->label('Week Start')
->options(Weekday::class)
->required(),
@@ -112,7 +103,7 @@ class UserResource extends Resource
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Select::make('currency')
Forms\Components\Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
@@ -126,11 +117,11 @@ class UserResource extends Resource
->required()
->visibleOn(['create'])
->searchable(),
DateTimePicker::make('created_at')
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
DateTimePicker::make('updated_at')
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
@@ -141,25 +132,25 @@ class UserResource extends Resource
{
return $table
->columns([
TextColumn::make('name')
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('email')
Tables\Columns\TextColumn::make('email')
->icon('heroicon-m-envelope')
->searchable()
->sortable(),
IconColumn::make('is_real_user')
Tables\Columns\IconColumn::make('is_real_user')
->getStateUsing(fn (User $record): bool => ! $record->is_placeholder)
->label('Real user?')
->boolean(),
IconColumn::make('email_verified')
Tables\Columns\IconColumn::make('email_verified')
->getStateUsing(fn (User $record): bool => $record->email_verified_at !== null)
->label('Email verified?')
->boolean(),
TextColumn::make('created_at')
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('updated_at')
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@@ -187,7 +178,7 @@ class UserResource extends Resource
->attribute('email_verified_at')
->nullable(),
])
->recordActions([
->actions([
Impersonate::make()->before(function (User $record): void {
if ($record->currentOrganization === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first();
@@ -200,8 +191,8 @@ class UserResource extends Resource
app(UserService::class)->switchCurrentOrganization($record, $organization);
}
}),
EditAction::make(),
DeleteAction::make()
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
@@ -216,8 +207,8 @@ class UserResource extends Resource
}
}),
])
->toolbarActions([
BulkAction::make('Resend verification email')
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
@@ -239,10 +230,10 @@ class UserResource extends Resource
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
'view' => ViewUser::route('/{record}'),
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
'view' => Pages\ViewUser::route('/{record}'),
];
}
}

View File

@@ -22,7 +22,7 @@ class CreateUser extends CreateRecord
$data['email'],
$data['password_create'],
$data['timezone'],
$data['week_start'] instanceof Weekday ? $data['week_start'] : Weekday::from($data['week_start']),
Weekday::from($data['week_start']),
$data['currency'],
verifyEmail: (bool) $data['is_email_verified']
);

View File

@@ -5,9 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Filament\Resources\UserResource\Actions\DeleteUser;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Actions\Impersonate;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class EditUser extends EditRecord
{
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
DeleteUser::make(),
UserResource\Actions\DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\CreateAction;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
@@ -15,7 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
CreateAction::make()
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}

View File

@@ -7,7 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Actions\Impersonate;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class ViewUser extends ViewRecord
{

View File

@@ -11,13 +11,12 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Actions\Action;
use Filament\Actions\DetachAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -25,10 +24,10 @@ class OrganizationsRelationManager extends RelationManager
{
protected static string $relationship = 'organizations';
public function form(Schema $schema): Schema
public function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
Select::make('role')
->options(Role::class),
]);
@@ -47,21 +46,21 @@ class OrganizationsRelationManager extends RelationManager
])
->headerActions([
])
->recordActions([
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(),
])),
EditAction::make()
Tables\Actions\EditAction::make()
->using(function (Organization $record, array $data): Organization {
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, ($data['role'] instanceof Role ? $data['role'] : Role::from($data['role'])), true);
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
@@ -75,7 +74,7 @@ class OrganizationsRelationManager extends RelationManager
return $record;
}),
DetachAction::make()
Tables\Actions\DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
@@ -95,7 +94,7 @@ class OrganizationsRelationManager extends RelationManager
}
}),
])
->toolbarActions([
->bulkActions([
]);
}
}

View File

@@ -6,10 +6,10 @@ namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Actions\Action;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
class OwnedOrganizationsRelationManager extends RelationManager
@@ -18,10 +18,10 @@ class OwnedOrganizationsRelationManager extends RelationManager
protected static string $relationship = 'ownedOrganizations';
public function form(Schema $schema): Schema
public function form(Form $form): Form
{
return $schema
->components([
return $form
->schema([
]);
}
@@ -30,14 +30,14 @@ class OwnedOrganizationsRelationManager extends RelationManager
return $table
->recordTitleAttribute('name')
->columns([
TextColumn::make('name'),
Tables\Columns\TextColumn::make('name'),
])
->filters([
//
])
->headerActions([
])
->recordActions([
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
@@ -51,7 +51,7 @@ class OwnedOrganizationsRelationManager extends RelationManager
]))
->openUrlInNewTab(),
])
->toolbarActions([
->bulkActions([
]);
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Cache;
class ServerOverview extends Widget
{
protected string $view = 'filament.widgets.server-overview';
protected static string $view = 'filament.widgets.server-overview';
/**
* @return array<string, mixed>

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class TimeEntriesCreated extends ChartWidget
{
protected ?string $heading = 'Time Entries Created';
protected static ?string $heading = 'Time Entries Created';
public ?string $filter = 'week';
@@ -53,7 +53,7 @@ class TimeEntriesCreated extends ChartWidget
return [
'datasets' => [
[
'label' => $this->heading,
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class TimeEntriesImported extends ChartWidget
{
protected ?string $heading = 'Time Entries Imported';
protected static ?string $heading = 'Time Entries Imported';
public ?string $filter = 'week';
@@ -53,7 +53,7 @@ class TimeEntriesImported extends ChartWidget
return [
'datasets' => [
[
'label' => $this->heading,
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -11,7 +11,7 @@ use Flowframe\Trend\TrendValue;
class UserRegistrations extends ChartWidget
{
protected ?string $heading = 'User Registrations';
protected static ?string $heading = 'User Registrations';
public ?string $filter = 'week';
@@ -54,7 +54,7 @@ class UserRegistrations extends ChartWidget
return [
'datasets' => [
[
'label' => $this->heading,
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],

View File

@@ -14,7 +14,6 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use RuntimeException;
class ApiTokenController extends Controller
{
@@ -63,7 +62,7 @@ class ApiTokenController extends Controller
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (RuntimeException $exception) {
} catch (\RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;

View File

@@ -33,7 +33,6 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class MemberController extends Controller
{
@@ -147,7 +146,7 @@ class MemberController extends Controller
*
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable
* @throws \Throwable
*
* @operationId mergeMember
*/

View File

@@ -55,7 +55,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use LogicException;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
@@ -266,7 +265,7 @@ class TimeEntryController extends Controller
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));
if ($viewFile === false) {
throw new LogicException('View file not found');
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
@@ -294,7 +293,7 @@ class TimeEntryController extends Controller
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new LogicException('View file not found');
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
@@ -496,7 +495,7 @@ class TimeEntryController extends Controller
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf.blade.php'));
if ($viewFile === false) {
throw new LogicException('View file not found');
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, [
'aggregatedData' => $aggregatedData,
@@ -513,7 +512,7 @@ class TimeEntryController extends Controller
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new LogicException('View file not found');
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {

View File

@@ -16,7 +16,6 @@ use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use stdClass;
class UserProfileController extends Controller
{
@@ -104,7 +103,7 @@ class UserProfileController extends Controller
/**
* Get the current sessions.
*
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&stdClass>
* @return array<int, object{agent: array{is_desktop: bool, platform: string|null, browser: string|null}, ip_address: string, is_current_device: bool, last_active: string}&\stdClass>
*/
public function sessions(Request $request): array
{

View File

@@ -9,7 +9,6 @@ use App\Models\Organization;
use App\Service\BillingContract;
use Closure;
use Illuminate\Http\Request;
use LogicException;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationBlocked
@@ -26,7 +25,7 @@ class CheckOrganizationBlocked
$organization = $request->route('organization');
if (! ($organization instanceof Organization)) {
throw new LogicException('The organization must be loaded before this middleware.');
throw new \LogicException('The organization must be loaded before this middleware.');
}
/** @var BillingContract $billing */

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -40,7 +39,7 @@ class TrustHosts extends BaseTrustHosts
}
/**
* @param Closure(Request):Response $next
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, $next)
{

View File

@@ -13,13 +13,11 @@ use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use LogicException;
/**
* @property Organization $organization Organization from model binding
@@ -29,7 +27,7 @@ class ReportStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule|Closure>>
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
public function rules(): array
{
@@ -87,7 +85,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.client_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -103,7 +101,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.project_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -119,7 +117,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.tag_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -139,7 +137,7 @@ class ReportStoreRequest extends BaseFormRequest
],
'properties.task_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -215,7 +213,7 @@ class ReportStoreRequest extends BaseFormRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
if ($start === null) {
throw new LogicException('Start date validation is not working');
throw new \LogicException('Start date validation is not working');
}
return $start;
@@ -225,7 +223,7 @@ class ReportStoreRequest extends BaseFormRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
if ($end === null) {
throw new LogicException('End date validation is not working');
throw new \LogicException('End date validation is not working');
}
return $end;

View File

@@ -19,13 +19,11 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use LogicException;
/**
* @property Organization $organization
@@ -35,7 +33,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -99,7 +97,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -116,7 +114,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -133,7 +131,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -154,7 +152,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -238,7 +236,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new LogicException('Start date validation is not working');
throw new \LogicException('Start date validation is not working');
}
return $start;
@@ -248,7 +246,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new LogicException('End date validation is not working');
throw new \LogicException('End date validation is not working');
}
return $end;

View File

@@ -17,7 +17,6 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
@@ -32,7 +31,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -84,7 +83,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -101,7 +100,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -118,7 +117,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -139,7 +138,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}

View File

@@ -15,13 +15,11 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use LogicException;
/**
* @property Organization $organization
@@ -31,7 +29,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|Closure>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
*/
public function rules(): array
{
@@ -70,7 +68,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -87,7 +85,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -104,7 +102,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -125,7 +123,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -202,7 +200,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new LogicException('Start date validation is not working');
throw new \LogicException('Start date validation is not working');
}
return $start;
@@ -212,7 +210,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new LogicException('End date validation is not working');
throw new \LogicException('End date validation is not working');
}
return $end;

View File

@@ -15,7 +15,6 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\TimeEntryFilter;
use Closure;
use Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
@@ -30,7 +29,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|RuleContract|Closure>>
* @return array<string, array<string|ValidationRule|RuleContract|\Closure>>
*/
public function rules(): array
{
@@ -62,7 +61,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'client_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -79,7 +78,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'project_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -96,7 +95,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'tag_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}
@@ -117,7 +116,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
],
'task_ids.*' => [
'string',
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryFilter::NONE_VALUE) {
return;
}

View File

@@ -12,7 +12,6 @@ use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -27,7 +26,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -95,7 +94,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, Closure $fail): void {
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}

View File

@@ -13,7 +13,6 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@@ -30,7 +29,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
*/
public function rules(): array
{
@@ -106,7 +105,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void {
// While breaks are disabled, entries that already are breaks may stay
// breaks, but converting a work entry to a break is not allowed.
if ($value === TimeEntryType::Break->value

View File

@@ -25,7 +25,7 @@ use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Dedoc\Scramble\Support\Generator\SecuritySchemes\OAuthFlow;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Section;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;

View File

@@ -13,7 +13,7 @@ use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Pages\Dashboard;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@@ -41,7 +41,7 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
Dashboard::class,
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
@@ -51,6 +51,7 @@ class AdminPanelProvider extends PanelProvider
TimeEntriesCreated::class,
TimeEntriesImported::class,
])
->viteTheme('resources/css/filament/admin/theme.css')
->plugins([
EnvironmentIndicatorPlugin::make()
->color(fn () => match (App::environment()) {

View File

@@ -15,7 +15,6 @@ use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Http;
use Log;
use Throwable;
class ApiService
{
@@ -43,7 +42,7 @@ class ApiService
return null;
}
} catch (Throwable $e) {
} catch (\Throwable $e) {
Log::warning('Failed to check for update', [
'message' => $e->getMessage(),
]);

View File

@@ -16,7 +16,6 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DashboardService
{
@@ -102,7 +101,7 @@ class DashboardService
$value1 = Carbon::createFromFormat('Y-m-d', $possibleDates->first(), $timeZone);
$value2 = Carbon::createFromFormat('Y-m-d', $possibleDates->last(), $timeZone);
if ($value2 === null || $value1 === null) {
throw new RuntimeException('Provided date is not valid');
throw new \RuntimeException('Provided date is not valid');
}
if ($value1->gt($value2)) {
$last = $value1;

View File

@@ -17,7 +17,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
class ReportPropertiesDto implements Castable
{
@@ -102,15 +101,15 @@ class ReportPropertiesDto implements Castable
public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto
{
if (! is_string($value)) {
throw new InvalidArgumentException('The given value is not a string');
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if ($data === null) {
throw new InvalidArgumentException('The given value is not a JSON string');
throw new \InvalidArgumentException('The given value is not a JSON string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new ReportPropertiesDto;
@@ -146,7 +145,7 @@ class ReportPropertiesDto implements Castable
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {
throw new InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
}
$data = (object) [
@@ -172,7 +171,7 @@ class ReportPropertiesDto implements Castable
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new InvalidArgumentException('Could not encode the given data to a JSON string');
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
@@ -189,10 +188,10 @@ class ReportPropertiesDto implements Castable
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new InvalidArgumentException('The given ID is not a string');
throw new \InvalidArgumentException('The given ID is not a string');
}
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
throw new InvalidArgumentException('The given ID is not a valid UUID');
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}

View File

@@ -9,7 +9,6 @@ use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
/**
* @template TModel of Model
@@ -134,7 +133,7 @@ class ImportDatabaseHelper
{
$jsonData = json_encode($data);
if ($jsonData === false) {
throw new RuntimeException('Failed to encode data to JSON');
throw new \RuntimeException('Failed to encode data to JSON');
}
return md5($jsonData);
@@ -165,7 +164,7 @@ class ImportDatabaseHelper
return $this->createEntity($identifierData, $createValues, $externalIdentifier);
} else {
throw new RuntimeException('Not implemented');
throw new \RuntimeException('Not implemented');
}
}

View File

@@ -9,14 +9,13 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class ClockifyProjectsImporter extends DefaultImporter
{
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -130,13 +129,13 @@ class ClockifyProjectsImporter extends DefaultImporter
return $billableRateKey;
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.clockify_projects.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.clockify_projects.description');

View File

@@ -15,7 +15,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class ClockifyTimeEntriesImporter extends DefaultImporter
{
@@ -45,7 +44,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -263,13 +262,13 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
return null;
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.clockify_time_entries.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.clockify_time_entries.description');

View File

@@ -18,7 +18,6 @@ use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper;
use App\Service\TimezoneService;
use Illuminate\Database\Eloquent\Builder;
use Override;
abstract class DefaultImporter implements ImporterContract
{
@@ -182,7 +181,7 @@ abstract class DefaultImporter implements ImporterContract
$this->billableRateService = app(BillableRateService::class);
}
#[Override]
#[\Override]
public function getReport(): ReportDto
{
return new ReportDto(

View File

@@ -14,7 +14,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class GenericTimeEntriesImporter extends DefaultImporter
{
@@ -60,7 +59,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -195,13 +194,13 @@ class GenericTimeEntriesImporter extends DefaultImporter
}
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.generic_time_entries.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.generic_time_entries.description');

View File

@@ -7,7 +7,6 @@ namespace App\Service\Import\Importers;
use Exception;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class HarvestClientsImporter extends DefaultImporter
{
@@ -21,7 +20,7 @@ class HarvestClientsImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -63,13 +62,13 @@ class HarvestClientsImporter extends DefaultImporter
}
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.harvest_clients.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_clients.description');

View File

@@ -8,7 +8,6 @@ use Exception;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class HarvestProjectsImporter extends DefaultImporter
{
@@ -25,7 +24,7 @@ class HarvestProjectsImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -94,13 +93,13 @@ class HarvestProjectsImporter extends DefaultImporter
}
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.harvest_projects.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_projects.description');

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
class ImportException extends Exception {}
class ImportException extends \Exception {}

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use InvalidArgumentException;
class ImporterProvider
{
/**
@@ -51,7 +49,7 @@ class ImporterProvider
public function getImporter(string $type): ImporterContract
{
if (! array_key_exists($type, $this->importers)) {
throw new InvalidArgumentException('Invalid importer type');
throw new \InvalidArgumentException('Invalid importer type');
}
return new $this->importers[$type];

View File

@@ -14,7 +14,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class TogglTimeEntriesImporter extends DefaultImporter
{
@@ -44,7 +43,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
/**
* @throws ImportException
*/
#[Override]
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
@@ -193,13 +192,13 @@ class TogglTimeEntriesImporter extends DefaultImporter
}
}
#[Override]
#[\Override]
public function getName(): string
{
return __('importer.toggl_time_entries.name');
}
#[Override]
#[\Override]
public function getDescription(): string
{
return __('importer.toggl_time_entries.description');

View File

@@ -14,7 +14,6 @@ use League\Csv\CannotInsertRecord;
use League\Csv\Exception;
use League\Csv\UnavailableStream;
use League\Csv\Writer;
use LogicException;
use Spatie\TemporaryDirectory\TemporaryDirectory;
/**
@@ -117,7 +116,7 @@ abstract class CsvExport
private function validateRow(array $row): void
{
if (array_keys($row) !== static::HEADER) {
throw new LogicException('Invalid row');
throw new \LogicException('Invalid row');
}
}
}

View File

@@ -20,7 +20,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class TimeEntryAggregationService
{
@@ -527,7 +526,7 @@ class TimeEntryAggregationService
public function timeSlotsBetween(Carbon $start, Carbon $end, string $timezone, Weekday $startOfWeek, TimeEntryAggregationTypeInterval $interval, string $format): Collection
{
if ($start->gt($end)) {
throw new InvalidArgumentException('Start date must be before end date');
throw new \InvalidArgumentException('Start date must be before end date');
}
$slots = new Collection;
$current = $start->copy()->timezone($timezone);
@@ -540,7 +539,7 @@ class TimeEntryAggregationService
} elseif ($interval === TimeEntryAggregationTypeInterval::Year) {
$current->startOfYear();
} else {
throw new InvalidArgumentException('Invalid interval');
throw new \InvalidArgumentException('Invalid interval');
}
while ($current->lt($end)) {

View File

@@ -6,7 +6,6 @@ namespace App\Service;
use App\Models\User;
use Carbon\CarbonTimeZone;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
@@ -288,7 +287,7 @@ class TimezoneService
{
try {
return new CarbonTimeZone($user->timezone);
} catch (Exception $e) {
} catch (\Exception $e) {
Log::error('User has a invalid timezone', [
'user_id' => $user->getKey(),
'timezone' => $user->timezone,

View File

@@ -20,7 +20,6 @@ use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class UserService
{
@@ -149,7 +148,7 @@ class UserService
->whereBelongsTo($newOwner, 'user')
->first();
if ($userMembership === null) {
throw new InvalidArgumentException('User is not a member of the organization');
throw new \InvalidArgumentException('User is not a member of the organization');
}
$userMembership->role = Role::Owner->value;
$userMembership->save();

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Support;
use finfo;
use Symfony\Component\Mime\MimeTypes;
class Base64File
@@ -28,7 +27,7 @@ class Base64File
return null;
}
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
if ($mimeType === false) {
return null;
}

View File

@@ -7,14 +7,14 @@
"require": {
"php": "8.3.*",
"ext-zip": "*",
"brick/money": "^0.11.2",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.43",
"filament/filament": "^5.0",
"flowframe/laravel-trend": "^0.5.0",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^3.3.4",
"inertiajs/inertia-laravel": "^2.0.3",
"korridor/laravel-computed-attributes": "^3.1",
"korridor/laravel-has-many-sync": "^3.1",
"korridor/laravel-model-validation-rules": "^3.0",
@@ -26,15 +26,15 @@
"league/csv": "^9.16.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/iso3166": "^4.3",
"maatwebsite/excel": "^4.0.3",
"maatwebsite/excel": "^3.1",
"mobiledetect/mobiledetectlib": "^4.11",
"novadaemon/filament-pretty-json": "^4.0",
"nwidart/laravel-modules": "^13.0.0",
"novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^12.0.4",
"owen-it/laravel-auditing": "^14.0.0",
"pxlrbt/filament-environment-indicator": "^3.0",
"pxlrbt/filament-environment-indicator": "^2.1.0",
"spatie/temporary-directory": "^2.2",
"staudenmeir/eloquent-json-relations": "^1.1",
"stechstudio/filament-impersonate": "^5.0",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^3.0.0",
"wikimedia/composer-merge-plugin": "^2.1.0"

3100
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Server Side Rendering
|--------------------------------------------------------------------------
|
| These options configures if and how Inertia uses Server Side Rendering
| to pre-render the initial visits made to your application's pages.
|
| You can specify a custom SSR bundle path, or omit it to let Inertia
| try and automatically detect it for you.
|
| Do note that enabling these options will NOT automatically make SSR work,
| as a separate rendering service needs to be available. To learn more,
| please visit https://inertiajs.com/server-side-rendering
|
*/
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
'ensure_runtime_exists' => (bool) env('INERTIA_SSR_ENSURE_RUNTIME_EXISTS', false),
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
'hot_url' => env('INERTIA_SSR_HOT_URL'),
'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
/*
|--------------------------------------------------------------------------
| SSR Error Handling
|--------------------------------------------------------------------------
|
| When SSR rendering fails, Inertia gracefully falls back to client-side
| rendering. Set throw_on_error to true to throw an exception instead.
| This is useful for E2E testing where you want SSR errors to fail loudly.
|
| You can also listen for the Inertia\Ssr\SsrRenderFailed event to handle
| failures in your own way (e.g., logging, error tracking service).
|
*/
'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],
/*
|--------------------------------------------------------------------------
| Pages
|--------------------------------------------------------------------------
|
| Set `ensure_pages_exist` to true if you want to enforce that Inertia page
| components exist on disk when rendering a page. This is useful for
| catching missing or misnamed components.
|
| The `paths` and `extensions` options define where to look for page
| components and which file extensions to consider.
|
*/
'pages' => [
'ensure_pages_exist' => false,
'paths' => [
resource_path('js/Pages'),
base_path('extensions/Invoicing/resources/js/Pages'),
],
'extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
/*
|--------------------------------------------------------------------------
| Testing
|--------------------------------------------------------------------------
|
| When using `assertInertia`, the assertion attempts to locate the
| component as a file relative to the `pages.paths` AND with any of
| the `pages.extensions` specified above.
|
| You can disable this behavior by setting `ensure_pages_exist`
| to false.
|
*/
'testing' => [
'ensure_pages_exist' => true,
],
/*
|--------------------------------------------------------------------------
| Expose Shared Prop Keys
|--------------------------------------------------------------------------
|
| When enabled, each page response includes a `sharedProps` metadata key
| listing the top-level prop keys that were registered via `Inertia::share`.
| The frontend can use this to carry shared props over during instant visits.
|
*/
'expose_shared_prop_keys' => true,
/*
|--------------------------------------------------------------------------
| History
|--------------------------------------------------------------------------
|
| Enable `encrypt` to encrypt page data before it is stored in the
| browser's history state, preventing sensitive information from
| being accessible after logout. Can also be enabled per-request
| or via the `inertia.encrypt` middleware.
|
*/
'history' => [
'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false),
],
/*
|--------------------------------------------------------------------------
| DevTools
|--------------------------------------------------------------------------
|
| Records one entry per request to disk so the DevTools Chrome extension may
| read it back over HTTP. Recording is limited to your local environment.
| See https://inertiajs.com/docs/devtools for the gate and storage options.
|
*/
'devtools' => [
'enabled' => env('INERTIA_DEVTOOLS_ENABLED'),
'except' => ['telescope*', 'horizon*', '_inertia/devtools*'],
'storage' => [
'path' => storage_path('inertia-devtools'),
'ttl' => (int) env('INERTIA_DEVTOOLS_TTL_HOURS', 24),
'prune_interval' => (int) env('INERTIA_DEVTOOLS_PRUNE_INTERVAL_SECONDS', 300),
'limit' => (int) env('INERTIA_DEVTOOLS_LIMIT', 100),
],
'middleware' => ['web'],
'gate' => env('INERTIA_DEVTOOLS_GATE'),
'redact' => [
'keys' => [
'password',
'password_confirmation',
'current_password',
'token',
'_token',
'access_token',
'refresh_token',
'secret',
'client_secret',
'api_key',
],
'headers' => [
'cookie',
'set-cookie',
'authorization',
'proxy-authorization',
'x-xsrf-token',
'x-csrf-token',
],
],
],
];

View File

@@ -53,6 +53,47 @@ function todayAt(hour: number, minute: number = 0): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
// whichever day the suite runs on.
function wednesdayAt(hour: number, minute: number = 0): Date {
const now = new Date();
const daysSinceMonday = (now.getDay() + 6) % 7;
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - daysSinceMonday + 2,
hour,
minute,
0,
0
);
}
function thursdayAt(hour: number, minute: number = 0): Date {
const d = wednesdayAt(hour, minute);
d.setDate(d.getDate() + 1);
return d;
}
function toApiTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
function toDateStr(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
await expect(header).toBeVisible();
const box = await header.boundingBox();
return box!.x + box!.width / 2;
}
/**
* These tests verify that changing the project on a time entry via the calendar
* updates the billable status to match the new project's is_billable setting.
@@ -962,14 +1003,9 @@ test.describe('Drag-to-Move Events', () => {
page,
ctx,
}) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
const start = todayAt(0, 30);
const end = todayAt(1, 30);
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const start = toApiTimestamp(thursdayAt(0, 30));
const end = toApiTimestamp(thursdayAt(1, 30));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Drag up past midnight test',
start,
@@ -978,11 +1014,10 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const event = todayCol
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
const event = thursdayCol
.locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 });
@@ -1018,10 +1053,7 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
});
});
@@ -1261,8 +1293,8 @@ test.describe('Resize Events', () => {
});
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = todayAt(10);
const end = todayAt(11);
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(11));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1271,26 +1303,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Find a column AFTER today (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
// Pick first column after today, or skip if today is last
for (let i = todayIndex + 1; i < headerCount; i++) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column after today to resize to');
// Resize into Thursday's column (for end resize, end must be > start)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const event = page
.locator('.fc-event')
@@ -1318,7 +1332,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// Then move horizontally to a later day column
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.up();
})(),
]);
@@ -1340,8 +1354,8 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
const start = todayAt(10);
const end = todayAt(14);
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1350,30 +1364,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let foundToday = false;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
foundToday = true;
continue;
}
if (foundToday) {
const box = await header.boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
}
// If today is the last column, use the one before today instead won't work for end resize,
// so skip this test in that edge case
if (targetX === undefined) {
test.skip();
return;
}
// Resize into Thursday's column (later day needed for end-edge resize)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const event = page
.locator('.fc-event')
@@ -1398,7 +1390,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// Move to a different day column at same Y position
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
await page.mouse.up();
})(),
]);
@@ -1464,22 +1456,9 @@ test.describe('Resize Events', () => {
});
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning today evening → tomorrow morning
const start = todayAt(20);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
10,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
// Create entry spanning Wednesday evening → Thursday morning
const start = toApiTimestamp(wednesdayAt(20));
const end = toApiTimestamp(thursdayAt(10));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi-day end resize',
@@ -1488,14 +1467,14 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on tomorrow's column
const event = tomorrowCol
// Find the event segment on Thursday's column
const event = thursdayCol
.locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' })
.first();
@@ -1541,22 +1520,9 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
// Create entry spanning today → tomorrow
const start = todayAt(10);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
// Create entry spanning Wednesday → Thursday
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Backward end resize multi',
@@ -1565,29 +1531,16 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
await scrollCalendarToTime(page, '12:00:00');
// Find today's column header to get its X center
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let todayX: number | undefined;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
const box = await header.boundingBox();
todayX = box!.x + box!.width / 2;
break;
}
}
test.skip(todayX === undefined, 'Could not find today column header');
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
// Find event segment on tomorrow's column and resize end backward to today
const event = tomorrowCol
// Find event segment on Thursday's column and resize end backward to Wednesday
const event = thursdayCol
.locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' })
.first();
@@ -1609,9 +1562,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100);
await page.mouse.down();
// Drag down a bit first, then move to today's column at a Y after the start
// Drag down a bit first, then move to Wednesday's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.up();
})(),
]);
@@ -1627,8 +1580,8 @@ test.describe('Resize Events', () => {
});
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = todayAt(10);
const end = todayAt(14);
const start = toApiTimestamp(thursdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test',
start,
@@ -1637,25 +1590,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Find a column BEFORE today
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
for (let i = todayIndex - 1; i >= 0; i--) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column before today to test');
// Target Wednesday's column, the day before the entry
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
const event = page
.locator('.fc-event')
@@ -1683,7 +1619,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// Move to earlier column at a Y position near the top of the grid (before start time)
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.up();
// Wait for any potential API call
@@ -1732,30 +1668,21 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
page,
}) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday with Monday week start)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00');
// Find today's and tomorrow's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
// Find Wednesday's and Thursday's columns
const wednesdayStr = toDateStr(wednesdayAt(0));
const thursdayStr = toDateStr(thursdayAt(0));
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
await expect(todayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible();
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(wednesdayCol).toBeVisible();
await expect(thursdayCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
await expect(startSlot).toBeVisible();
@@ -1763,18 +1690,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox();
const todayColBox = await todayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox();
const wednesdayColBox = await wednesdayCol.boundingBox();
const thursdayColBox = await thursdayCol.boundingBox();
// Start drag at 11:00 on today's column
const startX = todayColBox!.x + todayColBox!.width / 2;
// Start drag at 11:00 on Wednesday's column
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
const startY = startSlotBox!.y + 2;
// End drag at 13:00 on tomorrow's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
// End drag at 13:00 on Thursday's column
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
const endY = endSlotBox!.y + 2;
// Drag from today to tomorrow — move down first, then across
// Drag from Wednesday to Thursday, moving down first, then across
const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY);
await page.mouse.down();
@@ -1792,9 +1719,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible();
// Start date should be today, end date should be tomorrow
await expect(dialog.getByText(todayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
// Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(thursdayStr)).toBeVisible();
});
});
@@ -1876,17 +1803,9 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from today 22:00 to tomorrow 02:00
const now = new Date();
const dayOfWeek = now.getDay();
// If today is Saturday (6), the entry would span to next week and may not be visible
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day entry',
@@ -1901,14 +1820,8 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event can be edited via click', async ({ page, ctx }) => {
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day edit test',
@@ -1927,14 +1840,8 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event context menu works', async ({ page, ctx }) => {
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day ctx test',
@@ -1954,16 +1861,11 @@ test.describe('Multi-Day Events', () => {
page,
ctx,
}) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag test',
@@ -1973,12 +1875,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
await expect(event).toBeVisible({ timeout: 10000 });
const eventBox = await event.boundingBox();
@@ -2013,27 +1912,19 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on today (not jumped to tomorrow)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
// The start should still be on Wednesday (not jumped to Thursday)
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
});
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
page,
ctx,
}) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag up test',
@@ -2043,12 +1934,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol
.locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 });

View File

@@ -1 +0,0 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-DR6K5BQD.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-A2J6H3EX.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-ZABHMKQG.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-VWEHJHBA.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-ZIT2UBIY.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-DIHYUR35.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

View File

@@ -0,0 +1,10 @@
import preset from '../../../../vendor/filament/filament/tailwind.config.preset';
export default {
presets: [preset],
content: [
'./app/Filament/**/*.php',
'./resources/views/filament/**/*.blade.php',
'./vendor/filament/**/*.blade.php',
],
};

View File

@@ -0,0 +1,3 @@
@import '/vendor/filament/filament/resources/css/theme.css';
@config 'tailwind.config.js';

View File

@@ -10,13 +10,21 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
| 'name'
| 'tasks'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -56,6 +64,16 @@ const clientNameMap = computed(() => {
return map;
});
const { tasks } = useTasksQuery();
const taskCountMap = computed(() => {
const map = new Map<string, number>();
tasks.value.forEach((task) => {
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
});
return map;
});
// Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down).
@@ -64,6 +82,11 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{
id: 'client_name',
accessorFn: (row: Project) => {
@@ -161,7 +184,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
});
</script>

View File

@@ -36,6 +36,9 @@ function handleSort(column: SortColumn) {
@sort="handleSort">
Name
</SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client
</SortableTableHeaderCell>

View File

@@ -88,17 +88,19 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 ml-1 rounded-full"></div>
:style="{ backgroundColor: project.color }"
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="projectTasksCount">
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">

View File

@@ -21,7 +21,7 @@ class CurrencyEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertOk();
$response->assertJsonCount(214);
$response->assertJsonCount(166);
$responseObj = collect($response->json());
$this->assertSame([
'code' => 'EUR',

Some files were not shown because too many files have changed in this diff Show More