Courses

Creating CRM with Filament 3: Step-By-Step

Customer Tasks and Calendar View

Summary of this lesson:
- Creating task model with customer relationships
- Implementing task creation from customer list
- Adding task calendar using FullCalendar plugin
- Building task management interface with status tracking

Now that we have Employees - they usually have to perform specific Tasks with our Customers. For example, they might need to make a phone call to them or send over some documents. For that, we can build a Task system with a calendar view like this:

In this lesson, we will do the following:

  • Create Task Model and Database
  • Add the Create Task button to the Customer list
  • Add Task list to the Customer page (view page)
  • Add Task Resource with Tabs
  • Add a Calendar page for Tasks

Create Task Model and Database

Let's start with our Models and Database structure:

Migration

use App\Models\Customer;
use App\Models\User;
 
// ...
 
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Customer::class)->constrained();
$table->foreignIdFor(User::class)->nullable()->constrained();
$table->text('description');
$table->date('due_date')->nullable();
$table->boolean('is_completed')->default(false);
$table->timestamps();
});

Then, we can fill our Model:

app/Models/Task.php

use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
class Task extends Model
{
protected $fillable = [
'customer_id',
'user_id',
'description',
'due_date',
'is_completed',
];
 
protected $casts = [
'due_date' => 'date',
'is_completed' => 'boolean',
];
 
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
 
public function employee(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

That's it. We have our base structure for the Task Model and Database.


Add Create Task Button to the Customer list

Next, we want to add a button to create a new task for each of our customers:

app/Filament/Resources/CustomerResource.php

// ...
 
return $table
// ...
->actions([
// ...
Tables\Actions\Action::make('Add Task')
->icon('heroicon-s-clipboard-document')
->form([
Forms\Components\RichEditor::make('description')
->required(),
Forms\Components\Select::make('user_id')
->preload()
->searchable()
->relationship('employee', 'name'),
Forms\Components\DatePicker::make('due_date')
->native(false),
 
])
->action(function (Customer $customer, array $data) {
$customer->tasks()->create($data);
 
Notification::make()
->title('Task created successfully')
->success()
->send();
})
])
 
// ...

Before we load the page, we need to add a relationship to our Customer Model:

app/Models/Customer.php

// ...
 
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
 
// ...

Now we can load the page and see these buttons:

Clicking on them will open a modal with a form to create a new Task:


Add Task List to the Customer Page

Now that we can create tasks, we cannot know what tasks are assigned to the Customer. Let's solve that by adding it to our Customer View page:

app/Filament/Resources/CustomerResource.php

use Filament\Infolists\Components\Tabs;
use Filament\Infolists\Components\Actions\Action;
 
// ...
 
return $infolist
->schema([
// ...
Section::make('Pipeline Stage History and Notes')
->schema([
ViewEntry::make('pipelineStageLogs')
->label('')
->view('infolists.components.pipeline-stage-history-list')
])
->collapsible()
->collapsible(),
Tabs::make('Tasks')
->tabs([
Tabs\Tab::make('Completed')
->badge(fn($record) => $record->completedTasks->count())
->schema([
RepeatableEntry::make('completedTasks')
->hiddenLabel()
->schema([
TextEntry::make('description')
->html()
->columnSpanFull(),
TextEntry::make('employee.name')
->hidden(fn($state) => is_null($state)),
TextEntry::make('due_date')
->hidden(fn($state) => is_null($state))
->date(),
])
->columns()
]),
Tabs\Tab::make('Incomplete')
->badge(fn($record) => $record->incompleteTasks->count())
->schema([
RepeatableEntry::make('incompleteTasks')
->hiddenLabel()
->schema([
TextEntry::make('description')
->html()
->columnSpanFull(),
TextEntry::make('employee.name')
->hidden(fn($state) => is_null($state)),
TextEntry::make('due_date')
->hidden(fn($state) => is_null($state))
->date(),
TextEntry::make('is_completed')
->formatStateUsing(function ($state) {
return $state ? 'Yes' : 'No';
})
->suffixAction(
Action::make('complete')
->button()
->requiresConfirmation()
->modalHeading('Mark task as completed?')
->modalDescription('Are you sure you want to mark this task as completed?')
->action(function (Task $record) {
$record->is_completed = true;
$record->save();
 
Notification::make()
->title('Task marked as completed')
->success()
->send();
})
),
])
->columns(3)
])
])
->columnSpanFull(),
]);
 
// ...

With this, we expect two new relationships in our Customer Model:

app/Models/Customer.php

// ...
 
public function completedTasks(): HasMany
{
return $this->hasMany(Task::class)->where('is_completed', true);
}
 
public function incompleteTasks(): HasMany
{
return $this->hasMany(Task::class)->where('is_completed', false);
}
 
// ...

These relationships will load specific information for displaying our RepeatableEntry fields. Treat them as a way to filter the data.

Now, loading our Customer view - we can see the Tasks section:

In our incomplete tab - we can see the button to mark the task as completed:


Add Task Resource with Tabs

Creating tasks from the Customer page is nice - we should have a separate page for that. Let's create a new Resource for that:

php artisan make:filament-resource Task --generate

By default, Filament guessed the fields, but it's not entirely accurate:

Even our Create form is not quite right:

Let's fix both of these to be up to our standards. First, we will fix the table to show the correct information:

Note: We have replaced the whole table

app/Filament/Resources/TaskResource.php

use Filament\Notifications\Notification;
 
// ...
 
return $table
->columns([
Tables\Columns\TextColumn::make('customer.first_name')
->formatStateUsing(function ($record) {
return $record->customer->first_name . ' ' . $record->customer->last_name;
})
->searchable(['first_name', 'last_name'])
->sortable(),
Tables\Columns\TextColumn::make('employee.name')
->label('Employee')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->html(),
Tables\Columns\TextColumn::make('due_date')
->date()
->sortable(),
Tables\Columns\IconColumn::make('is_completed')
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('Complete')
->hidden(fn(Task $record) => $record->is_completed)
->icon('heroicon-m-check-badge')
->modalHeading('Mark task as completed?')
->modalDescription('Are you sure you want to mark this task as completed?')
->action(function (Task $record) {
$record->is_completed = true;
$record->save();
 
Notification::make()
->title('Task marked as completed')
->success()
->send();
})
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->defaultSort(function ($query) {
return $query->orderBy('due_date', 'asc')
->orderBy('id', 'desc');
});
 
// ...

This fixed a couple of things:

  • Employee name display
  • Customer name display
  • Added default sort by due date and id
  • Added Complete action to the table

Here's what this looks like now:

Next, we need to fix our form:

Note: Once again, we have replaced the whole form

app/Filament/Resources/TaskResource.php

use App\Models\Customer;
 
// ...
 
return $form
->schema([
Forms\Components\Select::make('customer_id')
->searchable()
->relationship('customer')
->getOptionLabelFromRecordUsing(fn(Customer $record) => $record->first_name . ' ' . $record->last_name)
->searchable(['first_name', 'last_name'])
->required(),
Forms\Components\Select::make('user_id')
->preload()
->searchable()
->relationship('employee', 'name'),
Forms\Components\RichEditor::make('description')
->required()
->maxLength(65535)
->columnSpanFull(),
Forms\Components\DatePicker::make('due_date'),
Forms\Components\Toggle::make('is_completed')
->required(),
]);
 
// ...

This fixes the following:

  • Customer select field is now searchable by first/last names and displays the full name
  • Employee select field is now searchable by name
  • Description is now a RichEditor

Here's what this looks like now:


Adding Tabs to the Task Resource

Now that our table is fixed, we can add a couple of tabs to our Task Resource:

  • My Tasks - filter for employees to only see their tasks
  • All Tasks - displays all tasks in the system
  • Completed Tasks - displays only completed tasks
  • Incomplete Tasks - displays only incomplete tasks

app/Filament/Resources/TaskResource/Pages/ListTasks.php

use App\Models\Task;
use Filament\Resources\Components\Tab;
 
// ...
 
public function getTabs(): array
{
$tabs = [];
 
if (!auth()->user()->isAdmin()) {
$tabs[] = Tab::make('My Tasks')
->badge(Task::where('user_id', auth()->id())->count())
->modifyQueryUsing(function ($query) {
return $query->where('user_id', auth()->id());
});
}
 
$tabs[] = Tab::make('All Tasks')
->badge(Task::count());
 
$tabs[] = Tab::make('Completed Tasks')
->badge(Task::where('is_completed', true)->count())
->modifyQueryUsing(function ($query) {
return $query->where('is_completed', true);
});
 
$tabs[] = Tab::make('Incomplete Tasks')
->badge(Task::where('is_completed', false)->count())
->modifyQueryUsing(function ($query) {
return $query->where('is_completed', false);
});
 
return $tabs;
}

Here's what this looks like for admins:

And for employees:


Add a Calendar Page for Tasks

Last on our list is a custom page using Filament FullCalendar plugin. Let's install the plugin via composer:

composer require saade/filament-fullcalendar:^3.0

Then we need to register it in our AdminPanelProvider:

app/Filament/Providers/AdminPanelProvider.php

use Saade\FilamentFullCalendar\FilamentFullCalendarPlugin;
 
// ...
 
return $panel
// ...
->authMiddleware([
Authenticate::class,
])
->plugins([
FilamentFullCalendarPlugin::make()
]);
 
// ...

Once that is done, we can create a livewire page:

php artisan make:filament-page TaskCalendar

This should create the file app/Filament/Pages/TaskCalendar.php. Next, we need to create a widget for it:

php artisan make:filament-widget TaskCalendar

Make sure that your settings are correct:

Note: We are aiming to create a livewire widget here!

Once that is done, you should have a new file app/Livewire/TaskCalendarWidget.php, which means we can add the widget to our page:

resources/views/filament/pages/task-calendar.blade.php

<x-filament-panels::page>
@livewire(App\Livewire\TaskCalendarWidget::class)
</x-filament-panels::page>

Now, all we have to do - is modify our widget itself:

app/Livewire/TaskCalendarWidget.php

use Filament\Widgets\Widget;
use App\Filament\Resources\TaskResource;
use App\Models\Task;
use Saade\FilamentFullCalendar\Data\EventData;
use Saade\FilamentFullCalendar\Widgets\FullCalendarWidget;
 
class TaskCalendarWidget extends Widget
class TaskCalendarWidget extends FullCalendarWidget
{
protected static string $view = 'livewire.task-calendar-widget';
 
public function fetchEvents(array $fetchInfo): array
{
return Task::query()
->where('due_date', '>=', $fetchInfo['start'])
->where('due_date', '<=', $fetchInfo['end'])
->when(!auth()->user()->isAdmin(), function ($query) {
return $query->where('user_id', auth()->id());
})
->get()
->map(
fn(Task $task) => EventData::make()
->id($task->id)
->title(strip_tags($task->description))
->start($task->due_date)
->end($task->due_date)
->url(TaskResource::getUrl('edit', [$task->id]))
->toArray()
)
->toArray();
}
}

Since we removed the $view from our widget, we can delete this file resources/views/livewire/task-calendar-widget.blade.php

Now we can go to our page and see the calendar:

That's it. We have a working Task system now with a calendar view. If you wish to modify what the calendar does, you can read the plugin documentation.

Previous: Employee User Invitations Process
avatar

Question how do we go about changing the time for the task the only thing coming up is 12a in front of every task?

avatar

If you need the time - you need to have dateTime column on your tasks and not just date. That is indeed a limitation on the calendar integration - it sets 12am as default date if none is provided.

avatar

If you add this: ->allDay(true)

to the EventData::make() you will render them without time, so it won't display 12a or 00 in front of the events.

avatar
You can use Markdown
avatar

Helo, thanks for wonderful course. I hit a problem I am not able to solve. In CustomerResource Infolist on Tabs with Tasks there is ->suffixAction at is_completed TextEntry. If I use the action with the closure: ->action(function (Task $record) { $record->is_completed = true; $record->save(); i got an axception, that the model Customer instead of model Task was provided to the closure. It seems it injects the $record of the main record of the infolist = Customer and not the actual record of the repeatable entry.

avatar
You can use Markdown
avatar

Hi guys,

I just found out that something strange happens with table action form.

When we select employee in the form, somehow it automatically updates the $customer employee_id to selected user_id...

->action(function (Customer $customer, array $data) { dd($customer, Customer::find($customer->id)); })

It is happening when we use ->relationship('employee', 'name'), to fix it we can change it to ->options(User::all()->pluck('name', 'id')) but it is strange it is doing that in the background.

avatar

Hi, I'm not sure I fully understood the issue you are facing here.

Does this happen on an action when you have more than one row selected? What about just a single row selected, is it working as expected?

avatar

It happens on this form:

Tables\Actions\Action::make('Add Task') ->icon('heroicon-s-clipboard-document') ->form([ Forms\Components\RichEditor::make('description') ->required(), Forms\Components\Select::make('user_id') ->preload() ->searchable() ->relationship('employee', 'name'), Forms\Components\DatePicker::make('due_date') ->native(false),

        ])
        ->action(function (Customer $customer, array $data) {
            $customer->tasks()->create($data);

            Notification::make()
                ->title('Task created successfully')
                ->success()
                ->send();
        })

Its per record

avatar

Hmm, I have not experienced this. Could you dump the $data using dd($data) and see what it returns? As it shouldn't set the employee id, unless you have an employee ID on the form...

Or maybe something happens if you have more than 1 record selected and try to create a task - not sure

avatar

Its not problem in the data. Forms\Components\Select::make('user_id') ->preload() ->searchable() ->relationship('employee', 'name')

This like auto-populates employee_id when it is using relationship, if you dd($customer) you can see it changes it when the select changes..

avatar

This is weird. I don't see such behaviour, and I don't remember it being like this...

avatar
You can use Markdown
avatar

I new and learing. If I use UTC time on the app and have a timezone field for my user, how can I have the text column adjust from UTC to customer's time zone? This is probably easy to do but i've searched and couldn't find anything specific to filament. Right now I have a text column that shows the timestamp from my database, I really need to recalucate the proper time before displaying. Any help will be greatly appreciated. Thank you.

avatar

Hi, then this course would help you:

//course/laravel-user-timezones?mtm_campaign=search-results-course

avatar
You can use Markdown
avatar

Thank you very much. Please when i add this to CustomerResource below i'm encountering an error.

Tables\Actions\Action::make('Add Task') ->icon('heroicon-s-clipboard-document') ->form([ Forms\Components\RichEditor::make('description') ->required(), Forms\Components\Select::make('user_id') ->preload() ->searchable() ->relationship('employee', 'name'), Forms\Components\DatePicker::make('due_date') ->native(false),

        ])
        ->action(function (Customer $customer, array $data) {
            $customer->tasks()->create($data);

            Notification::make()
                ->title('Task created successfully')
                ->success()
                ->send();
        })
avatar

What is the error?

avatar

I've fixed that. thank you.

avatar
You can use Markdown
avatar

O lecture fifteen. Im havig difficultyu in creating a Calendar widget. It shows

Unable to find component: [App\Livewire\TaskCalendarWidget]

after following your procedures. Thanks for quick response.

avatar

It seems that you don't have the widget class declared correctly. Please check the file that's below the blade code snippet

avatar

Resolved. Thank you so much.

avatar
You can use Markdown
avatar
You can use Markdown