Browse Source

feat(kanban): adapt backend for kanban task board

- Migration: alter kanbans table (add phase, priority, scope, sector, due_date, responsible_user_id, created_by_user_id, target_unit_id, origin; drop kanban_status_id)
- Migration: create kanban_replies table
- Model: Kanban (relationships + visibleToUnit scope)
- Model: KanbanReply (new)
- Request: KanbanRequest (full validation rules)
- Request: KanbanReplyRequest (new)
- Resource: KanbanResource (all fields + counts)
- Resource: KanbanReplyResource (new)
- Service: KanbanService (matrix vs unit filtering)
- Service: KanbanReplyService (new)
- Controller: KanbanController (matrix/unit logic, broadcast scope)
- Controller: KanbanReplyController (new)
- Routes: kanban.php (merged reply sub-routes)
ebagabee 2 tuần trước cách đây
mục cha
commit
826eef8ca6

+ 59 - 3
app/Http/Controllers/KanbanController.php

@@ -2,27 +2,78 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Controllers\Concerns\ResolvesActiveUnit;
 use App\Services\KanbanService;
 use App\Http\Requests\KanbanRequest;
 use App\Http\Resources\KanbanResource;
+use App\Models\Unit;
 use Illuminate\Http\JsonResponse;
 
 class KanbanController extends Controller
 {
+    use ResolvesActiveUnit;
+
     public function __construct(
         protected KanbanService $service,
     ) {}
 
     public function index(): JsonResponse
     {
-        $items = $this->service->getAll();
+        $user = auth()->user();
+
+        if ($this->isMatriz($user)) {
+            $items = $this->service->getAll();
+        } else {
+            $items = $this->service->getAllForUnit($this->activeUnitId($user));
+        }
+
         return $this->successResponse(payload: KanbanResource::collection($items));
     }
 
     public function store(KanbanRequest $request): JsonResponse
     {
-        $item = $this->service->create($request->validated());
-        return $this->successResponse(payload: new KanbanResource($item), message: __('messages.created'), code: 201);
+        $data = $request->validated();
+        $user = auth()->user();
+        $isMatriz = $this->isMatriz($user);
+
+        $data['origin'] = $isMatriz ? 'matriz' : 'unit';
+        $data['created_by_user_id'] = $user->id;
+        $data['unit_id'] = $isMatriz ? null : $this->activeUnitId($user);
+
+        // Broadcast to all units
+        if ($isMatriz && ($data['scope'] ?? null) === 'all') {
+            $unitIds = Unit::query()->pluck('id');
+            $created = [];
+            foreach ($unitIds as $unitId) {
+                $created[] = $this->service->create(array_merge($data, [
+                    'target_unit_id' => $unitId,
+                ]));
+            }
+            return $this->successResponse(
+                payload: KanbanResource::collection($created),
+                message: __('messages.created'),
+                code: 201
+            );
+        }
+
+        if ($isMatriz) {
+            if (($data['scope'] ?? null) === 'internal') {
+                $data['target_unit_id'] = null;
+            }
+            // scope='specific' → target_unit_id already set from request
+        } else {
+            // Franchisee: internal = own unit, specific = Matriz (null target)
+            $data['target_unit_id'] = (($data['scope'] ?? null) === 'internal')
+                ? $this->activeUnitId($user)
+                : null;
+        }
+
+        $item = $this->service->create($data);
+        return $this->successResponse(
+            payload: new KanbanResource($item),
+            message: __('messages.created'),
+            code: 201
+        );
     }
 
     public function show(int $id): JsonResponse
@@ -42,4 +93,9 @@ public function destroy(int $id): JsonResponse
         $this->service->delete($id);
         return $this->successResponse(message: __('messages.deleted'), code: 204);
     }
+
+    private function isMatriz(\App\Models\User $user): bool
+    {
+        return $user->user_type === 'ADMIN';
+    }
 }

