move into sonardesk
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use Modules\Workflow\Models\WorkflowInstance;
|
||||
|
||||
trait HasWorkflow
|
||||
{
|
||||
public function workflowInstance(): MorphOne
|
||||
{
|
||||
return $this->morphOne(WorkflowInstance::class, 'subject');
|
||||
}
|
||||
|
||||
public function workflowLabel(): string
|
||||
{
|
||||
return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
return ['name' => 'Workflow', 'default_due_days' => 3];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
|
||||
interface Workflowable
|
||||
{
|
||||
public function workflowInstance(): MorphOne;
|
||||
|
||||
public function workflowLabel(): string;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('workflow_definitions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('name');
|
||||
$t->string('slug')->unique();
|
||||
$t->text('description')->nullable();
|
||||
$t->string('subject_type')->nullable()->index();
|
||||
$t->boolean('is_active')->default(true);
|
||||
$t->timestamps();
|
||||
});
|
||||
Schema::create('workflow_stages', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name');
|
||||
$t->string('slug');
|
||||
$t->unsignedInteger('position')->default(0);
|
||||
$t->string('required_permission')->nullable();
|
||||
$t->boolean('is_initial')->default(false);
|
||||
$t->boolean('is_terminal')->default(false);
|
||||
$t->boolean('allows_rejection')->default(true);
|
||||
$t->unsignedInteger('due_days')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['workflow_definition_id', 'slug']);
|
||||
});
|
||||
Schema::create('workflow_transitions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
|
||||
$t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
|
||||
$t->string('name');
|
||||
$t->string('action');
|
||||
$t->string('required_permission')->nullable();
|
||||
$t->boolean('requires_comment')->default(false);
|
||||
$t->timestamps();
|
||||
});
|
||||
Schema::create('workflow_instances', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('workflow_stage_id')->constrained('workflow_stages');
|
||||
$t->string('subject_type');
|
||||
$t->unsignedBigInteger('subject_id');
|
||||
$t->string('status')->default('active');
|
||||
$t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$t->timestamp('due_at')->nullable();
|
||||
$t->timestamp('completed_at')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['subject_type', 'subject_id']);
|
||||
$t->index(['status', 'workflow_stage_id']);
|
||||
});
|
||||
Schema::create('workflow_history', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
|
||||
$t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
|
||||
$t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$t->string('action');
|
||||
$t->text('comment')->nullable();
|
||||
$t->json('metadata')->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('workflow_history');
|
||||
Schema::dropIfExists('workflow_instances');
|
||||
Schema::dropIfExists('workflow_transitions');
|
||||
Schema::dropIfExists('workflow_stages');
|
||||
Schema::dropIfExists('workflow_definitions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Workflow\Models\WorkflowDefinition;
|
||||
use Modules\Workflow\Models\WorkflowInstance;
|
||||
use Modules\Workflow\Services\WorkflowManager;
|
||||
|
||||
final class WorkflowController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
abort_unless(auth()->user()->can('workflow.view'), 403);
|
||||
|
||||
return view('workflow::admin.index', ['definitions' => WorkflowDefinition::with('stages')->latest()->get(), 'queue' => WorkflowInstance::with(['definition', 'stage', 'subject'])->where('status', 'active')->latest()->paginate(20), 'completed' => WorkflowInstance::where('status', 'completed')->count()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('workflow.manage'), 403);
|
||||
$data = $request->validate(['name' => 'required|string|max:120', 'description' => 'nullable|string', 'subject_type' => 'nullable|string|max:255']);
|
||||
$definition = WorkflowDefinition::create($data + ['slug' => Str::slug($data['name']), 'is_active' => true]);
|
||||
$draft = $definition->stages()->create(['name' => 'Draft', 'slug' => 'draft', 'position' => 0, 'is_initial' => true, 'due_days' => null]);
|
||||
$review = $definition->stages()->create(['name' => 'Review', 'slug' => 'review', 'position' => 10, 'required_permission' => 'workflow.review', 'due_days' => 3]);
|
||||
$published = $definition->stages()->create(['name' => 'Approved', 'slug' => 'approved', 'position' => 20, 'is_terminal' => true, 'allows_rejection' => false]);
|
||||
$definition->transitions()->createMany([['from_stage_id' => $draft->id, 'to_stage_id' => $review->id, 'name' => 'Submit for review', 'action' => 'submit'], ['from_stage_id' => $review->id, 'to_stage_id' => $published->id, 'name' => 'Approve', 'action' => 'approve', 'required_permission' => 'workflow.review'], ['from_stage_id' => $review->id, 'to_stage_id' => $draft->id, 'name' => 'Request changes', 'action' => 'reject', 'required_permission' => 'workflow.review', 'requires_comment' => true]]);
|
||||
|
||||
return back()->with('success', 'Workflow created.');
|
||||
}
|
||||
|
||||
public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->can('workflow.review'), 403);
|
||||
$data = $request->validate(['action' => 'required|string', 'comment' => 'nullable|string|max:2000']);
|
||||
$manager->transition($instance, $data['action'], $request->user(), $data['comment'] ?? null);
|
||||
|
||||
return back()->with('success', 'Workflow updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class WorkflowDefinition extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug', 'description', 'subject_type', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function stages(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkflowStage::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function transitions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkflowTransition::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class WorkflowHistory extends Model
|
||||
{
|
||||
protected $table = 'workflow_history';
|
||||
|
||||
protected $fillable = ['workflow_instance_id', 'from_stage_id', 'to_stage_id', 'user_id', 'action', 'comment', 'metadata'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['metadata' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
final class WorkflowInstance extends Model
|
||||
{
|
||||
protected $fillable = ['workflow_definition_id', 'workflow_stage_id', 'subject_type', 'subject_id', 'status', 'started_by', 'assigned_to', 'due_at', 'completed_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['due_at' => 'datetime', 'completed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function definition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
|
||||
}
|
||||
|
||||
public function stage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
|
||||
}
|
||||
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function history(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkflowHistory::class)->latest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class WorkflowStage extends Model
|
||||
{
|
||||
protected $fillable = ['workflow_definition_id', 'name', 'slug', 'position', 'required_permission', 'is_initial', 'is_terminal', 'allows_rejection', 'due_days'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_initial' => 'boolean', 'is_terminal' => 'boolean', 'allows_rejection' => 'boolean'];
|
||||
}
|
||||
|
||||
public function definition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class WorkflowTransition extends Model
|
||||
{
|
||||
protected $fillable = ['workflow_definition_id', 'from_stage_id', 'to_stage_id', 'name', 'action', 'required_permission', 'requires_comment'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['requires_comment' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
Route::middleware('web')->group(module_path('Workflow', 'Routes/web.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Workflow\Services\WorkflowManager;
|
||||
|
||||
final class WorkflowServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(module_path('Workflow', 'Config/config.php'), 'workflow');
|
||||
$this->app->singleton(WorkflowManager::class);
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(module_path('Workflow', 'Database/Migrations'));
|
||||
$this->loadViewsFrom(module_path('Workflow', 'Resources/views'), 'workflow');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<x-layouts.app :title="__('Workflows')"><main class="p-6 space-y-6"><div><flux:heading size="xl">Workflow</flux:heading><flux:text>Approval definitions, active reviews and completed work.</flux:text></div><div class="grid gap-4 md:grid-cols-3"><div class="rounded-xl border p-4"><flux:text>Definitions</flux:text><flux:heading size="xl">{{ $definitions->count() }}</flux:heading></div><div class="rounded-xl border p-4"><flux:text>Awaiting action</flux:text><flux:heading size="xl">{{ $queue->total() }}</flux:heading></div><div class="rounded-xl border p-4"><flux:text>Completed</flux:text><flux:heading size="xl">{{ $completed }}</flux:heading></div></div><div class="grid gap-6 xl:grid-cols-3"><section class="xl:col-span-2 rounded-xl border p-5"><flux:heading>Review queue</flux:heading><div class="mt-4 space-y-3">@forelse($queue as $item)<div class="rounded-lg border p-4"><div class="flex items-start justify-between gap-4"><div><div class="font-semibold">{{ method_exists($item->subject,'workflowLabel') ? $item->subject->workflowLabel() : class_basename($item->subject_type).' #'.$item->subject_id }}</div><div class="text-sm text-zinc-500">{{ $item->definition->name }} · {{ $item->stage->name }} @if($item->due_at) · due {{ $item->due_at->diffForHumans() }} @endif</div></div><div class="flex gap-2"><form method="POST" action="{{ route('administration.workflows.transition',$item) }}">@csrf<input type="hidden" name="action" value="approve"><flux:button type="submit" size="sm" variant="primary">Approve</flux:button></form><form method="POST" action="{{ route('administration.workflows.transition',$item) }}" class="flex gap-2">@csrf<input type="hidden" name="action" value="reject"><input name="comment" required placeholder="Reason" class="rounded-md border px-2 text-sm"><flux:button type="submit" size="sm">Changes</flux:button></form></div></div></div>@empty<flux:text>No items are awaiting review.</flux:text>@endforelse</div><div class="mt-4">{{ $queue->links() }}</div></section><section class="rounded-xl border p-5"><flux:heading>Create workflow</flux:heading><form method="POST" action="{{ route('administration.workflows.store') }}" class="mt-4 space-y-4">@csrf<flux:input name="name" label="Name" required/><flux:input name="subject_type" label="Subject model (optional)" placeholder="Modules\Pages\Models\Page"/><flux:textarea name="description" label="Description"/><flux:button type="submit" variant="primary">Create</flux:button></form><div class="mt-6 space-y-2">@foreach($definitions as $definition)<div class="rounded-lg border p-3"><div class="font-medium">{{ $definition->name }}</div><div class="text-xs text-zinc-500">{{ $definition->stages->pluck('name')->join(' → ') }}</div></div>@endforeach</div></section></div></main></x-layouts.app>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Workflow\Http\Controllers\WorkflowController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('administration/workflows')->name('administration.workflows.')->group(function () {
|
||||
Route::get('/', [WorkflowController::class, 'index'])->name('index');
|
||||
Route::post('/', [WorkflowController::class, 'store'])->name('store');
|
||||
Route::post('/{instance}/transition', [WorkflowController::class, 'transition'])->name('transition');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Workflow\Models\WorkflowDefinition;
|
||||
use Modules\Workflow\Models\WorkflowHistory;
|
||||
use Modules\Workflow\Models\WorkflowInstance;
|
||||
use Modules\Workflow\Models\WorkflowStage;
|
||||
use Modules\Workflow\Models\WorkflowTransition;
|
||||
|
||||
final class WorkflowManager
|
||||
{
|
||||
public function start(Model $subject, WorkflowDefinition $definition, ?Authenticatable $user = null): WorkflowInstance
|
||||
{
|
||||
return DB::transaction(function () use ($subject, $definition, $user) {
|
||||
$stage = $definition->stages()->where('is_initial', true)->first() ?? $definition->stages()->firstOrFail();
|
||||
$instance = WorkflowInstance::updateOrCreate(['subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey()], ['workflow_definition_id' => $definition->getKey(), 'workflow_stage_id' => $stage->getKey(), 'status' => $stage->is_terminal ? 'completed' : 'active', 'started_by' => $user?->getAuthIdentifier(), 'due_at' => $stage->due_days ? now()->addDays($stage->due_days) : null, 'completed_at' => $stage->is_terminal ? now() : null]);
|
||||
WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'to_stage_id' => $stage->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => 'started']);
|
||||
|
||||
return $instance->fresh(['definition', 'stage']);
|
||||
});
|
||||
}
|
||||
|
||||
public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user = null, ?string $comment = null): WorkflowInstance
|
||||
{
|
||||
return DB::transaction(function () use ($instance, $action, $user, $comment) {
|
||||
$transition = WorkflowTransition::where('workflow_definition_id', $instance->workflow_definition_id)->where('from_stage_id', $instance->workflow_stage_id)->where('action', $action)->first();
|
||||
if (! $transition) {
|
||||
throw ValidationException::withMessages(['action' => 'This transition is not available.']);
|
||||
} if ($transition->requires_comment && blank($comment)) {
|
||||
throw ValidationException::withMessages(['comment' => 'A comment is required.']);
|
||||
} if ($transition->required_permission && (! $user || ! method_exists($user, 'can') || ! $user->can($transition->required_permission))) {
|
||||
abort(403);
|
||||
} $target = WorkflowStage::findOrFail($transition->to_stage_id);
|
||||
$from = $instance->workflow_stage_id;
|
||||
$instance->update(['workflow_stage_id' => $target->id, 'status' => $target->is_terminal ? 'completed' : 'active', 'due_at' => $target->due_days ? now()->addDays($target->due_days) : null, 'completed_at' => $target->is_terminal ? now() : null]);
|
||||
WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'from_stage_id' => $from, 'to_stage_id' => $target->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => $action, 'comment' => $comment]);
|
||||
|
||||
return $instance->fresh(['definition', 'stage', 'subject']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Workflow\Tests\Feature;
|
||||
|
||||
use Modules\Workflow\Providers\WorkflowServiceProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ModuleBootTest extends TestCase
|
||||
{
|
||||
public function test_workflow_module_is_registered(): void
|
||||
{
|
||||
$this->assertTrue(app()->providerIsLoaded(WorkflowServiceProvider::class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Workflow",
|
||||
"alias": "workflow",
|
||||
"description": "Reusable approval workflows, review queues, transitions and audit history.",
|
||||
"keywords": ["workflow", "approval", "review"],
|
||||
"priority": 35,
|
||||
"providers": [
|
||||
"Modules\\Workflow\\Providers\\WorkflowServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
Reference in New Issue
Block a user