Skip to main content

Kanban

This module adds a kanbanboard view to your resource index page.

Installation

To install this module

composer require qore/kanban
php artisan vendor:publish --tag=qore.kanban.db
php artisan vendor:publish --tag=qore.kanban.frontend
warning

For the time being, for using this module frontend, you have to manually register it's vuex module

You can do this by adding the following changes your frontend index.js file inside src/store

import kanban from '../vendor/kanban/store'
import createPersistedState from 'vuex-persistedstate'

Vue.use(Vuex)

const store = new Vuex.Store({
modules: {
auth,
globals,
table,
quickActions,
kanban
},

This should only be done after the package frontend files are published.

warning

You need to mark the persistedStore for Kanban Module, by adding the following keys to the store booting file in your frontend folder src/store/index.js.

plugins: [createPersistedState({
paths: [
'auth.status',
'auth.twoFactorAuthStatus',
'auth.user',
...
'kanban.toggles',
'kanban.columnToggles',
'kanban.defaultSorts'
]
})]

Usage

Make sure to migrate.

To use this module

Implement the following class on the resource like the following:

class OrderResource extends QoreResource implements KanbanResource

Then add the following function to your resource:

public function kanbanBoard(string $identifier, KanbanRequestState $requestState): KanbanBoard
{
return KanbanBoard::make('type', 'name', [
KanbanBoardColumn::make('to-do', 'to-do'),
KanbanBoardColumn::make('doing', 'doing'),
KanbanBoardColumn::make('review', 'review'),
KanbanBoardColumn::make('done', 'done'),
KanbanBoardColumn::make('another_column', 'another_column'),
])
}

The $identifier and $requestState can be used to define different kanban boards, but may be left unused.

Inside the function you can add the kanbanboard that is based on a field from your resource. The first parameter inside make(...) is the field you want to use. The second parameter is the name of the field you want to use as a title.

The third parameter is the array of kanban columns that are based on the values that your field has. In this example the resource is showing every model with the values 'to-do', 'doing', 'review', 'done', 'another_column' in their respective columns. If you have models that do not have one of these values, they are not shown in the kanbanboard.

    /**
* @return KanbanBoard
*/
public function kanbanBoard(): KanbanBoard
{
return KanbanBoard::make('type', 'name', [
KanbanBoardColumn::make('to-do', 'to-do'),
KanbanBoardColumn::make('doing', 'doing'),
KanbanBoardColumn::make('review', 'review'),
KanbanBoardColumn::make('done', 'done'),
KanbanBoardColumn::make('another_column', 'another_column'),
]);
}

Screens

You also need to add a screen for the kanbanboard to be shown on the resource index page:

/**
* @return list<ResourceScreen>
*/
public function indexScreens(): array
{
return [
new ResourceDefaultTableScreen($this),
new KanbanResourceIndexScreen($this)
];
}

Relations

A Kanban Board can be shown instead of a relational table on a resource page:

return HasManyWithKanban::make(__('ap::ap.issues.plural'), 'issues', issues_resource());

Functionalities & API

The kanban board support multiple functionalities (tabs, custom components, totals, custom index query etc.). Below is an example of 2 kanban boards on a resource (used by qore/sales module) with many of these functionalities:

/**
* @return list<ResourceScreen>
*/
public function indexScreens(): array
{
return [
new ResourceDefaultTableScreen($this),
new KanbanResourceIndexScreen($this),
(new KanbanResourceIndexScreen($this))
->setKanbanIdentifier(static::FORECAST_KANBAN_IDENTIFIER)
->setKanbanIcon(MaterialIcons::FLAG->value)
->setKanbanLabel(___('sales::sales.forecast')),
];
}

/**
* @param array<KanbanBoardColumn> $columns
*/
protected function makeKanbanBoard(
string $columnName,
array $columns,
bool $isForecast,
KanbanRequestState $requestState
): KanbanBoard {
$resource = $this;

$fieldsToUse = [
'organization', 'urgency', 'contact', 'owner', 'value',
'expected_close_date', 'labels', 'sales_stage_id', 'sales_pipeline_id',
'won_at', 'lost_at', 'days_in_current_stage',
];

if (module_is_active(TaskModule::COMPOSER_NAME)) {
$fieldsToUse[] = 'nextTask';
}

return KanbanBoard::make(
onColumn: $columnName,
titleFromField: 'title',
boardColumns: $columns,
)
->setIndexQuery(function (ResourceBoard $board) use ($resource, $isForecast) {
return $resource->indexQuery()
->with(
'organization', 'contact', 'salesStage',
'urgency', 'labels', 'owner', 'salesPipeline', 'nextTask'
)
->when(! $isForecast, function (Builder $q) use ($board) {
return $q->whereIn($board->onField->column, $board->kanban->getColumnNames());
});
})
->enabled(true)
->useFields($fieldsToUse)
->setCardComponent('SalesDealKanbanCard')
->setCardDetailsComponent('KanbanCardDetails')
->setIndexTabs(new SalesDealKanbanTabCollection(includeAllTab: $isForecast))
->showColumnTotalsFromField(field: 'weighted_value', icon: MaterialIcons::BALANCE->value)
->onTotalsHover(function (KanbanTotalsTooltipBuilder $builder) {
$builder->setComponent('SalesDealKanbanTotalsHover');

$values = $builder->getQuery()
->selectRaw('SUM(weighted_value) as total_weighted_value, SUM(value) as total_value')
->toBase()
->first();

$totalValue = $values->total_value ?? 0;
$totalWeightedValue = $values->total_weighted_value ?? 0;
$percentage = $totalValue > 0 ? round(($totalWeightedValue / $totalValue) * 100) : 0;

$builder->setProperties([
'total_value' => format_price($totalValue),
'total_weighted_value' => format_price($totalWeightedValue),
'percentage' => "{$percentage}%",
]);
})
->onColumnFieldUpdated(function (ResourceBoard $board, SalesDeal $model) use ($isForecast) {
if ($isForecast || $model->probability_manually_set) {
return;
}

$model->probability = $model->salesStage->probability;
$model->save();
})
->setSortableFields([
'expected_close_date',
'created_at',
])
->withProperties([
'is_forecast' => $isForecast,
'pipelines' => ! $isForecast ? [] : $this->getPipelinesAndStagesForKanban($requestState),
]);
}

public function kanbanBoard(string $identifier, KanbanRequestState $requestState): KanbanBoard
{
if ($identifier === static::FORECAST_KANBAN_IDENTIFIER) {
return $this->forecastKanbanBoard($requestState);
}

$pipeline = $this->getPipelineForKanban($requestState);

$columns = [];

foreach ($pipeline->salesStages as $stage) {
$columns[] = KanbanBoardColumn::make(
label: $stage->name,
name: $stage->id,
allowCreating: false
);
}

return $this->makeKanbanBoard(
columnName: 'sales_stage_id',
columns: $columns,
isForecast: false,
requestState: $requestState
);
}

protected function getPipelineForKanban(KanbanRequestState $requestState): SalesPipeline
{
if ($requestState->tabName) {
/** @var SalesDealPipelineIndexTab|null $tab */
$tab = (new SalesDealKanbanTabCollection())->findByName($requestState->tabName);
if ($tab && $tab->pipeline) {
return $tab->pipeline;
}
}

return get_sales_pipelines()->firstOrFail();
}

/**
* @return array<string, array<mixed>>
*/
protected function getPipelinesAndStagesForKanban(KanbanRequestState $requestState): array
{
$pipelines = [];

foreach (get_sales_pipelines() as $pipeline) {
$pipelines[$pipeline->name] = $pipeline->salesStages->pluck('name')->toArray();
}

return $pipelines;
}

protected function forecastKanbanBoard(KanbanRequestState $requestState): KanbanBoard
{
$columns = [];

$startIndex = $requestState->arrowNavigationIndex ?? 0;

for ($i = $startIndex; $i <= $startIndex + 3; $i++) {
$columns[] = KanbanBoardColumn::make(
label: now()->addMonths($i)->translatedFormat('F Y'),
name: now()->addMonths($i)->endOfMonth()->toDateString(),
allowCreating: false
)->setColumnQuery(function (Builder $q) use ($i) {
return $q->whereBetween('expected_close_date', [
now()->addMonths($i)->startOfMonth()->toDateString(),
now()->addMonths($i)->endOfMonth()->toDateString(),
]);
});
}

return $this->makeKanbanBoard(
columnName: 'expected_close_date',
columns: $columns,
isForecast: true,
requestState: $requestState
)
->withArrowNavigation()
->fillColumnUsing(function (SalesDeal $model, array $data) {
$value = $data['expected_close_date'] ?? null;
$newDate = $value ? Carbon::parse($value) : null;

if ($newDate === null) {
return;
}

$oldDate = $model->expected_close_date;
$parsedNewDate = $newDate->startOfMonth();

if ($oldDate) {
$endOfMonthDay = $parsedNewDate->endOfMonth()->day;
$newDay = min($oldDate->day + 1, $endOfMonthDay);
$parsedNewDate->setDay($newDay);
}

$model->expected_close_date = $parsedNewDate;
});
}

Upgrade Guide

To upgrade this module

composer update qore/kanban

If you need to upgrade migrations or Vue components:

php artisan vendor:publish --tag=qore.kanban.db --force
php artisan vendor:publish --tag=qore.kanban.frontend --force

Make sure to migrate after.