+ 39 - 0
app/Http/Controllers/KanbanReplyController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\KanbanReplyService;
+use App\Http\Requests\KanbanReplyRequest;
+use App\Http\Resources\KanbanReplyResource;
+use Illuminate\Http\JsonResponse;
+
+class KanbanReplyController extends Controller
+{
+    public function __construct(
+        protected KanbanReplyService $service,
+    ) {}
+
+    public function index(int $kanbanId): JsonResponse
+    {
+        $replies = $this->service->getByKanban($kanbanId);
+        return $this->successResponse(payload: KanbanReplyResource::collection($replies));
+    }
+
+    public function store(KanbanReplyRequest $request, int $kanbanId): JsonResponse
+    {
+        $reply = $this->service->create($kanbanId, auth()->id(), $request->validated()['reply']);
+        return $this->successResponse(payload: new KanbanReplyResource($reply), message: __('messages.created'), code: 201);
+    }
+
+    public function update(KanbanReplyRequest $request, int $kanbanId, int $id): JsonResponse
+    {
+        $reply = $this->service->update($kanbanId, $id, $request->validated()['reply']);
+        return $this->successResponse(payload: new KanbanReplyResource($reply), message: __('messages.updated'));
+    }
+
+    public function destroy(int $kanbanId, int $id): JsonResponse
+    {
+        $this->service->delete($kanbanId, $id);
+        return $this->successResponse(message: __('messages.deleted'), code: 204);
+    }
+}

+ 15 - 0
app/Http/Requests/KanbanReplyRequest.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class KanbanReplyRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'reply' => 'required|string',
+        ];
+    }
+}

+ 13 - 21
app/Http/Requests/KanbanRequest.php

@@ -8,27 +8,19 @@ class KanbanRequest extends FormRequest
 {
     public function rules(): array
     {
-        $rules = [
-            // Add your validation rules here
-            //'field' => 'sometimes|string|max:255',
-        ];
-
-        // Different rules for creation
-        //if ($this->isMethod('POST')) {
-            // Make fields required if needed
-            // $rules['field'] = 'required|string|max:255';
-        //}
+        $isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
+        $sometimes = $isUpdate ? 'sometimes' : 'required';
 
-        return $rules;
+        return [
+            'title'               => "{$sometimes}|string|max:255",
+            'priority'            => "{$sometimes}|string|in:alta,normal,baixa",
+            'phase'               => "{$sometimes}|string|in:a_fazer,em_progresso,em_revisao,concluido,demandas_especiais",
+            'scope'               => "{$sometimes}|string|in:internal,all,specific",
+            'responsible_user_id' => 'nullable|integer|exists:users,id',
+            'target_unit_id'      => 'nullable|integer|exists:units,id',
+            'sector'              => 'nullable|string|max:255',
+            'due_date'            => 'nullable|date',
+            'description'         => 'nullable|string',
+        ];
     }
-
-    /**
-    * Add custom messages when needed
-    * public function messages(): array
-    * {
-    *   return [
-    *        'field.required' => __('message.algo'),
-    *    ];
-    * }
-    */
 }

+ 21 - 0
app/Http/Resources/KanbanReplyResource.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class KanbanReplyResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'         => $this->id,
+            'kanban_id'  => $this->kanban_id,
+            'reply'      => $this->reply,
+            'user_name'  => $this->user?->name,
+            'created_at' => Carbon::parse($this->created_at)->format('d/m/Y H:i'),
+        ];
+    }
+}

+ 21 - 17
app/Http/Resources/KanbanResource.php

@@ -2,7 +2,6 @@
 
 namespace App\Http\Resources;
 
