diff --git a/app/Actions/Approval/CreateApprovalAction.php b/app/Actions/Approval/CreateApprovalAction.php new file mode 100644 index 0000000..9b3eb72 --- /dev/null +++ b/app/Actions/Approval/CreateApprovalAction.php @@ -0,0 +1,28 @@ +create([ + 'company_id' => $approval->companyId, + 'report_id' => $approval->reportId, + 'approver_id' => $approval->approverId, + 'status' => $approval->status, + 'level' => 'manager', + 'comments' => $approval->comments, + 'approved_at' => now(), + ]); + + ApprovalStatusChangedEvent::dispatch($approval->status, $approval->reportId); + } +} diff --git a/app/DTOs/ApprovalDTO.php b/app/DTOs/ApprovalDTO.php new file mode 100644 index 0000000..013c549 --- /dev/null +++ b/app/DTOs/ApprovalDTO.php @@ -0,0 +1,34 @@ + Color::Green, + self::Rejected => Color::Red, + }; + } + + public function getLabel(): string + { + return match ($this) { + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } +} diff --git a/app/Enums/ReimbursementStatus.php b/app/Enums/ReimbursementStatus.php new file mode 100644 index 0000000..66614fc --- /dev/null +++ b/app/Enums/ReimbursementStatus.php @@ -0,0 +1,37 @@ + Color::Green, + self::Rejected => Color::Red, + self::Created => Color::Blue, + self::Refunded => Color::Purple, + }; + } + + public function getLabel(): string + { + return match ($this) { + self::Approved => 'Approved', + self::Rejected => 'Rejected', + self::Created => 'Created', + self::Refunded => 'Refunded', + }; + } +} diff --git a/app/Enums/ReportStatus.php b/app/Enums/ReportStatus.php new file mode 100644 index 0000000..ccc9120 --- /dev/null +++ b/app/Enums/ReportStatus.php @@ -0,0 +1,41 @@ + Color::Gray, + self::Submitted => Color::Blue, + self::Approved => Color::Emerald, + self::Rejected => Color::Red, + self::Reimbursed => Color::Purple, + }; + } + + public function getLabel(): string + { + + return match ($this) { + self::Draft => 'Draft', + self::Submitted => 'Submitted', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + self::Reimbursed => 'Reimbursed', + }; + } +} diff --git a/app/Events/ApprovalStatusChangedEvent.php b/app/Events/ApprovalStatusChangedEvent.php new file mode 100644 index 0000000..6658b7f --- /dev/null +++ b/app/Events/ApprovalStatusChangedEvent.php @@ -0,0 +1,18 @@ + ListApprovals::route('/'), + 'create' => CreateApproval::route('/create'), + 'edit' => EditApproval::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Approvals/Pages/CreateApproval.php b/app/Filament/Admin/Resources/Approvals/Pages/CreateApproval.php new file mode 100644 index 0000000..4c07b91 --- /dev/null +++ b/app/Filament/Admin/Resources/Approvals/Pages/CreateApproval.php @@ -0,0 +1,21 @@ +id(); + $data['approved_at'] = now(); + + return $data; + } +} diff --git a/app/Filament/Admin/Resources/Approvals/Pages/EditApproval.php b/app/Filament/Admin/Resources/Approvals/Pages/EditApproval.php new file mode 100644 index 0000000..9a70c7f --- /dev/null +++ b/app/Filament/Admin/Resources/Approvals/Pages/EditApproval.php @@ -0,0 +1,21 @@ +components([ + Select::make('company_id') + ->relationship('company', 'name') + ->required(), + + CompanyDependentSelect::make('report_id', Report::class, 'title', 'status', ReportStatus::Submitted->value) + ->label('Report') + ->required(), + + TextInput::make('level') + ->required(), + Select::make('status') + ->options(ApprovalStatus::class) + ->enum(ApprovalStatus::class) + ->required(), + TextInput::make('comments') + ->required() + ->minlength(3) + ->maxLength(255), + DateTimePicker::make('approved_at') + ->hidden() + ->required(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Approvals/Tables/ApprovalsTable.php b/app/Filament/Admin/Resources/Approvals/Tables/ApprovalsTable.php new file mode 100644 index 0000000..251d6e3 --- /dev/null +++ b/app/Filament/Admin/Resources/Approvals/Tables/ApprovalsTable.php @@ -0,0 +1,79 @@ +columns([ + TextColumn::make('company.name') + ->numeric() + ->sortable(), + TextColumn::make('report.title') + ->numeric() + ->sortable(), + TextColumn::make('approver.name') + ->numeric() + ->sortable(), + TextColumn::make('level') + ->searchable(), + TextColumn::make('status') + ->searchable() + ->badge(), + TextColumn::make('comments') + ->searchable(), + TextColumn::make('approved_at') + ->dateTime() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('company') + ->preload() + ->searchable() + ->relationship('company', 'name'), + + SelectFilter::make('approver') + ->label('Approver') + ->preload() + ->searchable() + ->relationship('approver', 'name'), + + SelectFilter::make('status') + ->preload() + ->searchable() + ->options(ApprovalStatus::class), + + ])->filtersFormColumns(3) + ->filtersFormWidth(Width::FourExtraLarge) + ->persistFiltersInSession() + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Categories/CategoryResource.php b/app/Filament/Admin/Resources/Categories/CategoryResource.php new file mode 100644 index 0000000..c654ca6 --- /dev/null +++ b/app/Filament/Admin/Resources/Categories/CategoryResource.php @@ -0,0 +1,53 @@ + ListCategories::route('/'), + 'create' => CreateCategory::route('/create'), + 'edit' => EditCategory::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Categories/Pages/CreateCategory.php b/app/Filament/Admin/Resources/Categories/Pages/CreateCategory.php new file mode 100644 index 0000000..313ce53 --- /dev/null +++ b/app/Filament/Admin/Resources/Categories/Pages/CreateCategory.php @@ -0,0 +1,13 @@ +components([ + NameInput::make(), + TextInput::make('description') + ->required(), + Select::make('company_id') + ->relationship('company', 'name') + ->required(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Admin/Resources/Categories/Tables/CategoriesTable.php new file mode 100644 index 0000000..463ab42 --- /dev/null +++ b/app/Filament/Admin/Resources/Categories/Tables/CategoriesTable.php @@ -0,0 +1,47 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('description') + ->searchable(), + TextColumn::make('company.name') + ->numeric() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Companies/CompanyResource.php b/app/Filament/Admin/Resources/Companies/CompanyResource.php new file mode 100644 index 0000000..18e2410 --- /dev/null +++ b/app/Filament/Admin/Resources/Companies/CompanyResource.php @@ -0,0 +1,53 @@ + ListCompanies::route('/'), + 'create' => CreateCompany::route('/create'), + 'edit' => EditCompany::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Companies/Pages/CreateCompany.php b/app/Filament/Admin/Resources/Companies/Pages/CreateCompany.php new file mode 100644 index 0000000..23e6658 --- /dev/null +++ b/app/Filament/Admin/Resources/Companies/Pages/CreateCompany.php @@ -0,0 +1,13 @@ +components([ + NameInput::make() + ->reactive() + ->afterStateUpdated(fn (Set $set, Get $get): mixed => $set('slug', Str::slug($get('name')))), + TextInput::make('slug') + ->required() + ->readOnly() + ->unique(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php b/app/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php new file mode 100644 index 0000000..8a3ed8d --- /dev/null +++ b/app/Filament/Admin/Resources/Companies/Tables/CompaniesTable.php @@ -0,0 +1,38 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + ...TimestampsColumns::make(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Departments/DepartmentResource.php b/app/Filament/Admin/Resources/Departments/DepartmentResource.php new file mode 100644 index 0000000..4ba0c1e --- /dev/null +++ b/app/Filament/Admin/Resources/Departments/DepartmentResource.php @@ -0,0 +1,55 @@ + ListDepartments::route('/'), + 'create' => CreateDepartment::route('/create'), + 'edit' => EditDepartment::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Departments/Pages/CreateDepartment.php b/app/Filament/Admin/Resources/Departments/Pages/CreateDepartment.php new file mode 100644 index 0000000..8d1713d --- /dev/null +++ b/app/Filament/Admin/Resources/Departments/Pages/CreateDepartment.php @@ -0,0 +1,20 @@ +record; + $managerId = $department->manager->id; + $department->users()->syncWithoutDetaching([$managerId]); + } +} diff --git a/app/Filament/Admin/Resources/Departments/Pages/EditDepartment.php b/app/Filament/Admin/Resources/Departments/Pages/EditDepartment.php new file mode 100644 index 0000000..95d1537 --- /dev/null +++ b/app/Filament/Admin/Resources/Departments/Pages/EditDepartment.php @@ -0,0 +1,21 @@ +components([ + NameInput::make(), + TextInput::make('budget') + ->required() + ->numeric(), + Select::make('company_id') + ->relationship('company', 'name') + ->required(), + + CompanyDependentSelect::make('manager_id', User::class, 'name') + ->label('Manager') + ->required(), + + Select::make('users') + ->relationship(name: 'users', titleAttribute: 'name', modifyQueryUsing: function (Builder $query, Get $get): Builder { + if ($managerId = $get('manager_id')) { + $query->where('users.id', '!=', $managerId) + ->where('company_id', $get('company_id')); + } + + return $query->where('company_id', $get('company_id')); + }) + ->required(fn (Get $get): bool => is_null($get('manager_id'))) + ->multiple() + ->preload(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Departments/Tables/DepartmentsTable.php b/app/Filament/Admin/Resources/Departments/Tables/DepartmentsTable.php new file mode 100644 index 0000000..abc2115 --- /dev/null +++ b/app/Filament/Admin/Resources/Departments/Tables/DepartmentsTable.php @@ -0,0 +1,44 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('budget') + ->numeric() + ->sortable(), + TextColumn::make('company.name') + ->numeric() + ->sortable(), + TextColumn::make('manager.name') + ->searchable(), + ...TimestampsColumns::make(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Expenses/ExpenseResource.php b/app/Filament/Admin/Resources/Expenses/ExpenseResource.php new file mode 100644 index 0000000..30c93e2 --- /dev/null +++ b/app/Filament/Admin/Resources/Expenses/ExpenseResource.php @@ -0,0 +1,53 @@ + ListExpenses::route('/'), + 'create' => CreateExpense::route('/create'), + 'edit' => EditExpense::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Expenses/Pages/CreateExpense.php b/app/Filament/Admin/Resources/Expenses/Pages/CreateExpense.php new file mode 100644 index 0000000..42a0f80 --- /dev/null +++ b/app/Filament/Admin/Resources/Expenses/Pages/CreateExpense.php @@ -0,0 +1,13 @@ +components([ + TextInput::make('amount') + ->required() + ->numeric(), + DateTimePicker::make('date') + ->required(), + TextInput::make('description') + ->required() + ->maxLength(255), + + SpatieMediaLibraryFileUpload::make('receipt') + ->collection('receipt') + ->label('Receipt Path') + ->multiple() + ->image() + ->required(), + + Select::make('company_id') + ->relationship('company', 'name') + ->required(), + CompanyDependentSelect::make('user_id', User::class, 'name') + ->label('User') + ->required(), + CompanyDependentSelect::make('report_id', Report::class, 'title', 'status', ReportStatus::Submitted->value) + ->label('Report') + ->required(), + + CompanyDependentSelect::make('category_id', Category::class, 'name') + ->label('Category') + ->required(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Expenses/Tables/ExpensesTable.php b/app/Filament/Admin/Resources/Expenses/Tables/ExpensesTable.php new file mode 100644 index 0000000..3d8f112 --- /dev/null +++ b/app/Filament/Admin/Resources/Expenses/Tables/ExpensesTable.php @@ -0,0 +1,80 @@ +columns([ + TextColumn::make('amount') + ->numeric() + ->sortable(), + TextColumn::make('date') + ->dateTime() + ->sortable(), + TextColumn::make('description') + ->searchable(), + TextColumn::make('receipt_path') + ->searchable(), + TextColumn::make('company.name') + ->numeric() + ->sortable(), + TextColumn::make('user.name') + ->numeric() + ->sortable(), + TextColumn::make('report.title') + ->numeric() + ->sortable(), + TextColumn::make('category.name') + ->numeric() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('company') + ->preload() + ->searchable() + ->relationship('company', 'name'), + + SelectFilter::make('user') + ->preload() + ->searchable() + ->relationship('user', 'name'), + + SelectFilter::make('category') + ->preload() + ->searchable() + ->relationship('category', 'name'), + + ])->filtersFormColumns(3) + ->filtersFormWidth(Width::FourExtraLarge) + ->persistFiltersInSession() + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Reimbursements/Pages/CreateReimbursement.php b/app/Filament/Admin/Resources/Reimbursements/Pages/CreateReimbursement.php new file mode 100644 index 0000000..63ea1fd --- /dev/null +++ b/app/Filament/Admin/Resources/Reimbursements/Pages/CreateReimbursement.php @@ -0,0 +1,21 @@ + ListReimbursements::route('/'), + 'create' => CreateReimbursement::route('/create'), + 'edit' => EditReimbursement::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Reimbursements/Schemas/ReimbursementForm.php b/app/Filament/Admin/Resources/Reimbursements/Schemas/ReimbursementForm.php new file mode 100644 index 0000000..08c62b9 --- /dev/null +++ b/app/Filament/Admin/Resources/Reimbursements/Schemas/ReimbursementForm.php @@ -0,0 +1,65 @@ +components([ + Select::make('company_id') + ->relationship('company', 'name') + ->required() + ->reactive(), + Select::make('report_id') + ->relationship('report', 'title', + function (Builder $query, Get $get): void { + if ($companyId = $get('company_id')) { + $query->where('company_id', $companyId) + ->where('status', '=', ReportStatus::Approved); + } + + $query->whereDoesntHave('reimbursement', function ($q): void { + $q->where('report_id', '=', DB::raw('reports.id')); + }); + }) + ->required() + ->reactive() + ->disabled(fn (Get $get): bool => ! $get('company_id')) + ->afterStateUpdated(function (Set $set, Get $get): void { + if ($reportId = $get('report_id')) { + $amount = Report::query()->find($reportId)->expenses()->sum('amount'); + $set('amount', $amount); + } + }), + TextInput::make('amount') + ->required() + ->readOnly() + ->numeric(), + Select::make('status') + ->required() + ->hidden(), + TextInput::make('payment_method') + ->required(), + TextInput::make('payment_date') + ->required() + ->nullable() + ->hidden(), + TextInput::make('reference') + ->required(), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Reimbursements/Tables/ReimbursementsTable.php b/app/Filament/Admin/Resources/Reimbursements/Tables/ReimbursementsTable.php new file mode 100644 index 0000000..4673c00 --- /dev/null +++ b/app/Filament/Admin/Resources/Reimbursements/Tables/ReimbursementsTable.php @@ -0,0 +1,72 @@ +columns([ + TextColumn::make('company.name') + ->numeric() + ->sortable(), + TextColumn::make('report.title') + ->numeric() + ->sortable(), + TextColumn::make('amount') + ->numeric() + ->sortable(), + TextColumn::make('status') + ->searchable() + ->badge() + ->sortable(), + TextColumn::make('payment_method') + ->searchable(), + TextColumn::make('payment_date') + ->searchable(), + TextColumn::make('reference') + ->searchable(), + TextColumn::make('report.user.name') + ->label('Owner') + ->searchable(), + ...TimestampsColumns::make(), + ]) + ->filters([ + SelectFilter::make('status') + ->options(ReimbursementStatus::class), + SelectFilter::make('company') + ->preload() + ->searchable() + ->relationship('company', 'name'), + + SelectFilter::make('user') + ->preload() + ->searchable() + ->relationship('report.user', 'name'), + + ])->filtersFormColumns(3) + ->filtersFormWidth(Width::FourExtraLarge) + ->persistFiltersInSession() + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Reports/Pages/ApproveReport.php b/app/Filament/Admin/Resources/Reports/Pages/ApproveReport.php new file mode 100644 index 0000000..c7b6f88 --- /dev/null +++ b/app/Filament/Admin/Resources/Reports/Pages/ApproveReport.php @@ -0,0 +1,80 @@ +record = $this->resolveRecord($record); + $this->form->fill(); + $this->setTitle(); + } + + public function save(CreateApprovalAction $action): void + { + $data = $this->form->getState(); + $reportId = $this->record->getKey(); + + $action->execute( + ApprovalDTO::make( + companyId: auth()->user()->company_id, + reportId: $reportId, + approverId: auth()->user()->id, + status: $data['status'], + comments: $data['comments'], + ) + ); + + Notification::make() + ->title(sprintf('Your report %s status was %s', $reportId, $data['status']->value)) + ->sendToDatabase($this->record->user); + + redirect()->route('filament.admin.resources.approvals.index'); + } + + public function setTitle(): void + { + self::$title = sprintf('Report Number %s made by %s', $this->record->id, $this->record->user->name); + } + + public function form(Schema $schema): Schema + { + return $schema->schema([ + Select::make('status') + ->options(ApprovalStatus::class) + ->enum(ApprovalStatus::class) + ->required(), + TextInput::make('comments') + ->required() + ->minlength(3) + ->maxLength(255), + ])->statePath('data'); + } +} diff --git a/app/Filament/Admin/Resources/Reports/Pages/CreateReport.php b/app/Filament/Admin/Resources/Reports/Pages/CreateReport.php new file mode 100644 index 0000000..47450b9 --- /dev/null +++ b/app/Filament/Admin/Resources/Reports/Pages/CreateReport.php @@ -0,0 +1,27 @@ +id(); + $data['status'] = ReportStatus::Draft; + + collect($this->data['expenses'])->each(function (array $expense) use (&$data): void { + $this->data['total'] += $expense['amount']; + $data['total'] = $this->data['total']; + }); + + return $data; + } +} diff --git a/app/Filament/Admin/Resources/Reports/Pages/EditReport.php b/app/Filament/Admin/Resources/Reports/Pages/EditReport.php new file mode 100644 index 0000000..681c5c8 --- /dev/null +++ b/app/Filament/Admin/Resources/Reports/Pages/EditReport.php @@ -0,0 +1,21 @@ + ListReports::route('/'), + 'create' => CreateReport::route('/create'), + 'edit' => EditReport::route('/{record}/edit'), + 'approve-report' => ApproveReport::route('/{record}/approve'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/Reports/Schemas/ReportForm.php b/app/Filament/Admin/Resources/Reports/Schemas/ReportForm.php new file mode 100644 index 0000000..58f408d --- /dev/null +++ b/app/Filament/Admin/Resources/Reports/Schemas/ReportForm.php @@ -0,0 +1,115 @@ +components([ + Tabs::make() + ->tabs([ + Tab::make('Report-Information') + ->schema([ + TextInput::make('title') + ->required() + ->maxLength(255), + TextInput::make('description') + ->required() + ->maxLength(255), + + Select::make('company_id') + ->label('Company') + ->preload() + ->relationship('company', 'name') + ->required(), + + Select::make('status') + ->hidden(fn (string $operation): bool => $operation !== 'edit') + ->options([ + 'draft' => ReportStatus::Draft->value, + 'submitted' => ReportStatus::Submitted->value, + ]) + ->default(ReportStatus::Draft->value) + ->enum(ReportStatus::class) + ->required(), + TextInput::make('total') + ->hidden() + ->reactive() + ->required(), + ]), + Tab::make('Expenses') + ->schema([ + Repeater::make('expenses') + ->relationship('expenses') + ->schema([ + TextInput::make('amount') + ->label('Amount') + ->numeric() + ->minValue(1) + ->required(), + + DateTimePicker::make('date') + ->label('Date') + ->required(), + + TextInput::make('description') + ->label('Description') + ->required(), + + SpatieMediaLibraryFileUpload::make('receipt') + ->collection('receipt') + ->label('Receipt Path') + ->multiple() + ->image() + ->required(), + + TextInput::make('company_id') + ->required() + ->hidden(), + + TextInput::make('user_id') + ->label('User') + ->hidden() + ->required(), + + Select::make('category_id') + ->label('Category') + ->options(function (Get $get) { + if (! $companyId = $get('../../company_id')) { + return []; + } + + return Category::query()->where('company_id', $companyId)->pluck('name', 'id')->toArray(); + }) + ->reactive() + ->searchable() + ->required(), + ]) + ->defaultItems(1) + ->mutateRelationshipDataBeforeCreateUsing(function (array $data, Get $get) { + $data['company_id'] = $get('company_id'); + $data['user_id'] = auth()->id(); + + return $data; + }), + ]), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Reports/Tables/ReportsTable.php b/app/Filament/Admin/Resources/Reports/Tables/ReportsTable.php new file mode 100644 index 0000000..ef060dc --- /dev/null +++ b/app/Filament/Admin/Resources/Reports/Tables/ReportsTable.php @@ -0,0 +1,78 @@ +columns([ + TextColumn::make('title') + ->searchable(), + TextColumn::make('description') + ->searchable(), + TextColumn::make('status') + ->searchable() + ->badge(), + TextColumn::make('total') + ->searchable() + ->sortable(), + TextColumn::make('submitted_at') + ->dateTime() + ->sortable(), + TextColumn::make('company.name') + ->numeric() + ->searchable() + ->sortable(), + TextColumn::make('user.name') + ->numeric() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options(ReportStatus::class), + SelectFilter::make('company') + ->preload() + ->searchable() + ->relationship('company', 'name'), + SelectFilter::make('user') + ->preload() + ->searchable() + ->relationship('user', 'name'), + + ])->filtersFormColumns(3) + ->filtersFormWidth(Width::FourExtraLarge) + ->persistFiltersInSession() + ->recordActions([ + EditAction::make(), + Action::make('Approve') + ->url(fn ($record) => route('filament.admin.resources.reports.approve-report', $record)), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Admin/Resources/Users/Schemas/UserForm.php b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php index 2984f80..293db02 100644 --- a/app/Filament/Admin/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Admin/Resources/Users/Schemas/UserForm.php @@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Users\Schemas; +use App\Filament\Shared\Schemas\Form\NameInput; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\TextInput; use Filament\Schemas\Schema; @@ -14,9 +15,7 @@ public static function configure(Schema $schema): Schema { return $schema ->components([ - TextInput::make('name') - ->required() - ->maxLength(255), + NameInput::make(), TextInput::make('email') ->label('Email address') ->email() diff --git a/app/Filament/Admin/Resources/Users/Tables/UsersTable.php b/app/Filament/Admin/Resources/Users/Tables/UsersTable.php index ceb767e..6ce3142 100644 --- a/app/Filament/Admin/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Admin/Resources/Users/Tables/UsersTable.php @@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Users\Tables; +use App\Filament\Shared\Schemas\Table\Columns\TimestampsColumns; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -27,14 +28,7 @@ public static function configure(Table $table): Table TextColumn::make('email_verified_at') ->dateTime() ->sortable(), - TextColumn::make('created_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('updated_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), + ...TimestampsColumns::make(), ]) ->filters([ // diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 2722bfc..ad2d0be 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -15,12 +15,15 @@ use Filament\Schemas\Schema; use Filament\Support\Icons\Heroicon; use Filament\Tables\Table; +use UnitEnum; final class UserResource extends Resource { protected static ?string $model = User::class; - protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack; + protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers; + + protected static string|null|UnitEnum $navigationGroup = 'Users'; public static function form(Schema $schema): Schema { diff --git a/app/Filament/Shared/Schemas/Form/CompanyDependentSelect.php b/app/Filament/Shared/Schemas/Form/CompanyDependentSelect.php new file mode 100644 index 0000000..237729e --- /dev/null +++ b/app/Filament/Shared/Schemas/Form/CompanyDependentSelect.php @@ -0,0 +1,43 @@ +options(function (Get $get) use ($relatedModel, $displayColumn, $condition, $conditionValue) { + $companyId = $get('company_id'); + if (! $companyId) { + return []; + } + + if (! $condition) { + $relatedModel::where('company_id', $companyId)->pluck($displayColumn, 'id'); + } + + return $relatedModel::where('company_id', $companyId)->where($condition, $conditionValue)->pluck($displayColumn, 'id'); + }); + + $select->required(); + $select->preload(); + $select->searchable(); + $select->reactive(); + + return $select; + } +} diff --git a/app/Filament/Shared/Schemas/Form/NameInput.php b/app/Filament/Shared/Schemas/Form/NameInput.php new file mode 100644 index 0000000..099043c --- /dev/null +++ b/app/Filament/Shared/Schemas/Form/NameInput.php @@ -0,0 +1,25 @@ +required(); + $this->maxLength(255); + $this->placeholder(__('Enter your name')); + + } + + public static function make(?string $name = 'name'): static + { + return parent::make($name); + } +} diff --git a/app/Filament/Shared/Schemas/Table/Columns/TimestampsColumns.php b/app/Filament/Shared/Schemas/Table/Columns/TimestampsColumns.php new file mode 100644 index 0000000..ffb63a0 --- /dev/null +++ b/app/Filament/Shared/Schemas/Table/Columns/TimestampsColumns.php @@ -0,0 +1,30 @@ +dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]; + } +} diff --git a/app/Listeners/UpdateReportStatusListener.php b/app/Listeners/UpdateReportStatusListener.php new file mode 100644 index 0000000..28ff524 --- /dev/null +++ b/app/Listeners/UpdateReportStatusListener.php @@ -0,0 +1,22 @@ +find($event->reportId); + + $event->status->value === ApprovalStatus::Approved->value + ? $report->update(['status' => ReportStatus::Approved]) + : $report->update(['status' => ReportStatus::Rejected]); + } +} diff --git a/app/Models/Approval.php b/app/Models/Approval.php new file mode 100644 index 0000000..91a97bb --- /dev/null +++ b/app/Models/Approval.php @@ -0,0 +1,51 @@ +belongsTo(Company::class); + } + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approver_id'); + } + + protected function casts(): array + { + return [ + 'approved_at' => 'datetime', + 'status' => ApprovalStatus::class, + ]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..9bdb65b --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,34 @@ +belongsTo(Company::class); + } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class); + } +} diff --git a/app/Models/Company.php b/app/Models/Company.php new file mode 100644 index 0000000..03f4370 --- /dev/null +++ b/app/Models/Company.php @@ -0,0 +1,47 @@ +hasMany(User::class); + } + + public function reports(): HasMany + { + return $this->hasMany(Report::class); + } + + public function departments(): HasMany + { + return $this->hasmany(Department::class); + } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class); + } + + public function categories(): HasMany + { + return $this->hasMany(Category::class); + } +} diff --git a/app/Models/Department.php b/app/Models/Department.php new file mode 100644 index 0000000..48d8d20 --- /dev/null +++ b/app/Models/Department.php @@ -0,0 +1,40 @@ +belongsTo(Company::class); + } + + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_id'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'department_user', 'department_id', 'user_id'); + } +} diff --git a/app/Models/Expense.php b/app/Models/Expense.php new file mode 100644 index 0000000..704c2a8 --- /dev/null +++ b/app/Models/Expense.php @@ -0,0 +1,65 @@ +belongsTo(Company::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('receipt') + ->useDisk('public'); + } + + protected function casts(): array + { + return [ + 'date' => 'datetime', + 'receipt_path' => 'array', + ]; + } +} diff --git a/app/Models/Reimbursement.php b/app/Models/Reimbursement.php new file mode 100644 index 0000000..82dcbd8 --- /dev/null +++ b/app/Models/Reimbursement.php @@ -0,0 +1,53 @@ + ReimbursementStatus::class, + 'payment_date' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function report(): BelongsTo + { + return $this->belongsTo(Report::class); + } + + public function user(): HasOneThrough + { + return $this->hasOneThrough( + User::class, + Report::class, + 'user_id', + 'id', + 'id', + 'user_id' + ); + } +} diff --git a/app/Models/Report.php b/app/Models/Report.php new file mode 100644 index 0000000..3eb7158 --- /dev/null +++ b/app/Models/Report.php @@ -0,0 +1,61 @@ +belongsTo(Company::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class); + } + + public function reimbursement(): HasOne + { + return $this->hasOne(Reimbursement::class); + } + + protected function casts(): array + { + return [ + 'submitted_at' => 'datetime', + 'status' => ReportStatus::class, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 635e0ae..e7c6f16 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,8 +8,12 @@ use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Attributes\UsePolicy; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -17,7 +21,6 @@ final class User extends Authenticatable implements FilamentUser { use HasFactory; - use HasUuids; use Notifiable; /** @@ -29,7 +32,9 @@ final class User extends Authenticatable implements FilamentUser 'name', 'email', 'password', + 'company_id', 'email_verified_at', + 'department_id', ]; /** @@ -47,6 +52,36 @@ public function canAccessPanel(Panel $panel): bool return true; } + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function managedDepartment(): HasOne + { + return $this->hasOne(Department::class, 'manager_id'); + } + + public function departments(): BelongsToMany + { + return $this->belongsToMany(Department::class, 'department_user', 'user_id', 'department_id'); + } + + public function reports(): HasMany + { + return $this->hasmany(Report::class); + } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class); + } + + public function reimbursements(): HasManyThrough + { + return $this->hasManyThrough(Reimbursement::class, Report::class); + } + /** * Get the attributes that should be cast. * diff --git a/app/Observers/ReportObserver.php b/app/Observers/ReportObserver.php new file mode 100644 index 0000000..9491bb1 --- /dev/null +++ b/app/Observers/ReportObserver.php @@ -0,0 +1,19 @@ +isDirty('status') && $report->status === ReportStatus::Submitted) { + $report->submitted_at = now(); + $report->save(); + } + } +} diff --git a/app/Policies/ApprovalPolicy.php b/app/Policies/ApprovalPolicy.php new file mode 100644 index 0000000..89a51b9 --- /dev/null +++ b/app/Policies/ApprovalPolicy.php @@ -0,0 +1,47 @@ +id('admin') ->path('admin') ->login(LoginPage::class) + ->databaseNotifications() ->colors([ - 'primary' => Color::Amber, + 'primary' => Color::Emerald, ]) ->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\Filament\\Admin\\Resources') ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') diff --git a/database/factories/ApprovalFactory.php b/database/factories/ApprovalFactory.php new file mode 100644 index 0000000..07c58d6 --- /dev/null +++ b/database/factories/ApprovalFactory.php @@ -0,0 +1,48 @@ + $this->faker->word(), + 'status' => $this->faker->randomElement(ApprovalStatus::cases()), + 'comments' => $this->faker->word(), + 'approved_at' => Carbon::now(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'company_id' => Company::factory(), + 'report_id' => Report::factory(), + 'approver_id' => User::factory(), + ]; + } + + public function approved(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ApprovalStatus::Approved, + ]); + } + + public function rejected(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ApprovalStatus::Rejected, + ]); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..b9960bf --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,26 @@ + $this->faker->name(), + 'description' => $this->faker->text(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'company_id' => Company::factory(), + ]; + } +} diff --git a/database/factories/CompanyFactory.php b/database/factories/CompanyFactory.php new file mode 100644 index 0000000..bb058ed --- /dev/null +++ b/database/factories/CompanyFactory.php @@ -0,0 +1,24 @@ + $this->faker->name(), + 'slug' => $this->faker->slug(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/factories/DepartmentFactory.php b/database/factories/DepartmentFactory.php new file mode 100644 index 0000000..bb800e1 --- /dev/null +++ b/database/factories/DepartmentFactory.php @@ -0,0 +1,29 @@ + $this->faker->name(), + 'budget' => $this->faker->word(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'company_id' => Company::factory(), + 'manager_id' => User::factory(), + ]; + } +} diff --git a/database/factories/ExpenseFactory.php b/database/factories/ExpenseFactory.php new file mode 100644 index 0000000..e94939c --- /dev/null +++ b/database/factories/ExpenseFactory.php @@ -0,0 +1,34 @@ + $this->faker->word(), + 'date' => Carbon::now(), + 'description' => $this->faker->text(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'company_id' => Company::factory(), + 'user_id' => User::factory(), + 'report_id' => Report::factory(), + 'category_id' => Category::factory(), + ]; + } +} diff --git a/database/factories/ReimbursementFactory.php b/database/factories/ReimbursementFactory.php new file mode 100644 index 0000000..7d693c8 --- /dev/null +++ b/database/factories/ReimbursementFactory.php @@ -0,0 +1,61 @@ + $this->faker->randomFloat(), + 'status' => $this->faker->randomElement(ReimbursementStatus::cases()), + 'payment_method' => $this->faker->word(), + 'payment_date' => Carbon::now(), + 'reference' => $this->faker->word(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'company_id' => Company::factory(), + 'report_id' => Report::factory(), + ]; + } + + public function created(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReimbursementStatus::Created, + ]); + } + + public function approved(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReimbursementStatus::Approved, + ]); + } + + public function rejected(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReimbursementStatus::Rejected, + ]); + } + + public function refunded(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReimbursementStatus::Refunded, + ]); + } +} diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php new file mode 100644 index 0000000..e93007f --- /dev/null +++ b/database/factories/ReportFactory.php @@ -0,0 +1,78 @@ + $this->faker->word(), + 'description' => $this->faker->text(), + 'status' => $this->faker->randomElement(ReportStatus::cases()), + 'submitted_at' => Carbon::now(), + 'total' => $this->faker->numberBetween(10, 5000), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'company_id' => Company::factory(), + 'user_id' => User::factory(), + ]; + } + + public function configure(): static + { + return $this->afterCreating(function (Report $report): void { + if ($report->expenses->count() > 0) { + $total = $report->expenses()->sum('amount'); + $report->update(['total' => $total]); + } + }); + } + + public function approved(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReportStatus::Approved, + ]); + } + + public function rejected(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReportStatus::Rejected, + ]); + } + + public function reimbursed(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReportStatus::Reimbursed, + ]); + } + + public function draft(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReportStatus::Draft, + ]); + } + + public function submitted(): self + { + return $this->state(fn (array $attributes): array => [ + 'status' => ReportStatus::Submitted, + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 67d7050..4c9435d 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Models\Company; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; @@ -34,6 +35,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => self::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'company_id' => Company::factory(), ]; } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 0603e73..239fdc8 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Models\Company; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; @@ -14,9 +15,10 @@ public function up(): void { Schema::create('users', function (Blueprint $table): void { - $table->uuid('id'); + $table->id(); $table->string('name'); $table->string('email')->unique(); + $table->foreignIdFor(Company::class)->nullable(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); diff --git a/database/migrations/2025_08_04_173220_create_companies_table.php b/database/migrations/2025_08_04_173220_create_companies_table.php new file mode 100644 index 0000000..b910c2f --- /dev/null +++ b/database/migrations/2025_08_04_173220_create_companies_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('companies'); + } +}; diff --git a/database/migrations/2025_08_04_194150_create_departments_table.php b/database/migrations/2025_08_04_194150_create_departments_table.php new file mode 100644 index 0000000..aa0f25e --- /dev/null +++ b/database/migrations/2025_08_04_194150_create_departments_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->decimal('budget', 12, 2); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->foreignIdFor(User::class, 'manager_id')->constrained('users'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('departments'); + } +}; diff --git a/database/migrations/2025_08_04_195836_create_department_user_table.php b/database/migrations/2025_08_04_195836_create_department_user_table.php new file mode 100644 index 0000000..eb8e842 --- /dev/null +++ b/database/migrations/2025_08_04_195836_create_department_user_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(Department::class, 'department_id')->constrained('departments'); + $table->foreignIdFor(User::class, 'user_id')->constrained('users'); + $table->timestamps(); + $table->unique(['department_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('department_user'); + } +}; diff --git a/database/migrations/2025_08_05_130733_create_reports_table.php b/database/migrations/2025_08_05_130733_create_reports_table.php new file mode 100644 index 0000000..b1b658c --- /dev/null +++ b/database/migrations/2025_08_05_130733_create_reports_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('title'); + $table->string('description'); + $table->string('status')->comment("'draft, submitted, approved, rejected, reimbursed'"); + $table->decimal('total', 12, 2); + $table->dateTime('submitted_at')->nullable(); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->foreignIdFor(User::class)->constrained('users'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reports'); + } +}; diff --git a/database/migrations/2025_08_05_162522_create_categories_table.php b/database/migrations/2025_08_05_162522_create_categories_table.php new file mode 100644 index 0000000..7e6444e --- /dev/null +++ b/database/migrations/2025_08_05_162522_create_categories_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('name'); + $table->string('description'); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/migrations/2025_08_05_184944_create_expenses_table.php b/database/migrations/2025_08_05_184944_create_expenses_table.php new file mode 100644 index 0000000..01e8655 --- /dev/null +++ b/database/migrations/2025_08_05_184944_create_expenses_table.php @@ -0,0 +1,34 @@ +id(); + $table->decimal('amount', 12, 2); + $table->dateTime('date'); + $table->string('description'); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->foreignIdFor(User::class)->constrained('users'); + $table->foreignIdFor(Report::class)->constrained('reports'); + $table->foreignIdFor(Category::class)->constrained('categories'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('expenses'); + } +}; diff --git a/database/migrations/2025_08_07_173103_create_approvals_table.php b/database/migrations/2025_08_07_173103_create_approvals_table.php new file mode 100644 index 0000000..ee2c39a --- /dev/null +++ b/database/migrations/2025_08_07_173103_create_approvals_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->foreignIdFor(Report::class)->constrained('reports'); + $table->foreignIdFor(User::class, 'approver_id')->constrained('users'); + $table->string('level'); + $table->string('status'); + $table->string('comments'); + $table->dateTime('approved_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('approvals'); + } +}; diff --git a/database/migrations/2025_08_08_134802_create_notifications_table.php b/database/migrations/2025_08_08_134802_create_notifications_table.php new file mode 100644 index 0000000..eeddea0 --- /dev/null +++ b/database/migrations/2025_08_08_134802_create_notifications_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2025_08_10_010222_create_reimbursements_table.php b/database/migrations/2025_08_10_010222_create_reimbursements_table.php new file mode 100644 index 0000000..aa3c693 --- /dev/null +++ b/database/migrations/2025_08_10_010222_create_reimbursements_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignIdFor(Company::class)->constrained('companies'); + $table->foreignIdFor(Report::class)->constrained('reports'); + $table->decimal('amount', 10, 2); + $table->string('status'); + $table->string('payment_method'); + $table->date('payment_date')->nullable(); + $table->string('reference'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reimbursements'); + } +}; diff --git a/resources/views/filament/admin/resources/reports/pages/approve-report.blade.php b/resources/views/filament/admin/resources/reports/pages/approve-report.blade.php new file mode 100644 index 0000000..a1f9e55 --- /dev/null +++ b/resources/views/filament/admin/resources/reports/pages/approve-report.blade.php @@ -0,0 +1,42 @@ + + + + +
+ @foreach($this->record->expenses as $expense) + +

Data: {{ $expense->date->format('d/m/y') }}

+

Valor: {{ $expense->amount }}

+

Descrição: {{ $expense->description }}

+ + + @if ($expense->hasMedia('receipt')) +
+ @foreach ($expense->getMedia('receipt') as $image) + Recibo + @endforeach +
+ @endif +
+ @endforeach +
+ +
+
+

{{ $this->form }}

+ + + Submit + +
+
+
+company = Company::factory()->create(); + $this->userReporter = User::factory() + ->has(Report::factory()) + ->createOne(); + $this->userReporter->company()->associate($this->company); + $this->userApprover = User::factory()->createOne(); + $this->userApprover->company()->associate($this->company); + $this->report = Report::factory()->for($this->userReporter)->submitted()->create(); + $this->company->reports()->save($this->report); + Expense::factory()->count(2)->for($this->report)->create(); + + actingAs($this->userApprover); +}); + +it('loads the information at the ApproveReportPage', function (): void { + + $component = livewire(ApproveReport::class, ['record' => $this->report->getKey()]) + ->assertOk(); + + $this->report->expenses()->each(function (Expense $expense) use ($component): void { + $component->assertSee($expense->date->format('d/m/y')); + $component->assertSee($expense->amount); + $component->assertSee($expense->description); + }); +}); + +test('should be able to approve or reject a report', function ($value): void { + livewire(ApproveReport::class, ['record' => $this->report->getKey()]) + ->assertOk() + ->set([ + 'data' => [ + 'status' => $value, + 'comments' => 'we cant pay for this', + ], + ]) + ->call('save') + ->assertHasNoFormErrors(); + + assertDatabaseHas(Approval::class, [ + 'status' => $value, + 'comments' => 'we cant pay for this', + 'company_id' => $this->report->company->getKey(), + 'report_id' => $this->report->getKey(), + 'approver_id' => $this->userApprover->getKey(), + ]); +})->with([ + ApprovalStatus::Rejected, + ApprovalStatus::Approved, +]); + +it('should notify user that approval status was changed', function ($value): void { + livewire(ApproveReport::class, ['record' => $this->report->getKey()]) + ->assertOk() + ->set([ + 'data' => [ + 'status' => $value, + 'comments' => 'we cant pay for this', + ], + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $notification = $this->userReporter->notifications()->latest()->first(); + assertStringContainsString( + sprintf('Your report %s status was ', $this->report->id).$value->value, + $notification->data['title'] + ); +})->with([ + ApprovalStatus::Rejected, + ApprovalStatus::Approved, +]); + +test('should update report status after creating the approval', function ($approvalStatus, $reportStatus): void { + + livewire(ApproveReport::class, ['record' => $this->report->getKey()]) + ->assertOk() + ->set([ + 'data' => [ + 'status' => $approvalStatus, + 'comments' => 'we cant pay for this', + ], + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->report->refresh(); + + expect($this->report->status)->toBe($reportStatus); + +})->with([ + [ApprovalStatus::Approved, ReportStatus::Approved], + [ApprovalStatus::Rejected, ReportStatus::Rejected], +]); diff --git a/tests/Feature/Filament/Resources/Approval/CreateApprovalTest.php b/tests/Feature/Filament/Resources/Approval/CreateApprovalTest.php new file mode 100644 index 0000000..3b2fb50 --- /dev/null +++ b/tests/Feature/Filament/Resources/Approval/CreateApprovalTest.php @@ -0,0 +1,73 @@ +company = Company::factory()->create(); + $this->userReporter = User::factory() + ->has(Report::factory()->submitted()) + ->createOne(); + + $this->userReporter->company()->associate($this->company); + $this->userApprover = User::factory()->createOne(); + $this->userApprover->company()->associate($this->company); + $this->company->reports()->save($this->userReporter->reports()->first()); + + actingAs($this->userApprover); +}); + +it('should be able to approve a Report', function (): void { + livewire(CreateApproval::class) + ->fillForm([ + 'company_id' => $this->company->getKey(), + 'report_id' => $this->userReporter->reports->first()->getKey(), + 'level' => 'level-3', + 'status' => ApprovalStatus::Approved, + 'comments' => 'ok homie', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + assertDatabaseCount(Approval::class, 1); + assertDatabaseHas(Approval::class, [ + 'company_id' => $this->company->getKey(), + 'report_id' => $this->userReporter->reports->first()->getKey(), + 'level' => 'level-3', + 'approver_id' => $this->userApprover->getKey(), + 'status' => ApprovalStatus::Approved, + 'comments' => 'ok homie', + ]); +}); + +test('only submitted report can be loaded ', function ($status): void { + $report = $this->userReporter->reports()->first(); + $report->update(['status' => $status]); + livewire(CreateApproval::class) + ->fillForm([ + 'company_id' => $this->company->getKey(), + 'report_id' => $report->getKey(), + 'level' => 'level-3', + 'status' => ApprovalStatus::Approved, + 'comments' => 'ok homie', + ]) + ->call('create') + ->assertHasFormErrors(['report_id']); +})->with([ + ReportStatus::Approved, + ReportStatus::Rejected, + ReportStatus::Draft, + ReportStatus::Reimbursed, +]); diff --git a/tests/Feature/Filament/Resources/Approval/ListApprovalTest.php b/tests/Feature/Filament/Resources/Approval/ListApprovalTest.php new file mode 100644 index 0000000..c6f7d6a --- /dev/null +++ b/tests/Feature/Filament/Resources/Approval/ListApprovalTest.php @@ -0,0 +1,60 @@ +company = Company::factory()->has(User::factory()->has(Report::factory()->submitted()))->create(); + $this->approvals = Approval::factory()->count(10)->create(); + $this->approvals->each(fn ($approval) => $approval->company()->associate($this->company)->save()); + + $this->user = $this->company->users()->first(); + actingAs($this->user); + }); + + test('status filter', function ($status): void { + $this->approvals->each(fn (Approval $approval) => $approval->update(['status' => $status])); + + livewire(ListApprovals::class) + ->assertOk() + ->assertTableFilterExists('status') + ->filterTable('status', $status) + ->assertCanSeeTableRecords($this->approvals); + + })->with([ + ApprovalStatus::cases(), + ]); + + test('company filter', function (): void { + $anotherApprovals = Approval::factory()->count(10)->create(); + livewire(ListApprovals::class) + ->assertOk() + ->assertTableFilterExists('company') + ->filterTable('company', $this->company) + ->assertCanSeeTableRecords($this->approvals) + ->assertCanNotSeeTableRecords($anotherApprovals); + }); + + test('approver filter', function (): void { + $anotherApprovals = Approval::factory()->count(10)->for(User::factory(), 'approver')->create(); + $approver = $anotherApprovals->first()->approver; + + livewire(ListApprovals::class) + ->assertOk() + ->assertTableFilterExists('approver') + ->filterTable('approver', $approver) + ->assertCanSeeTableRecords($anotherApprovals) + ->assertCanNotSeeTableRecords($this->approvals); + }); +}); diff --git a/tests/Feature/Filament/Resources/Company/CreateCompanyTest.php b/tests/Feature/Filament/Resources/Company/CreateCompanyTest.php new file mode 100644 index 0000000..7451b9f --- /dev/null +++ b/tests/Feature/Filament/Resources/Company/CreateCompanyTest.php @@ -0,0 +1,35 @@ +create()); + + $name = 'name without slug'; + livewire(CreateCompany::class) + ->assertOk() + ->fillForm([ + 'name' => $name, + ]) + ->assertSchemaStateSet([ + 'slug' => Str::slug($name), + ]) + ->call('create') + ->assertHasNoFormErrors(); + + assertDatabaseCount(Company::class, 2); + assertDatabaseHas(Company::class, [ + 'name' => $name, + 'slug' => Str::slug($name), + ]); +}); diff --git a/tests/Feature/Filament/Resources/Department/CreateDepartment.php b/tests/Feature/Filament/Resources/Department/CreateDepartment.php new file mode 100644 index 0000000..9ad8906 --- /dev/null +++ b/tests/Feature/Filament/Resources/Department/CreateDepartment.php @@ -0,0 +1,60 @@ +company = Company::factory()->has(User::factory()->count(10))->create(); + $this->employees = User::all(); + $this->manager = User::factory()->create(); + $this->company->users()->save($this->manager); + + actingAs($this->manager); +}); + +it('should be able to create a department', function (): void { + + livewire(CreateDepartment::class) + ->fillForm([ + 'budget' => 5000, + 'name' => 'Department name', + 'slug' => 'department-name', + 'manager_id' => $this->manager->id, + 'company_id' => $this->company->id, + 'users' => $this->employees->pluck('id')->toArray(), + ]) + ->call('create') + ->assertHasNoErrors(); + + $department = Department::query()->first(); + expect($department->manager->getKey()) + ->toBe($this->manager->getKey()); +}); + +test('department manager should be added to users relationship after creating a department', function (): void { + livewire(CreateDepartment::class) + ->fillForm([ + 'budget' => 5000, + 'name' => 'Department name', + 'slug' => 'department-name', + 'manager_id' => $this->manager->id, + 'company_id' => $this->company->id, + 'users' => $this->employees->pluck('id')->toArray(), + ]) + ->call('create') + ->assertHasNoErrors(); + + $department = Department::query()->first(); + expect($department->manager->getKey()) + ->toBe($this->manager->getKey()) + ->and($department->users()->count()) + ->toBe($this->employees->count() + 1) + ->and($department->users()->where('users.id', $this->manager->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Filament/Resources/Expense/CreateExpenseTest.php b/tests/Feature/Filament/Resources/Expense/CreateExpenseTest.php new file mode 100644 index 0000000..923716e --- /dev/null +++ b/tests/Feature/Filament/Resources/Expense/CreateExpenseTest.php @@ -0,0 +1,115 @@ +user = User::factory()->createOne(); + $this->company = Company::factory()->createOne(); + $this->user->company()->associate($this->company); + $this->company->users()->save($this->user); + $this->category = Category::factory()->for($this->company)->createOne(); + $this->report = Report::factory() + ->for($this->company) + ->for($this->user) + ->submitted() + ->createOne(); + $this->company->reports()->save($this->report); + + actingAs($this->user); +}); + +it('should be able create to create an expense', function (): void { + Storage::fake('public'); + $image = UploadedFile::fake()->image('image.jpg'); + livewire(CreateExpense::class) + ->assertOk() + ->fillForm([ + 'amount' => 10000, + 'date' => now(), + 'description' => 'description for expense', + 'receipt' => $image, + 'company_id' => $this->company->getKey(), + 'category_id' => $this->category->getKey(), + 'report_id' => $this->report->getKey(), + 'user_id' => $this->user->getKey(), + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect($this->user->expenses()->count())->toBeOne(1); +}); + +test('report status must be submitted to create an expense', function (): void { + Storage::fake('public'); + $image = UploadedFile::fake()->image('image.jpg'); + $this->report->update(['status' => ReportStatus::Draft]); + livewire(CreateExpense::class) + ->assertOk() + ->fillForm([ + 'amount' => 10000, + 'date' => now(), + 'description' => 'description for expense', + 'receipt_path' => $image, + 'company_id' => $this->company->getKey(), + 'category_id' => $this->category->getKey(), + 'report_id' => $this->report->getKey(), + 'user_id' => $this->user->getKey(), + ]) + ->call('create') + ->assertHasFormErrors(['report_id']); +}); + +describe('validation::tests', function (): void { + + test('description::validation', function ($value, $rule): void { + livewire(CreateExpense::class) + ->assertOk() + ->fillForm([ + 'description' => $value, + ]) + ->call('create') + ->assertHasFormErrors(['description' => $rule]); + })->with([ + 'required' => ['', 'The description field is required.'], + 'max:255' => [str_repeat('a', 256), 'The description field must not be greater than 255 characters.'], + ]); + + test('amount::validation', function ($value, $rule): void { + + livewire(CreateExpense::class) + ->assertOk() + ->fillForm([ + 'amount' => $value, + ]) + ->call('create') + ->assertHasFormErrors(['amount' => $rule]); + })->with([ + 'required' => ['', 'The amount field is required.'], + 'numeric' => ['NaN', 'The amount field must be a number.'], + ]); + + test('date::validation', function ($value, $rule): void { + + livewire(CreateExpense::class) + ->assertOk() + ->fillForm([ + 'date' => $value, + ]) + ->call('create') + ->assertHasFormErrors(['date' => $rule]); + })->with([ + 'required' => ['', 'The date field is required.'], + ]); +}); diff --git a/tests/Feature/Filament/Resources/Expense/ListExpenseTest.php b/tests/Feature/Filament/Resources/Expense/ListExpenseTest.php new file mode 100644 index 0000000..45408de --- /dev/null +++ b/tests/Feature/Filament/Resources/Expense/ListExpenseTest.php @@ -0,0 +1,49 @@ +company = Company::factory()->has(User::factory()->has(Expense::factory()->count(10)))->create(); + $this->user = $this->company->users()->first(); + actingAs($this->user); +}); +describe('filter tests', function (): void { + + test('company filter', function (): void { + livewire(ListExpenses::class) + ->assertOk() + ->assertTableFilterExists('company') + ->filterTable('company', $this->company) + ->assertCanSeeTableRecords($this->company->expenses()->get()); + }); + + test('user filter', function (): void { + $anotherExpenses = Expense::factory()->count(10)->create(); + $this->company->expenses()->saveMany($anotherExpenses); + livewire(ListExpenses::class) + ->assertOk() + ->assertTableFilterExists('user') + ->filterTable('user', $this->user) + ->assertCanSeeTableRecords($this->user->expenses()->get()) + ->assertCanNotSeeTableRecords($anotherExpenses); + }); + + test('category filter', function (): void { + $category = Category::factory()->has(Expense::factory()->count(10)->for($this->company))->create(); + livewire(ListExpenses::class) + ->assertOk() + ->assertTableFilterExists('category') + ->filterTable('category', $category) + ->assertCanSeeTableRecords($category->expenses()->get()) + ->assertCanNotSeeTableRecords([$this->user->expenses()->get()]); + }); +}); diff --git a/tests/Feature/Filament/Resources/Reimbursement/CreateReimbursementTest.php b/tests/Feature/Filament/Resources/Reimbursement/CreateReimbursementTest.php new file mode 100644 index 0000000..46e7f42 --- /dev/null +++ b/tests/Feature/Filament/Resources/Reimbursement/CreateReimbursementTest.php @@ -0,0 +1,128 @@ +user = User::factory()->create(); + $this->company = Company::factory()->create(); + $this->user->company()->associate($this->company); + $this->report = Report::factory()->has(Expense::factory())->approved()->create(); + $this->report->user()->associate($this->user); + $this->expense = Expense::query()->first(); + $this->expense->company()->associate($this->company); + $this->expense->user()->associate($this->user); + $this->report->company()->associate($this->company); + $this->report->save(); + actingAs($this->user); +}); + +test('reimbursement status should be Created by default', function (): void { + + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $this->company->getKey(), + 'report_id' => $this->report->id, + 'amount' => (int) $this->expense->amount, + 'payment_method' => 'PayPal', + 'reference' => 'whatssup', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $reimbursement = Reimbursement::query()->first(); + expect($reimbursement->status->value) + ->toBe(ReimbursementStatus::Created->value); + +}); + +it('should load only approved reports at reimbursement form', function (): void { + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm(['company_id' => $this->company->id]) + ->assertSchemaStateSet([ + 'report_id' => $this->report->name, + ]); + + $this->report->update(['status' => ReportStatus::Rejected]); + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm(['company_id' => $this->company->id]) + ->assertSchemaStateSet([ + 'report_id' => null, + ]); +}); + +it('loads amount after report has been chosen', function (): void { + $this->expense->update(['amount' => 500]); + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $this->company->id, + 'report_id' => $this->report->id, + ]) + ->assertSchemaStateSet([ + 'amount' => $this->expense->amount, + ]); +}); + +it('load only reports that does not have an reimbursement associated', function (): void { + $reimbursement = Reimbursement::factory()->create(); + $reimbursement->report()->associate($this->report); + $anotherReport = Report::factory()->create(); + + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $this->company->id, + ]) + ->assertSchemaStateSet([ + 'report_id' => null, + ]); + + $reimbursement->report()->associate($anotherReport); + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $this->company->id, + ]) + ->assertSchemaStateSet([ + 'report_id' => $this->report->name, + ]); +}); + +test('only reports that belongs to the company can be loaded', function (): void { + $report = Report::factory()->create(); + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $report->company->getKey(), + ]) + ->assertSchemaStateSet([ + 'report_id' => $report->name, + ]); + + livewire(CreateReimbursement::class) + ->assertOk() + ->fillForm([ + 'company_id' => $this->company->id, + ]) + ->assertSchemaStateSet(function (array $state) use ($report): void { + expect($state['report_id']) + ->not->toBe($report->id); + }); + + expect($report->company->getKey())->not->toBe($this->company->id); +}); diff --git a/tests/Feature/Filament/Resources/Reimbursement/ListReimbursementTest.php b/tests/Feature/Filament/Resources/Reimbursement/ListReimbursementTest.php new file mode 100644 index 0000000..b8376ea --- /dev/null +++ b/tests/Feature/Filament/Resources/Reimbursement/ListReimbursementTest.php @@ -0,0 +1,61 @@ +company = Company::factory() + ->has(User::factory()->has(Report::factory(10)->approved())) + ->create(); + $this->user = User::query()->first(); + $this->report = $this->user->reports->first(); + $this->reimbursement = Reimbursement::factory() + ->for($this->report) + ->for($this->company) + ->approved() + ->create(); + + $this->reimbursements = Reimbursement::factory()->count(10)->create(); + +}); +describe('filter tests', function (): void { + test('status filter', function ($status): void { + $this->reimbursements->each(fn ($reimbursement) => $reimbursement->update(['status' => $status])); + + livewire(ListReimbursements::class) + ->assertOk() + ->assertTableFilterExists('status') + ->filterTable('status', $status) + ->assertCanSeeTableRecords($this->reimbursements); + + })->with([ + ReimbursementStatus::cases(), + ]); + + test('user filter', function (): void { + livewire(ListReimbursements::class) + ->assertOk() + ->assertTableFilterExists('user') + ->filterTable('user', $this->user) + ->assertCanSeeTableRecords([$this->user->reimbursements->first()]) + ->assertCanNotSeeTableRecords($this->reimbursements); + + }); + + test('company filter', function (): void { + livewire(ListReimbursements::class) + ->assertOk() + ->assertTableFilterExists('company') + ->filterTable('company', $this->company) + ->assertCanSeeTableRecords([$this->user->reimbursements->first()]) + ->assertCanNotSeeTableRecords($this->reimbursements); + }); +}); diff --git a/tests/Feature/Filament/Resources/Report/CreateReportTest.php b/tests/Feature/Filament/Resources/Report/CreateReportTest.php new file mode 100644 index 0000000..53ec7a7 --- /dev/null +++ b/tests/Feature/Filament/Resources/Report/CreateReportTest.php @@ -0,0 +1,89 @@ +user = User::factory()->createOne(); + $this->company = Company::factory()->createOne(); + $this->user->company()->associate($this->company); + actingAs($this->user); +}); + +it('should create a report', function (): void { + Repeater::fake(); + Storage::fake('public'); + $image = UploadedFile::fake()->image('image.jpg'); + $category = Category::factory()->for($this->company)->createOne(); + livewire(CreateReport::class) + ->fillForm([ + 'company_id' => $this->company->getKey(), + 'title' => 'report title', + 'user_id' => $this->user->getKey(), + 'description' => 'report description', + 'status' => ReportStatus::Draft, + 'expenses' => [ + [ + 'amount' => 150, + 'date' => now()->format('Y-m-d H:i:s'), + 'description' => 'Almoço com cliente', + 'receipt' => $image, + 'company_id' => $this->company->getKey(), + 'category_id' => $category->getKey(), + ], + ], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + assertDatabaseHas(Report::class, [ + 'title' => 'report title', + 'description' => 'report description', + 'status' => ReportStatus::Draft, + 'company_id' => $this->company->getKey(), + 'user_id' => $this->user->getKey(), + 'total' => 150, + ]); +}); + +describe('validation::tests', function (): void { + + test('title::validations', function ($value, $rule): void { + + livewire(CreateReport::class) + ->fillForm([ + 'title' => $value, + ]) + ->call('create') + ->assertHasFormErrors(['title' => $rule]); + })->with([ + 'required' => ['', 'The title field is required.'], + 'max:255' => [str_repeat('a', 256), 'The title field must not be greater than 255 characters.'], + ]); + + test('description::validations', function ($value, $rule): void { + + livewire(CreateReport::class) + ->fillForm([ + 'description' => $value, + ]) + ->call('create') + ->assertHasFormErrors(['description' => $rule]); + })->with([ + 'required' => ['', 'The description field is required.'], + 'max:255' => [str_repeat('a', 256), 'The description field must not be greater than 255 characters.'], + ]); +}); diff --git a/tests/Feature/Filament/Resources/Report/ListReportTest.php b/tests/Feature/Filament/Resources/Report/ListReportTest.php new file mode 100644 index 0000000..287a695 --- /dev/null +++ b/tests/Feature/Filament/Resources/Report/ListReportTest.php @@ -0,0 +1,89 @@ +user = User::factory()->createOne(); + $company = Company::factory()->createOne(); + $this->user->company()->associate($company); + + $this->reports = Report::factory()->count(5) + ->for($this->user) + ->for($company) + ->create(); + + actingAs($this->user); +}); + +it('should list all reports', function (): void { + livewire(ListReports::class) + ->assertOk() + ->assertCanSeeTableRecords($this->reports) + ->assertCountTableRecords($this->reports->count()) + ->assertCanRenderTableColumn('title') + ->assertCanRenderTableColumn('description') + ->assertCanRenderTableColumn('status') + ->assertCanRenderTableColumn('submitted_at') + ->assertCanRenderTableColumn('company.name') + ->assertCanRenderTableColumn('user.name'); +}); + +it('should see the approve action on report list', function (): void { + $report = Report::factory()->createOne(); + livewire(ListReports::class) + ->assertOk() + ->assertSee('Approve') + ->assertActionVisible(TestAction::make('Approve')->table($report)) + ->assertActionExists(TestAction::make('Approve')->table($report)); +}); + +it('should redirect to ApproveReport Page when call the approve action', function (): void { + $report = Report::factory()->createOne(); + livewire(ListReports::class) + ->assertOk() + ->callAction(TestAction::make('Approve')->table($report)) + ->assertActionHasUrl(TestAction::make('Approve')->table($report), + route('filament.admin.resources.reports.approve-report', $report)); +}); + +describe('table filters tests', function (): void { + + test('company filter', function (): void { + livewire(ListReports::class) + ->assertOk() + ->assertTableFilterExists('company') + ->filterTable('company', $this->user->company) + ->assertCanSeeTableRecords($this->reports) + ->assertCanNotSeeTableRecords(Report::factory()->count(5)->create()); + }); + + test('user filter', function (): void { + livewire(ListReports::class) + ->assertOk() + ->assertTableFilterExists('user') + ->filterTable('user', $this->user) + ->assertCanSeeTableRecords($this->user->reports()->get()) + ->assertCanNotSeeTableRecords(Report::factory()->count(5)->create()); + }); + + test('status filter', function ($status): void { + $this->user->reports()->update(['status' => $status]); + livewire(ListReports::class) + ->assertOk() + ->assertTableFilterExists('status') + ->filterTable('status', $status) + ->assertCanSeeTableRecords($this->user->reports()->get()); + })->with([ + ReportStatus::cases(), + ]); +}); diff --git a/tests/Unit/Shared/Schema/Form/NameInputTest.php b/tests/Unit/Shared/Schema/Form/NameInputTest.php new file mode 100644 index 0000000..37b9b05 --- /dev/null +++ b/tests/Unit/Shared/Schema/Form/NameInputTest.php @@ -0,0 +1,25 @@ +nameInput = new NameInput('name'); + $reflection = new ReflectionClass(NameInput::class); + $reflection = $reflection->getMethod('setUp'); + $reflection->setAccessible(true); + $reflection->invoke($this->nameInput); +}); + +test('name input is required', function (): void { + expect($this->nameInput->isRequired())->toBeTrue(); +}); + +test('name input has max length 255 at default', function (): void { + expect($this->nameInput->getMaxLength())->toBe(255); +}); + +test('default placeholder', function (): void { + expect($this->nameInput->getplaceholder())->toBe('Enter your name'); +});