Courses

Creating CRM with Filament 3: Step-By-Step

Customers in a Draggable Kanban Board

Summary of this lesson:
- Creating custom Filament page for board view
- Implementing drag-and-drop functionality for customers
- Adding automatic pipeline stage history logging
- Building Kanban-style board with status columns

It is common to manage Customers in bulk or have an overview of where the Customer is progressing. For that, we can create a board like this:

In this lesson, we will do the following:

  • Create a Custom Page
  • Add a "kanban" style board to it
  • Allow the user to move customers between Pipeline Stages

Creating Custom Page - Our Customer Board

We will create a Custom Page using the Filament command:

php artisan make:filament-page ManageCustomerStages

This should create two files:

  • app/Filament/Pages/ManageCustomerStages.php - the page class
  • resources/views/filament/pages/manage-customer-stages.blade.php - the page view

We will begin with modifications to our page class:

app/Filament/Pages/ManageCustomerStages.php

 
use App\Models\Customer;
use App\Models\PipelineStage;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
 
class ManageCustomerStages extends Page
{
protected static string $view = 'filament.pages.manage-customer-stages';
 
// Our Custom heading to be displayed on the page
protected ?string $heading = 'Customer Board';
// Custom Navigation Link name
protected static ?string $navigationLabel = 'Customer Board';
// Adding a Heroicon to the Navigation Link
protected static ?string $navigationIcon = 'heroicon-s-queue-list';
 
// We will be listening for the `statusChangeEvent` event to update the record status
#[On('statusChangeEvent')]
public function changeRecordStatus($id, $pipeline_stage_id): void
{
// Find the customer and update the pipeline_stage_id
$customer = Customer::find($id);
$customer->pipeline_stage_id = $pipeline_stage_id;
$customer->save();
 
// Don't forget to write the log
$customer->pipelineStageLogs()->create([
'pipeline_stage_id' => $pipeline_stage_id,
'notes' => null,
'user_id' => auth()->id()
]);
 
// Inform the user that the status has been updated
$customerName = $customer->first_name . ' ' . $customer->last_name;
 
Notification::make()
->title($customerName . ' Pipeline Stage Updated')
->success()
->send();
}
 
// Data that we will pass to our View
protected function getViewData(): array
{
$statuses = $this->statuses();
 
$records = $this->records();
 
// We are mapping through the statuses and adding the records to each status
// This will form multiple lists dynamically based on the records
$statuses = $statuses
->map(function ($status) use ($records) {
$status['group'] = $this->getId();
$status['kanbanRecordsId'] = "{$this->getId()}-{$status['id']}";
$status['records'] = $records
->filter(function ($record) use ($status) {
return $this->isRecordInStatus($record, $status);
});
 
return $status;
});
 
return [
'records' => $records,
'statuses' => $statuses,
];
}
 
// Loading the statuses from the database and mapping them
// to have id and title. ID will be checked against Customers
protected function statuses(): Collection
{
return PipelineStage::query()
->orderBy('position')
->get()
->map(function (PipelineStage $stage) {
return [
'id' => $stage->id,
'title' => $stage->name,
];
});
}
 
// We are loading all the customers and mapping them to have ID, title, and status
protected function records(): Collection
{
return Customer::all()
->map(function (Customer $item) {
return [
'id' => $item->id,
'title' => $item->first_name . ' ' . $item->last_name,
'status' => $item->pipeline_stage_id,
];
});
}
 
// We are checking if the record is in the status
protected function isRecordInStatus($record, $status): bool
{
return $record['status'] === $status['id'];
}
}

Loading our page in the browser, we should see the following:

This is because we have yet to modify the view. Let's do that now:

resources/views/filament/pages/manage-customer-stages.blade.php

<x-filament-panels::page>
<x-filament::card wire:ignore.self>
<div>
<div class="w-full h-full flex space-x-4 rtl:space-x-reverse overflow-x-auto">
@foreach($statuses as $status)
<div class="h-full flex-1">
<div class="bg-primary-200 rounded px-2 flex flex-col h-full" id="{{ $status['id'] }}">
<div class="p-2 text-sm text-gray-900">
{{ $status['title'] }}
</div>
<div
id="{{ $status['kanbanRecordsId'] }}"
data-status-id="{{ $status['id'] }}"
class="space-y-2 p-2 flex-1 overflow-y-auto">
 
@foreach($status['records'] as $record)
<div
id="{{ $record['id'] }}"
class="shadow bg-white dark:bg-gray-800 p-2 rounded border">
 