-use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -10,27 +9,32 @@
 
 class KanbanResource extends JsonResource
 {
-    /**
-     * Transform the resource into an array.
-     *
-     * @return array<string, mixed>
-     */
     public function toArray(Request $request): array
     {
         return [
-            'id' => $this->id,
-            'name' => $this->name,
-            'created_at' => Carbon::parse($this->created_at)->format('Y-m-d H:i:s'),
-            'updated_at' => Carbon::parse($this->updated_at)->format('Y-m-d H:i:s'),
-            // Add your fields here
+            'id'                   => $this->id,
+            'title'                => $this->title,
+            'description'          => $this->description,
+            'phase'                => $this->phase,
+            'priority'             => $this->priority,
+            'origin'               => $this->origin,
+            'scope'                => $this->scope,
+            'sector'               => $this->sector,
+            'due_date'             => $this->due_date?->format('Y-m-d'),
+            'responsible_user_id'  => $this->responsible_user_id,
+            'created_by_user_id'   => $this->created_by_user_id,
+            'unit_id'              => $this->unit_id,
+            'target_unit_id'       => $this->target_unit_id,
 
-            // Conditional fields
-            // $this->mergeWhen($request->user()?->isAdmin(), [
-            //     'internal_notes' => $this->internal_notes,
-            // ]),
+            // Resolved names for the UI
+            'created_by_user_name' => $this->whenLoaded('createdByUser', fn() => $this->createdByUser?->name),
+            'responsible_user_name'=> $this->whenLoaded('responsibleUser', fn() => $this->responsibleUser?->name),
+            'applicant_unit_name'  => $this->whenLoaded('applicantUnit', fn() => $this->applicantUnit?->fantasy_name),
+            'target_unit_name'     => $this->whenLoaded('targetUnit', fn() => $this->targetUnit?->fantasy_name),
+            'replies_count'        => $this->whenCounted('replies'),
 
-            // Relationships
-            // 'user' => new UserResource($this->whenLoaded('user')),
+            'created_at' => $this->created_at?->format('d/m/Y H:i'),
+            'updated_at' => $this->updated_at?->format('d/m/Y H:i'),
         ];
     }
 

+ 47 - 22
app/Models/Kanban.php

@@ -4,51 +4,76 @@
 
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 /**
- * @property int $id
- * @property int $unit_id
- * @property string $title
+ * @property int         $id
+ * @property int|null    $unit_id               Unidade que criou o card (nulo para Matriz)
+ * @property string      $title
  * @property string|null $description
- * @property int $kanban_status_id
+ * @property string      $phase                 a_fazer|em_progresso|em_revisao|concluido|demandas_especiais
+ * @property string      $priority              alta|normal|baixa
+ * @property string      $origin                matriz|unit
+ * @property string      $scope                 internal|all|specific
+ * @property string|null $sector
+ * @property string|null $due_date
+ * @property int|null    $responsible_user_id
+ * @property int|null    $created_by_user_id
+ * @property int|null    $target_unit_id
  * @property \Illuminate\Support\Carbon|null $created_at
  * @property \Illuminate\Support\Carbon|null $updated_at
  * @property string|null $deleted_at
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban newModelQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban newQuery()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban query()
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereCreatedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereDeletedAt($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereDescription($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereKanbanStatusId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereTitle($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereUnitId($value)
- * @method static \Illuminate\Database\Eloquent\Builder<static>|Kanban whereUpdatedAt($value)
  * @mixin \Eloquent
  */
 class Kanban extends Model
 {
-    use HasFactory;
+    use HasFactory, SoftDeletes;
 
     protected $table = 'kanbans';
 
-    protected $guarded = [
-        'id', // Add more fields that shouldn't be edited here
-    ];
+    protected $guarded = ['id'];
 
     protected $casts = [
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
-        // Add your casts here (e.g., 'is_active' => 'boolean')
+        'due_date'   => 'date',
     ];
 
     // Relationships
 
-    // Business Logic Methods
+    public function applicantUnit()
+    {
+        return $this->belongsTo(Unit::class, 'unit_id');
+    }
+
+    public function targetUnit()
+    {
+        return $this->belongsTo(Unit::class, 'target_unit_id');
+    }
+
+    public function responsibleUser()
+    {
+        return $this->belongsTo(User::class, 'responsible_user_id');
+    }
+
+    public function createdByUser()
+    {
+        return $this->belongsTo(User::class, 'created_by_user_id');
+    }
 
-    // Custom Finders
+    public function replies()
+    {
+        return $this->hasMany(KanbanReply::class, 'kanban_id');
+    }
 
     // Query Scopes
 
+    public function scopeVisibleToUnit($query, int $unitId)
+    {
+        return $query->where(function ($q) use ($unitId) {
+            $q->where('unit_id', $unitId)
+              ->orWhere('target_unit_id', $unitId)
+              ->orWhere('scope', 'all');
+        });
+    }
 }

+ 31 - 0
app/Models/KanbanReply.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
+class KanbanReply extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    protected $table = 'kanban_replies';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'created_at' => 'datetime',
+        'updated_at' => 'datetime',
+    ];
+
+    public function kanban()
+    {
+        return $this->belongsTo(Kanban::class, 'kanban_id');
+    }
+
+    public function user()
+    {
+        return $this->belongsTo(User::class, 'user_id');
+    }
+}

+ 41 - 0
app/Services/KanbanReplyService.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\KanbanReply;
+use Illuminate\Database\Eloquent\Collection;
+
+class KanbanReplyService
+{
+    public function getByKanban(int $kanbanId): Collection
+    {
+        return KanbanReply::with('user')
+            ->where('kanban_id', $kanbanId)
+            ->orderBy('created_at', 'asc')
+            ->get();
+    }
+
+    public function create(int $kanbanId, int $userId, string $reply): KanbanReply
+    {
+        $model = KanbanReply::create([
+            'kanban_id' => $kanbanId,
+            'user_id'   => $userId,
+            'reply'     => $reply,
+        ]);
+
+        return $model->load('user');
+    }
+
+    public function update(int $kanbanId, int $id, string $reply): KanbanReply
+    {
+        $model = KanbanReply::where('kanban_id', $kanbanId)->findOrFail($id);
+        $model->update(['reply' => $reply]);
+        return $model->load('user');
+    }
+
+    public function delete(int $kanbanId, int $id): bool
+    {
+        $model = KanbanReply::where('kanban_id', $kanbanId)->findOrFail($id);
+        return $model->delete();
+    }
+}

+ 22 - 8
app/Services/KanbanService.php

@@ -7,37 +7,53 @@
 
 class KanbanService
 {
+    private function baseQuery()
+    {
+        return Kanban::with(['createdByUser', 'responsibleUser', 'applicantUnit', 'targetUnit'])
+            ->withCount('replies');
+    }
+
     public function getAll(): Collection
     {
-        return Kanban::orderBy('created_at', 'desc')
+        return $this->baseQuery()
+            ->orderBy('created_at', 'desc')
+            ->get();
+    }
+
+    public function getAllForUnit(int $unitId): Collection
+    {
+        return $this->baseQuery()
+            ->visibleToUnit($unitId)
+            ->orderBy('created_at', 'desc')
             ->get();
     }
 
     public function findById(int $id): ?Kanban
     {
-        return Kanban::find($id);
+        return $this->baseQuery()->find($id);
     }
 
     public function create(array $data): Kanban
     {
-        return Kanban::create($data);
+        $model = Kanban::create($data);
+        return $this->findById($model->id);
     }
 
     public function update(int $id, array $data): ?Kanban
     {
-        $model = $this->findById($id);
+        $model = Kanban::find($id);
 
         if (!$model) {
             return null;
         }
 
         $model->update($data);
-        return $model->fresh();
+        return $this->findById($model->id);
     }
 
     public function delete(int $id): bool
     {
-        $model = $this->findById($id);
+        $model = Kanban::find($id);
 
         if (!$model) {
             return false;
@@ -45,6 +61,4 @@ public function delete(int $id): bool
 
         return $model->delete();
     }
-
-    // Add custom business logic methods here
 }

+ 66 - 0
database/migrations/2026_05_27_100000_alter_kanbans_add_task_fields.php

@@ -0,0 +1,66 @@
+<?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::table('kanbans', function (Blueprint $table) {
+            // Drop the old status FK — replaced by the 'phase' enum column
+            $table->dropForeign(['kanban_status_id']);
+            $table->dropColumn('kanban_status_id');
+
+            // Phase replaces kanban_status_id with a fixed set of values
+            $table->string('phase')->default('a_fazer')->after('unit_id');
+
+            // Task-specific fields
+            $table->string('priority')->default('normal')->after('phase');          // alta|normal|baixa
+            $table->string('origin')->default('unit')->after('priority');           // matriz|unit
+            $table->string('scope')->default('internal')->after('origin');          // internal|all|specific
+            $table->string('sector')->nullable()->after('scope');
+            $table->date('due_date')->nullable()->after('sector');
+
+            $table->foreignId('responsible_user_id')
+                ->nullable()
+                ->after('due_date')
+                ->constrained('users')
+                ->nullOnDelete();
+
+            $table->foreignId('created_by_user_id')
+                ->nullable()
+                ->after('responsible_user_id')
+                ->constrained('users')
+                ->nullOnDelete();
+
+            $table->foreignId('target_unit_id')
+                ->nullable()
+                ->after('created_by_user_id')
+                ->constrained('units')
+                ->nullOnDelete();
+
+            // unit_id is now the applicant (creator) unit; make it nullable
+            // (matrix users don't belong to a specific unit)
+            $table->unsignedBigInteger('unit_id')->nullable()->change();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('kanbans', function (Blueprint $table) {
+            $table->dropForeign(['responsible_user_id']);
+            $table->dropForeign(['created_by_user_id']);
+            $table->dropForeign(['target_unit_id']);
+            $table->dropColumn([
+                'phase', 'priority', 'origin', 'scope',
+                'sector', 'due_date',
+                'responsible_user_id', 'created_by_user_id', 'target_unit_id',
+            ]);
+
+            $table->foreignId('kanban_status_id')->constrained('kanban_statuses');
+            $table->unsignedBigInteger('unit_id')->nullable(false)->change();
+        });
+    }
+};

+ 28 - 0
database/migrations/2026_05_27_100001_create_kanban_replies_table.php

@@ -0,0 +1,28 @@
+<?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('kanban_replies', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('kanban_id')->constrained('kanbans')->cascadeOnDelete();
+            $table->foreignId('user_id')->constrained('users');
+            $table->text('reply');
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('kanban_id');
+            $table->index('user_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('kanban_replies');
+    }
+};

+ 9 - 4
routes/authRoutes/kanban.php

@@ -2,15 +2,20 @@
 
 use Illuminate\Support\Facades\Route;
 use App\Http\Controllers\KanbanController;
+use App\Http\Controllers\KanbanReplyController;
 
 Route::controller(KanbanController::class)->prefix('kanban')->group(function () {
     Route::get('/', 'index')->middleware('permission:kanban,view');
-
     Route::post('/', 'store')->middleware('permission:kanban,add');
-
     Route::get('/{id}', 'show')->middleware('permission:kanban,view');
-
     Route::put('/{id}', 'update')->middleware('permission:kanban,edit');
-
     Route::delete('/{id}', 'destroy')->middleware('permission:kanban,delete');
+
+    // Replies (comments)
+    Route::prefix('/{kanbanId}/replies')->controller(KanbanReplyController::class)->group(function () {
+        Route::get('/', 'index')->middleware('permission:kanban,view');
+        Route::post('/', 'store')->middleware('permission:kanban,add');
+        Route::put('/{id}', 'update')->middleware('permission:kanban,edit');
+        Route::delete('/{id}', 'destroy')->middleware('permission:kanban,delete');
+    });
 });