<p>
{{ $record['title'] }}
</p>
 
</div>
@endforeach
</div>
</div>
</div>
@endforeach
</div>
 
 
<div wire:ignore>
<script>
window.onload = () => {
@foreach($statuses as $status)
{{-- Space here is needed to fix the Livewire issue where it adds comment block breaking JS scripts--}}
Sortable.create(document.getElementById('{{ $status['kanbanRecordsId'] }}'), {
group: '{{ $status['group'] }}',
animation: 0,
ghostClass: 'bg-warning-600',
 
setData: function (dataTransfer, dragEl) {
dataTransfer.setData('id', dragEl.id);
},
 
onEnd: function (evt) {
const sameContainer = evt.from === evt.to;
const orderChanged = evt.oldIndex !== evt.newIndex;
 
if (sameContainer && !orderChanged) {
return;
}
 
const recordId = evt.item.id;
const toStatusId = evt.to.dataset.statusId;
 
@this.
dispatch('statusChangeEvent', {
id: recordId,
pipeline_stage_id: toStatusId
});
},
});
@endforeach
}
</script>
 
</div>
</div>
</x-filament::card>
</x-filament-panels::page>

Let's look at what we did here and why:

Note: We will refer to Pipeline Stages as Status here (to match the codebase)

  • We are using the x-filament-panels::page component to have the page layout
  • We are using the x-filament::card component to have the card layout
  • We are using wire:ignore.self to ignore the card itself from Livewire
  • We are looping through the statuses and creating a column for each status
  • We then fill each status with the records that belong to it - Customers
  • We are using the Sortable library to make the records draggable
  • We are creating multiple sortable lists, one for each status
  • We are checking if the record was moved from one status to another by listening to the onEnd event
  • If the record was moved, we are dispatching the statusChangeEvent event to Livewire
  • This event is processed by our page class

That's it! Opening our page in the browser, we should see the following:

And, of course, moving the customer between the stages should work as well:

(It's less obvious, but a notification pops up when it's moved.)


Why There Was no Package Used?

We have tried to use a package for this Kanban's Board page by David Vincent, but we found some issues with it. It did not work as expected, and while it was possible to override some parts - it was not easy to explain why these modifications were needed, nor was it easy to do so in some cases. This became a reason why we took the approach of creating this ourselves without the package. In any case, credits for this page goes to David Vincent

Previous: Custom Fields for Customers
avatar

Hello Please help us with the command to create the two files you are talking about! After entering the php artisan make:filament-page ManageCustomerStages We get two Questions that I for one don’t know what to enter do I enter “customer” No! Looking at the path you are giving us it looks like maybe, app or is it filament; no that did not work either. ‘either the Filament code has been updated or something else is wrong’. By you not giving us this type of guidance it makes it hard for us to follow along; ‘Please’ at least with things like this give us the guidance so we don’t have to guess.

👍 2
avatar

Hi, if there are no parameters - pick default ones. Otherwise we try to add them as needed. We understand that it might be confusing so we prepare as much as we can (yet, we do sometimes miss small things).

In any case - this is correct, if we did not assign any parameters - leave them empty and press enter

avatar

Ok I did just push enter and well; that worked thanks for the Quick come back.

avatar

I have made a few modifications to the blade, because, for me, when the customer has already been rejected or accepted, it makes no sense to move the status to another position, so:

	<?php

namespace App\Filament\Pages;

use App\Models\Customer;
use App\Models\PipelineStage;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;

class ManageCustomerStages extends Page
{
    // ...
    #[On('statusChangeEvent')]
    public function changeRecordStatus($id, $pipeline_stage_id): void
    {
        $customer = Customer::find($id);

        if(!in_array($customer->pipeline_stage_id, [4,5])) {
            $customer->pipeline_stage_id = $pipeline_stage_id;
            $customer->save();

            $customer->pipelineStageLogs()->create([
                'pipeline_stage_id' => $pipeline_stage_id,
                'notes' => null,
                'user_id' => auth()->id()
            ]);

            // Inform the user that the status has been updated
            $customerName = $customer->first_name . ' ' . $customer->last_name;

            Notification::make()
                ->title($customerName . ' Pipeline Stage Updated')
                ->success()
                ->send();
        }else {
            Notification::make()
                ->title('Error: You cannot change the status of this customer')
                ->danger()
                ->send();
        }

    }

anyway, it's just a suggestion.

avatar
You can use Markdown
avatar
You can use Markdown