From 326033c6b13d337d3049658c666ec5223883fd4e Mon Sep 17 00:00:00 2001 From: "remzi.dalyan" Date: Mon, 7 Apr 2025 05:53:06 +0300 Subject: [PATCH] Task Management system --- api-doc.md | 160 ++++++ app/Board.php | 25 + app/BoardAssignment.php | 36 ++ app/Console/Kernel.php | 7 +- app/Http/Controllers/Api/V1/TaskCTRL.php | 138 +++++ app/Http/Requests/Api/V1/TaskRequest.php | 33 ++ app/Jobs/TaskStartJob.php | 47 ++ app/Library/Helpers/StringHelper.php | 94 ++++ .../TaskStatusChangeNotification.php | 54 ++ app/Observers/BoardAssignmentObserver.php | 8 + app/Observers/BoardObserver.php | 16 + app/Observers/TaskObserver.php | 31 ++ app/Observers/TaskReviewerObserver.php | 8 + app/Observers/TaskStatusObserver.php | 16 + app/Providers/StringMacroServiceProvider.php | 29 + app/Task.php | 54 ++ app/TaskReviewer.php | 34 ++ app/TaskStatus.php | 25 + app/User.php | 19 +- composer.json | 5 +- config/app.php | 5 + config/database.php | 17 + ...1_01_000001_create_task_statuses_table.php | 48 ++ .../2025_02_02_000001_create_boards_table.php | 27 + ..._000002_create_board_assignments_table.php | 42 ++ .../2025_02_03_000001_create_tasks_table.php | 48 ++ ..._03_000002_create_task_reviewers_table.php | 45 ++ .../seeds/BoardAssignmentsTableSeeder.php | 34 ++ database/seeds/BoardsTableSeeder.php | 19 + database/seeds/DatabaseSeeder.php | 2 + package.json | 3 + readme.md | 181 +++--- resources/js/models/Task.js | 129 +++++ resources/js/routers/backoffice.js | 32 ++ .../js/views/__backoffice/partials/Sidebar.js | 43 +- .../__backoffice/task-management/Create.js | 178 ++++++ .../__backoffice/task-management/Edit.js | 134 +++++ .../task-management/Forms/Task.js | 335 +++++++++++ .../task-management/Forms/index.js | 3 + .../__backoffice/task-management/Kanban.js | 115 ++++ .../__backoffice/task-management/List.js | 525 ++++++++++++++++++ .../__backoffice/task-management/index.js | 6 + resources/lang/en/navigation.php | 3 + resources/lang/en/resources.php | 4 + resources/lang/fil/navigation.php | 3 + resources/lang/fil/resources.php | 4 + routes/api.php | 8 + 47 files changed, 2723 insertions(+), 109 deletions(-) create mode 100644 api-doc.md create mode 100644 app/Board.php create mode 100644 app/BoardAssignment.php create mode 100644 app/Http/Controllers/Api/V1/TaskCTRL.php create mode 100644 app/Http/Requests/Api/V1/TaskRequest.php create mode 100644 app/Jobs/TaskStartJob.php create mode 100644 app/Library/Helpers/StringHelper.php create mode 100644 app/Notifications/TaskStatusChangeNotification.php create mode 100644 app/Observers/BoardAssignmentObserver.php create mode 100644 app/Observers/BoardObserver.php create mode 100644 app/Observers/TaskObserver.php create mode 100644 app/Observers/TaskReviewerObserver.php create mode 100644 app/Observers/TaskStatusObserver.php create mode 100644 app/Providers/StringMacroServiceProvider.php create mode 100644 app/Task.php create mode 100644 app/TaskReviewer.php create mode 100644 app/TaskStatus.php create mode 100644 database/migrations/2025_01_01_000001_create_task_statuses_table.php create mode 100644 database/migrations/2025_02_02_000001_create_boards_table.php create mode 100644 database/migrations/2025_02_02_000002_create_board_assignments_table.php create mode 100644 database/migrations/2025_02_03_000001_create_tasks_table.php create mode 100644 database/migrations/2025_02_03_000002_create_task_reviewers_table.php create mode 100644 database/seeds/BoardAssignmentsTableSeeder.php create mode 100644 database/seeds/BoardsTableSeeder.php create mode 100644 resources/js/models/Task.js create mode 100644 resources/js/views/__backoffice/task-management/Create.js create mode 100644 resources/js/views/__backoffice/task-management/Edit.js create mode 100644 resources/js/views/__backoffice/task-management/Forms/Task.js create mode 100644 resources/js/views/__backoffice/task-management/Forms/index.js create mode 100644 resources/js/views/__backoffice/task-management/Kanban.js create mode 100644 resources/js/views/__backoffice/task-management/List.js create mode 100644 resources/js/views/__backoffice/task-management/index.js diff --git a/api-doc.md b/api-doc.md new file mode 100644 index 0000000..abc22df --- /dev/null +++ b/api-doc.md @@ -0,0 +1,160 @@ +# Task API Documentation + +# List Tasks + +**Endpoint**: `GET /api/v1/tasks` + +Retrieves a paginated list of tasks. + +## Authentication + +Bearer Token required + +## Query Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|---------|----------|---------|------------------| +| page | integer | No | 1 | Page number | +| perPage | integer | No | 10 | Items per page | +| sortBy | string | No | id | Field to sort by | +| sortType | string | No | asc | Sort direction | + +## Example Request + +```http +GET /api/v1/tasks?perPage=10&page=1&sortBy=id&sortType=asc +Authorization: Bearer +``` + +# Get Single Task + +**Endpoint**: `GET /api/v1/tasks/{id}` + +Retrieves a single task by ID. + +## Authentication + +Bearer Token required + +## Example Request + +```http +GET /api/v1/tasks/2 +Authorization: Bearer +``` + +# Create Task + +**Endpoint**: `POST /api/v1/tasks` + +Creates a new task. + +## Authentication + +Bearer Token required + +## Request Body (form-data) + +| Parameter | Type | Required | Default | Constraints | +|----------------|---------|----------|---------|-----------------------------------| +| board_id | integer | No | 1 | | +| task_status_id | integer | No | 1 | | +| title | string | Yes | - | | +| description | string | No | - | | +| start_date | string | No | - | Format: Y-m-d H:i | +| due_date | string | No | - | Must be after or equal start_date | + +## Example Request + +```http +POST /api/v1/tasks +Authorization: Bearer +Content-Type: multipart/form-data + +title: GÖREV 1 +start_date: 2025-04-06 15:00 +due_date: 2025-04-06 18:00 +board_id: 1 +task_status_id: 1 +description: GÖREV 1 AÇIKLAMASI +``` + +# Update Task + +**Endpoint**: `PUT /api/v1/tasks/{id}` + +Updates an existing task. + +## Authentication + +Bearer Token required + +## Request Body (form-data) + +| Parameter | Type | Required | Constraints | +|-------------|--------|----------|-----------------------------------| +| title | string | No | | +| description | string | No | | +| start_date | string | No | Format: Y-m-d H:i | +| due_date | string | No | Must be after or equal start_date | + +## Example Request + +```http +PUT /api/v1/tasks/1 +Authorization: Bearer +Content-Type: multipart/form-data + +title: Updated Task +due_date: 2025-04-06 18:30 +description: Updated description +``` + +# Delete Task + +**Endpoint**: `DELETE /api/v1/tasks/{id}` + +Deletes a task with optional recovery. + +## Authentication + +Bearer Token required + +## Query Parameters + +| Parameter | Type | Required | Description | +|-------------|---------|----------|----------------------| +| is_recovery | boolean | No | Enable recovery mode | + +## Example Request + +```http +DELETE /api/v1/tasks/1?is_recovery=true +Authorization: Bearer +``` + +# Change Task Status + +**Endpoint**: `PATCH /api/v1/tasks/{id}/status-change` + +Updates a task's status. + +## Authentication + +Bearer Token required + +## Request Body (x-www-form-urlencoded) + +| Parameter | Type | Required | Description | +|-----------|---------|----------|---------------| +| status_id | integer | Yes | New status ID | + +## Example Request + +```http +PATCH /api/v1/tasks/2/status-change +Authorization: Bearer +Content-Type: application/x-www-form-urlencoded + +status_id=2 +``` diff --git a/app/Board.php b/app/Board.php new file mode 100644 index 0000000..11dcd68 --- /dev/null +++ b/app/Board.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } + + public function board(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Board::class); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2c1c0b7..ac0c154 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Jobs\TaskStartJob; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -19,7 +20,7 @@ class Kernel extends ConsoleKernel /** * Define the application's command schedule. * - * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @param \Illuminate\Console\Scheduling\Schedule $schedule * @return void */ protected function schedule(Schedule $schedule) @@ -27,6 +28,8 @@ protected function schedule(Schedule $schedule) $schedule->command('backup:clean')->daily()->at('00:00'); $schedule->command('backup:run')->daily()->at('01:00'); + + $schedule->job(TaskStartJob::class)->everyFiveMinutes(); } /** @@ -36,7 +39,7 @@ protected function schedule(Schedule $schedule) */ protected function commands() { - $this->load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); require base_path('routes/console.php'); } diff --git a/app/Http/Controllers/Api/V1/TaskCTRL.php b/app/Http/Controllers/Api/V1/TaskCTRL.php new file mode 100644 index 0000000..e530fa0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TaskCTRL.php @@ -0,0 +1,138 @@ +get('perPage', 10); + $sortBy = $request->get('sortBy', 'id'); + $sortType = $request->get('sortType', 'asc'); + + $cacheKey = "tasks_{$perPage}_{$sortBy}_{$sortType}"; + + $tasks = Cache::remember($cacheKey, now()->addMinutes(10), function () use ($sortBy, $sortType, $perPage) { + return Model::withTrashed()->with('task_status')->orderBy($sortBy, $sortType)->paginate($perPage); + }); + + return response()->json([ + 'success' => true, + 'message' => 'Task index', + 'data' => $tasks, + ]); + } + + public function getAll(): JsonResponse + { + $tasks = Model::with([ + 'task_status', + 'board', + 'user', + 'task_reviewers' + ])->get(); + + return response()->json([ + 'success' => true, + 'message' => 'Task index', + 'data' => $tasks, + ]); + } + + public function show($id): JsonResponse + { + $task = Model::with([ + 'task_status', + 'board', + 'user', + 'task_reviewers' + ])->findOrFail($id); + return response()->json([ + 'success' => true, + 'message' => 'Task show', + 'data' => $task, + ]); + } + + public function store(TaskRequest $request): JsonResponse + { + $task = new Model(); + $task->fill($request->toArray()); + $task->user_id = auth()->user()->id; + $task->start_date = $request->input('start_date') ? (Carbon::parse($request->input('start_date'))->format('Y-m-d H:i:s')) : null; + $task->due_date = $request->input('due_date') ? (Carbon::parse($request->input('due_date'))->format('Y-m-d H:i:s')) : null; + $task->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Task created', + 'data' => $task, + ]); + } + + public function update(TaskRequest $request, $id): JsonResponse + { + $task = Model::findOrFail($id); + $task->fill($request->toArray()); + $task->user_id = auth()->user()->id; + $task->start_date = $request->input('start_date') ? (Carbon::parse($request->input('start_date'))->format('Y-m-d H:i:s')) : null; + $task->due_date = $request->input('due_date') ? (Carbon::parse($request->input('due_date'))->format('Y-m-d H:i:s')) : null; + $task->update(); + + return response()->json([ + 'success' => true, + 'message' => 'Task updated', + 'data' => $task, + ]); + } + + /** + * @throws Exception + */ + public function destroy($id): JsonResponse + { + $task = Model::withTrashed()->findOrFail($id); + + if (request()->input('is_recovery') === 'true') { + $task->restore(); + return response()->json([ + 'success' => true, + 'message' => 'Task restored', + 'data' => $task, + ]); + } + + $task->delete(); + return response()->json([ + 'success' => true, + 'message' => 'Task deleted', + 'data' => null, + ]); + } + + public function statusChange($id, Request $request): JsonResponse + { + $request->validate([ + 'status_id' => 'required|int|exists:task_statuses,id', + ]); + + $task = Model::findOrFail($id); + $task->task_status_id = $request->input('status_id'); + $task->save(); + + return response()->json([ + 'success' => true, + 'message' => 'Task status changed', + 'data' => $task, + ]); + } +} diff --git a/app/Http/Requests/Api/V1/TaskRequest.php b/app/Http/Requests/Api/V1/TaskRequest.php new file mode 100644 index 0000000..3ca89fa --- /dev/null +++ b/app/Http/Requests/Api/V1/TaskRequest.php @@ -0,0 +1,33 @@ + ['nullable', 'int', 'exists:App\Board,id'], + 'task_status_id' => ['nullable', 'int', 'exists:App\TaskStatus,id'], + 'title' => ['required', 'string'], + 'description' => ['nullable', 'string'], + 'start_date' => ['nullable', 'date_format:Y-m-d H:i'], + 'due_date' => ['nullable', 'date_format:Y-m-d H:i', 'after_or_equal:start_date'], + ]; + } + + protected function prepareForValidation() + { + $this->merge([ + 'board_id' => $this->route('board_id', 1), + 'task_status_id' => $this->route('task_status_id', 1), + ]); + } +} diff --git a/app/Jobs/TaskStartJob.php b/app/Jobs/TaskStartJob.php new file mode 100644 index 0000000..35202c9 --- /dev/null +++ b/app/Jobs/TaskStartJob.php @@ -0,0 +1,47 @@ +where('task_status_id', 1)->get(); + $tasks->each(function ($task) { + $task->update(['task_status_id' => 2]); + }); + + Log::info('TaskStartJob executed successfully.'); + + } catch (\Exception $e) { + // Handle any exceptions that may occur during the task execution + Log::error('TaskStartJob failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Library/Helpers/StringHelper.php b/app/Library/Helpers/StringHelper.php new file mode 100644 index 0000000..cf72d4e --- /dev/null +++ b/app/Library/Helpers/StringHelper.php @@ -0,0 +1,94 @@ +tr_strtoupper($value); + }); + } + + if (!Str::hasMacro('trLowercase')) { + Str::macro('trLowercase', function (string $value): string { + return $this->tr_strtolower($value); + }); + } + + if (!Str::hasMacro('trUppercaseFirst')) { + Str::macro('trUppercaseFirst', function (string $value): string { + return $this->tr_uppercase_first($value); + }); + } + + if (!Str::hasMacro('trLowercaseFirst')) { + Str::macro('trLowercaseFirst', function (string $value): string { + return $this->tr_lowercase_first($value); + }); + } + + if (!Str::hasMacro('trUppercaseWords')) { + Str::macro('trUppercaseWords', function (string $value): string { + return $this->tr_uppercase_words($value); + }); + } + } + + + private function tr_strtoupper(string $value): string + { + return mb_strtoupper(str_replace('i', 'İ', $value), 'UTF-8'); + } + + + private function tr_strtolower(string $value): string + { + return mb_strtolower(str_replace(['İ', 'I'], ['i', 'ı'], $value), 'UTF-8'); + } + + + private function tr_uppercase_first(string $value): string + { + $tmp = preg_split('//u', $value, 2, PREG_SPLIT_NO_EMPTY); + $more = $tmp[1] ?? ''; + + return mb_convert_case($this->tr_strtoupper($tmp[0]), MB_CASE_TITLE, 'UTF-8') . $this->tr_strtolower($more); + } + + + private function tr_lowercase_first(string $value): string + { + $tmp = preg_split('//u', $value, 2, PREG_SPLIT_NO_EMPTY); + $more = $tmp[1] ?? ''; + + return mb_convert_case($this->tr_strtolower($tmp[0]), MB_CASE_LOWER, 'UTF-8') . $more; + } + + private function tr_uppercase_words(string $value): string + { + $result = ''; + foreach (explode(' ', $value) as $word) { + if ($word === ' ') { + $result .= $word; + } else if (strlen($word) === 0) { + $result .= ' ' . $word; + } else { + $result .= ' ' . $this->tr_uppercase_first($word); + } + } + + return substr($result, 1); + } +} diff --git a/app/Notifications/TaskStatusChangeNotification.php b/app/Notifications/TaskStatusChangeNotification.php new file mode 100644 index 0000000..0eb76bc --- /dev/null +++ b/app/Notifications/TaskStatusChangeNotification.php @@ -0,0 +1,54 @@ +task = $task; + $this->oldStatus = $oldStatus; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable): array + { + return ['database']; + } + + public function toDatabase($notifiable): array + { + $oldStatus = TaskStatus::findOrFail($this->oldStatus); + $newStatus = TaskStatus::findOrFail($this->task->status); + + return [ + 'task_id' => $this->task->id, + 'task_title' => $this->task->title, + 'task_description' => $this->task->description, + 'task_status_id' => $this->task->task_status_id, + 'start_date' => $this->task->start_date, + 'due_date' => $this->task->due_date, + 'message' => "Task status changed from {$oldStatus->name} to {$newStatus->name}", + ]; + } +} diff --git a/app/Observers/BoardAssignmentObserver.php b/app/Observers/BoardAssignmentObserver.php new file mode 100644 index 0000000..c0d6b71 --- /dev/null +++ b/app/Observers/BoardAssignmentObserver.php @@ -0,0 +1,8 @@ +getOriginal('name') !== $model->getAttribute('name')) { + $model->name = Str::trUppercaseWords(Str::cleanSpaces($model->name)); + } + } +} diff --git a/app/Observers/TaskObserver.php b/app/Observers/TaskObserver.php new file mode 100644 index 0000000..fe9b659 --- /dev/null +++ b/app/Observers/TaskObserver.php @@ -0,0 +1,31 @@ +isDirty('task_status_id')) { + $oldStatusId = $model->getOriginal('task_status_id'); + $newStatusId = $model->getAttribute('task_status_id'); + + if ($oldStatusId !== null && $newStatusId !== null) { + $model->user->notify(new TaskStatusChangeNotification($model, $oldStatusId)); + + $model->task_reviewers->each(function ($reviewer) use ($model, $oldStatusId) { + $reviewer->notify(new TaskStatusChangeNotification($model, $oldStatusId)); + }); + } + + } + } +} diff --git a/app/Observers/TaskReviewerObserver.php b/app/Observers/TaskReviewerObserver.php new file mode 100644 index 0000000..7a7481c --- /dev/null +++ b/app/Observers/TaskReviewerObserver.php @@ -0,0 +1,8 @@ +getOriginal('name') !== $model->getAttribute('name')) { + $model->name = Str::trUppercaseWords(Str::cleanSpaces($model->name)); + } + } +} diff --git a/app/Providers/StringMacroServiceProvider.php b/app/Providers/StringMacroServiceProvider.php new file mode 100644 index 0000000..23f3f57 --- /dev/null +++ b/app/Providers/StringMacroServiceProvider.php @@ -0,0 +1,29 @@ +belongsTo(Board::class); + } + + public function task_status(): BelongsTo + { + return $this->belongsTo(TaskStatus::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function task_reviewers(): HasMany + { + return $this->hasMany(TaskReviewer::class); + } +} diff --git a/app/TaskReviewer.php b/app/TaskReviewer.php new file mode 100644 index 0000000..84ae7e9 --- /dev/null +++ b/app/TaskReviewer.php @@ -0,0 +1,34 @@ +belongsTo(User::class); + } + + public function task(): BelongsTo + { + return $this->belongsTo(Task::class); + } +} diff --git a/app/TaskStatus.php b/app/TaskStatus.php new file mode 100644 index 0000000..d03611e --- /dev/null +++ b/app/TaskStatus.php @@ -0,0 +1,25 @@ +getKey(); + return 'users/' . $this->getKey(); } /** @@ -61,8 +61,13 @@ public function getDirectory() : string * * @return array */ - public function getUploadAttributes() : array + public function getUploadAttributes(): array { return $this->uploadAttributes; } + + public function task_reviewers(): HasMany + { + return $this->hasMany(TaskReviewer::class); + } } diff --git a/composer.json b/composer.json index 6d9fa27..8fba83f 100755 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "laravel/tinker": "^1.0", "league/flysystem-aws-s3-v3": "^1.0", "spatie/laravel-backup": "^6.1", - "tymon/jwt-auth": "1.0.0-rc.5", + "tymon/jwt-auth": "^1.0", "valorin/pwned-validator": "^1.2" }, "require-dev": { @@ -41,7 +41,8 @@ "database/factories" ], "files": [ - "app/Utils/Helper.php" + "app/Utils/Helper.php", + "app/Library/Helpers/StringHelper.php" ], "psr-4": { "App\\": "app/" diff --git a/config/app.php b/config/app.php index 857d302..eb8f872 100755 --- a/config/app.php +++ b/config/app.php @@ -185,6 +185,11 @@ App\Providers\EventServiceProvider::class, App\Providers\TelescopeServiceProvider::class, App\Providers\RouteServiceProvider::class, + + /* + * Custom Service Providers... + */ + App\Providers\StringMacroServiceProvider::class, ], /* diff --git a/config/database.php b/config/database.php index a4d20dd..127bce2 100755 --- a/config/database.php +++ b/config/database.php @@ -56,6 +56,23 @@ 'engine' => null, ], + 'laravel-react-admin' => [ + 'driver' => 'mysql', + 'url' => env('DATABASE_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel-react-admin'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', 'secret'), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + ], + 'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), diff --git a/database/migrations/2025_01_01_000001_create_task_statuses_table.php b/database/migrations/2025_01_01_000001_create_task_statuses_table.php new file mode 100644 index 0000000..0ff3611 --- /dev/null +++ b/database/migrations/2025_01_01_000001_create_task_statuses_table.php @@ -0,0 +1,48 @@ +getConnection())->create($this->table, function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + $table->softDeletes(); + }); + + $this->seed(); + } + + protected function seed(): void + { + $columns = collect($this->columns); + $chunks = collect($this->data)->map(function ($item) use ($columns) { + return $columns->combine($item)->toArray(); + })->chunk(1000); + + foreach ($chunks as $chunk) { + DB::connection($this->getConnection())->table($this->table)->insert($chunk->toArray()); + } + } + + public function down() + { + Schema::connection($this->getConnection())->dropIfExists($this->table); + } +} diff --git a/database/migrations/2025_02_02_000001_create_boards_table.php b/database/migrations/2025_02_02_000001_create_boards_table.php new file mode 100644 index 0000000..902c2aa --- /dev/null +++ b/database/migrations/2025_02_02_000001_create_boards_table.php @@ -0,0 +1,27 @@ +getConnection())->create($this->table, function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::connection($this->getConnection())->dropIfExists($this->table); + } +} diff --git a/database/migrations/2025_02_02_000002_create_board_assignments_table.php b/database/migrations/2025_02_02_000002_create_board_assignments_table.php new file mode 100644 index 0000000..c6f7d5b --- /dev/null +++ b/database/migrations/2025_02_02_000002_create_board_assignments_table.php @@ -0,0 +1,42 @@ +getConnection())->create($this->table, function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('board_id'); + $table->unsignedInteger('user_id'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + $table->softDeletes(); + + // Add unique constraint to prevent duplicate assignments + $table->unique(['board_id', 'user_id'], 'unique_board_user_assignment'); + }); + + // Add foreign key constraints + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->foreign('board_id')->references('id')->on('boards')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down() + { + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->dropForeign(['board_id']); + $table->dropForeign(['user_id']); + $table->dropUnique('unique_board_user_assignment'); + }); + Schema::connection($this->getConnection())->dropIfExists($this->table); + } +} diff --git a/database/migrations/2025_02_03_000001_create_tasks_table.php b/database/migrations/2025_02_03_000001_create_tasks_table.php new file mode 100644 index 0000000..7d7db51 --- /dev/null +++ b/database/migrations/2025_02_03_000001_create_tasks_table.php @@ -0,0 +1,48 @@ +getConnection())->create($this->table, function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('board_id'); + $table->unsignedBigInteger('task_status_id'); + $table->unsignedInteger('user_id')->nullable(); + $table->string('title'); + $table->text('description')->nullable(); + $table->dateTime('start_date')->nullable(); + $table->dateTime('due_date')->nullable(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + $table->softDeletes(); + }); + + // Add foreign key constraints + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->foreign('board_id')->references('id')->on('boards')->onDelete('cascade'); + $table->foreign('task_status_id')->references('id')->on('task_statuses')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); + }); + } + + public function down() + { + // Drop foreign key constraints + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->dropForeign(['board_id']); + $table->dropForeign(['task_status_id']); + $table->dropForeign(['user_id']); + }); + + Schema::connection($this->getConnection())->dropIfExists($this->table); + } +} diff --git a/database/migrations/2025_02_03_000002_create_task_reviewers_table.php b/database/migrations/2025_02_03_000002_create_task_reviewers_table.php new file mode 100644 index 0000000..70a0249 --- /dev/null +++ b/database/migrations/2025_02_03_000002_create_task_reviewers_table.php @@ -0,0 +1,45 @@ +getConnection())->create($this->table, function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedInteger('user_id'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + $table->softDeletes(); + + // Add unique constraint to prevent duplicate reviewers + $table->unique(['task_id', 'user_id'], 'unique_task_user_reviewer'); + }); + + // Add foreign key constraints + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->foreign('task_id')->references('id')->on('tasks')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + public function down() + { + // Drop foreign key constraints + Schema::connection($this->getConnection())->table($this->table, function (Blueprint $table) { + $table->dropForeign(['task_id']); + $table->dropForeign(['user_id']); + $table->dropUnique('unique_task_user_reviewer'); + }); + + Schema::connection($this->getConnection())->dropIfExists($this->table); + } +} diff --git a/database/seeds/BoardAssignmentsTableSeeder.php b/database/seeds/BoardAssignmentsTableSeeder.php new file mode 100644 index 0000000..d51e001 --- /dev/null +++ b/database/seeds/BoardAssignmentsTableSeeder.php @@ -0,0 +1,34 @@ +command->error('No boards found. Please run the BoardsTableSeeder first.'); + return; + } + + $users = User::all(); + + foreach ($users as $user) { + $boardAssignment = new BoardAssignment; + $boardAssignment->user_id = $user->id; + $boardAssignment->board_id = $board->id; + $boardAssignment->save(); + } + + } +} diff --git a/database/seeds/BoardsTableSeeder.php b/database/seeds/BoardsTableSeeder.php new file mode 100644 index 0000000..b17e8bc --- /dev/null +++ b/database/seeds/BoardsTableSeeder.php @@ -0,0 +1,19 @@ +name = 'Task Board'; + $board->save(); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index ea7ee53..c149ef9 100755 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -12,5 +12,7 @@ class DatabaseSeeder extends Seeder public function run() { $this->call(UsersTableSeeder::class); + $this->call(BoardsTableSeeder::class); + $this->call(BoardAssignmentsTableSeeder::class); } } diff --git a/package.json b/package.json index 6f35277..cee5de5 100755 --- a/package.json +++ b/package.json @@ -25,8 +25,11 @@ "moment": "^2.24.0", "prop-types": "^15.7.2", "react": "^16.10.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^16.10.1", "react-dropzone": "^10.1.9", + "react-kanban-dnd": "^0.3.0", "react-loading-skeleton": "^1.1.2", "react-router-dom": "^4.3.1", "yup": "^0.26.10" diff --git a/readme.md b/readme.md index db1105f..629e462 100755 --- a/readme.md +++ b/readme.md @@ -1,131 +1,120 @@ -# About Laravel React Admin +# Laravel React Task Management System -This is a scaffolding project that comes with authentication & -users CRUD. It is a Single Page Application (SPA) built on top of [React.js](https://reactjs.org) -in the frontend & [Laravel](https://laravel.com) in the backend. +## Overview -

- - Build Status - - - Latest Version - - - License - -

+This system provides a comprehensive task management solution with: -## Screenshots +- Dashboard for task operations (CRUD) +- Task creation/editing forms +- Kanban board visualization +- Drag-and-drop functionality +- Real-time notifications +- Automated task scheduling -[![Laravel React Admin](https://user-images.githubusercontent.com/42484695/65893634-d9534700-e3da-11e9-84a1-20de8c6b4ced.png)](https://github.com/palonponjovertlota/laravel-react-admin) +## Key Features -[![Laravel React Admin](https://user-images.githubusercontent.com/42484695/65893636-d9534700-e3da-11e9-91c1-0d098a5e4301.png)](https://github.com/palonponjovertlota/laravel-react-admin) +### 1. Task Management Module -## Features +**Components:** -- Progressive Web App (PWA) -- Supports multiple locales -- Stateless authentication system -- Datatables with server-side pagination, sorting & dynamic filtering -- Undo common actions -- [Docker](https://www.docker.com) ready -- [Image Intervention](http://image.intervention.io/) integration for image uploads -- Drag & drop file uploads. -- Supports dark mode. +- Task creation form (title, description, assignee, dates) +- Task listing with pagination +- Detailed task view +- Edit/Delete functionality -## Preview +**Technical Implementation:** -You can check out the [live preview](https://laravel-react-admin.herokuapp.com) +- Laravel REST API backend +- React frontend with Axios for API calls +- Form validation on both client and server sides +- Soft delete functionality with recovery option -## Quick Start +### 2. Kanban Board -1. Clone the repo `git clone https://github.com/palonponjovertlota/laravel-react-admin.git`. -2. Go to your project folder from your terminal. -3. Run: `composer install` and `npm install` to install application dependencies. -4. Copy the `env.example` file into a `.env` file and then configure based on your local setup. -5. Installation is done, you can now run: `php artisan serve` then `npm run watch`. -6. The project will run in this URL: (http://localhost:3000). +**Functionality:** -## Using Docker +- Visual status tracking (Todo/In Progress/Done) +- Drag-and-drop status updates +- Real-time board refresh +- Column customization -If you prefer [Docker](https://www.docker.com), there is a working setup provided to get you started in no time. -Check your local setup to make sure that running this app in docker will work correctly: +**Technical Implementation:** -### For Unix Based Operating Systems, Give It Proper Permissions +- React Beautiful DnD library +- Custom status transition logic +- Optimistic UI updates +- WebSocket integration for real-time sync -If you are a **Linux** / **Mac** user, make sure to give the application proper _file permissions_. The _php-fpm_ image uses `www-data` as its default user: +### 3. Notification System -``` -cd ~/your_projects_folder +**Features:** -sudo chown ${USER}:www-data -R laravel-react-admin -``` +- Email notifications on: + - Task assignment + - Status changes -### Localhost Should Be Freed +**Technical Implementation:** -Make sure that the address `127.0.0.1:80` usually `localhost` is available on the _host machine_. It must be assured that **apache2**, **nginx** or any http webserver out there is not running on the _host machine_ to avoid address and port collision. +- Laravel Notifications +- Queue workers for async delivery -### Add a custom host +## Test Cases Overview -To make this app run on **docker** you must add a custom host address pointing to `localhost` or `127.0.0.1`. +### Task Management -### You are good to go +| Test Case | Objective | Verification Steps | +|---------------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **TC-1: Task Creation** | Verify successful task creation | 1. Open task creation form
2. Fill required fields (title)
3. Submit the form
4. Verify record creation in database
5. Confirm new task appears in the task list | +| **TC-2: Task Update** | Ensure proper task editing functionality | 1. Select a task from the list
2. Modify title/description/status
3. Save changes
4. Verify updated details on task view page
5. Check database record updates | +| **TC-3: Task Deletion** | Test task removal process | 1. Select task to delete
2. Click delete button
3. Confirm deletion dialog
4. Verify removal from task list
5. Check `deleted_at` timestamp in DB | +| **TC-4: Pagination** | Validate task list navigation | 1. Create 10+ test tasks
2. Set pagination to 5 items/page
3. Navigate between pages
4. Change "Items per page" value
5. Verify filtered results pagination | +| **TC-4.1: Task All List** | Ensure all tasks are displayed | 1. Create 10+ test tasks
2. Verify all tasks are displayed on one page
3. Check for performance issues with large datasets | -You can now run the _image_ using the `docker-compose up` and optionally pass the `--build` flag if you intend to build the image. The app can be visited here `http:your_custom_host_address`. +### Kanban Board -### Installing PHP & NPM dependencies +| Test Case | Objective | Verification Steps | +|-------------------------|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **TC-5: Drag-and-Drop** | Verify status change via UI | 1. Drag task from "Pending" column
2. Drop into "In Progress" column
3. Verify visual transition animation
4. Confirm status update in database
5. Check for status change notification | -In development, do note that all files inside this app is _bind-mounted_ into the container, **docker** will just use the existing directories, in our concern the `vendor` and `node_modules`. Here is an example of running a composer install command: `docker container exec -it laravel-react-admin-php-fpm composer install --no-interaction --no-plugins --no-scripts`. +### Real-Time Features -### Running Artisan Commands +| Test Case | Objective | Verification Steps | +|-------------------------|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| **TC-6: Notifications** | Test alert system functionality | 1. Change task status
2. Check in-app database | +| **TC-7: Auto Updates** | Validate scheduled transitions | 1. Set task start date to future
2. Wait for scheduled time
3. Verify automatic status change
4. Check associated notifications | -You can run any artisan commands directly into the `laravel-react-admin-php-fpm` container. Here is an example of a migration command: `docker container exec -it laravel-react-admin-php-fpm php artisan migrate:fresh --seed`. +### Performance -### What about Browsersync? +| Test Case | Objective | Verification Steps | +|-------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------| +| **TC-8: Caching** | Verify query optimization | 1. Request task list
2. Check Redis cache hits
3. Verify 60-second cache duration
4. Test cache invalidation on updates | -As we are bundling frontend assets with [webpack](https://webpack.js.org/) under the hood, you must specify the custom host address where the application runs in docker so that webpack can proxy that to be able to develop using docker. You can pass a `--env.proxy` flag when running for example the `npm run watch` command: `npm run watch -- --env.proxy=http:laravel-react-admin.test`. +## API Integration -### Using PhpMyAdmin +### 📌 API Endpoints -You could use **PhpMyAdmin** to browse your MySql database as it is included in this **Docker** integration. Just add a _Virtual Host_ that points to `127.0.0.1` & the _Domain_ should be the same with your custom host address: +The following endpoints are used in this module: -``` -// /etc/hosts +| Method | Endpoint | Description | +|--------|-----------------------------|------------------------------------| +| GET | `/api/v1/tasks` | Retrieve paginated task list | +| POST | `/api/v1/tasks` | Create new task | +| PUT | `/api/v1/tasks/{id}` | Update existing task | +| DELETE | `/api/v1/tasks/{id}` | Delete task (with recovery option) | +| PATCH | `/api/v1/tasks/{id}/status` | Change task status | +| GET | `/api/v1/tasks/all` | Get tasks list | -127.0.0.1 phpmyadmin.laravel-react-admin.test -``` +**Full API Reference**: See detailed documentation in [api-doc.md](./api-doc.md) -You could then visit **PhpMyAdmin** here: phpmyadmin.laravel-react-admin.test +### Usage Example -## Testing - -Run the tests with: - -``` -// If you have installed composer globally -composer test - -// This should also work -./vendor/bin/composer test -``` - -## Changelog - -Please see [CHANGELOG](https://github.com/palonponjovertlota/laravel-react-admin/blob/master/CHANGELOG.md) for more information on what has changed recently. - -## Contributing - -Please see [Contributing](https://github.com/palonponjovertlota/laravel-react-admin/blob/master/Contributing.md) for more details. - -## Security - -If you discover any security-related issues, please email [palonponjovertlota@gmail.com](mailto:palonponjovertlota@gmail.com) instead of using the issue tracker. - -## Credits - -- [@reeshkeed](https://github.com/reeshkeed) for designing the logo & design ideas. - -## License - -The MIT License (MIT). Please see [License File](https://github.com/palonponjovertlota/laravel-react-admin/blob/master/LICENSE) for more information. +```javascript +// Fetching tasks +const response = await axios.get('/api/v1/tasks', { + params: { + perPage: 10, + }, + headers: { + 'Authorization': `Bearer ${token}` + } +}); diff --git a/resources/js/models/Task.js b/resources/js/models/Task.js new file mode 100644 index 0000000..619ce47 --- /dev/null +++ b/resources/js/models/Task.js @@ -0,0 +1,129 @@ +import axios from 'axios'; + +export default class Task { + /** + * Fetch all Task list. + * + * @param {object} params + * + * @return {object} + */ + static async getAll(params = {}) { + const response = await axios.get('/api/v1/tasks/all', { + params, + }); + + if (response.status !== 200) { + return {}; + } + + return response.data.data; + } + + /** + * Fetch a paginated Task list. + * + * @param {object} params + * + * @return {object} + */ + static async paginated(params = {}) { + const response = await axios.get('/api/v1/tasks', { + params, + }); + + if (response.status !== 200) { + return {}; + } + + return response.data.data; + } + + /** + * Store a new Task. + * + * @param {object} attributes + * + * @return {object} + */ + static async store(attributes) { + const response = await axios.post('/api/v1/tasks', attributes); + + if (response.status !== 201) { + return {}; + } + + return response.data; + } + + /** + * Show a Task. + * + * @param {number} id + * + * @return {object} + */ + static async show(id) { + const response = await axios.get(`/api/v1/tasks/${id}`); + + if (response.status !== 200) { + return {}; + } + + return response.data; + } + + /** + * Update a Task. + * + * @param {number} id + * @param {object} attributes + * + * @return {object} + */ + static async update(id, attributes) { + const response = await axios.patch(`/api/v1/tasks/${id}`, attributes); + + if (response.status !== 200) { + return {}; + } + + return response.data; + } + + /** + * Delete a Task. + * + * @param {number} id + * + * @return {object} + */ + static async delete(id) { + const response = await axios.delete(`/api/v1/tasks/${id}`); + + if (response.status !== 200) { + return {}; + } + + return response.data; + } + + /** + * Restore a Task. + * + * @param {number} id + * + * @return {object} + */ + static async restore(id) { + const response = await axios.delete( + `/api/v1/tasks/${id}?is_recovery=true`, + ); + + if (response.status !== 200) { + return {}; + } + + return response.data; + } +} diff --git a/resources/js/routers/backoffice.js b/resources/js/routers/backoffice.js index 8d638ad..014e286 100644 --- a/resources/js/routers/backoffice.js +++ b/resources/js/routers/backoffice.js @@ -1,6 +1,7 @@ import { Home } from '../views/__backoffice'; import * as Settings from '../views/__backoffice/settings'; import * as Users from '../views/__backoffice/users'; +import * as Tasks from '../views/__backoffice/task-management'; const resources = [ { @@ -27,6 +28,36 @@ const resources = [ return route; }); +const taskManagementRoutes = [ + { + name: 'tasks.index', + path: '/tasks', + component: Tasks.List, + }, + + { + name: 'tasks.create', + path: '/tasks/create', + component: Tasks.Create, + }, + + { + name: 'tasks.edit', + path: '/tasks/:id/edit', + component: Tasks.Edit, + }, + + { + name: 'tasks.kanban', + path: '/tasks/kanban', + component: Tasks.Kanban, + }, +].map(route => ({ + ...route, + name: `task-management.${route.name}`, + path: `/task-management${route.path}`, +})); + export default [ { name: 'home', @@ -47,6 +78,7 @@ export default [ }, ...resources, + ...taskManagementRoutes, ].map(route => { route.name = `backoffice.${route.name}`; route.auth = true; diff --git a/resources/js/views/__backoffice/partials/Sidebar.js b/resources/js/views/__backoffice/partials/Sidebar.js index 47ccc26..7d28326 100644 --- a/resources/js/views/__backoffice/partials/Sidebar.js +++ b/resources/js/views/__backoffice/partials/Sidebar.js @@ -1,12 +1,12 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import { Divider, Drawer, - IconButton, Hidden, + IconButton, List, ListItem, ListItemIcon, @@ -17,6 +17,7 @@ import { } from '@material-ui/core'; import { + Assignment as TaskIcon, ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon, Dashboard as DashboardIcon, @@ -24,6 +25,7 @@ import { People as PeopleIcon, Security as SecurityIcon, ShowChart as ShowChartIcon, + ViewColumn as ViewColumnIcon, } from '@material-ui/icons'; import { APP } from '../../../config'; @@ -103,6 +105,43 @@ function Sidebar(props) { ], }, + { + name: Lang.get('navigation.task-management'), + id: 'task-management', + links: [ + { + name: Lang.get('navigation.tasks'), + icon: ( + + + + ), + path: NavigationUtils.route( + 'backoffice.task-management.tasks.index', + ), + }, + { + name: Lang.get('navigation.kanban'), + icon: ( + + + + ), + path: NavigationUtils.route( + 'backoffice.task-management.tasks.kanban', + ), + }, + ], + }, + { name: 'Analytics', id: 'analytics', diff --git a/resources/js/views/__backoffice/task-management/Create.js b/resources/js/views/__backoffice/task-management/Create.js new file mode 100644 index 0000000..72e30cc --- /dev/null +++ b/resources/js/views/__backoffice/task-management/Create.js @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; + +import { Paper, Typography, withStyles } from '@material-ui/core'; +import Task from '../../../models/Task'; +import { LinearIndeterminate } from '../../../ui/Loaders'; +import { Master as MasterLayout } from '../layouts'; + +import { Task as TaskForm } from './Forms'; + +function Create(props) { + const [loading, setLoading] = useState(false); + const [activeStep, setActiveStep] = useState(0); + const [formValues, setFormValues] = useState([]); + const [task, setTask] = useState({}); + const [message, setMessage] = useState({}); + + /** + * This should return back to the previous step. + * + * @return {undefined} + */ + const handleBack = () => { + setActiveStep(activeStep - 1); + }; + + /** + * Handle form submit, this should send an API response + * to create a task. + * + * @param {object} values + * + * @param {object} form + * + * @return {undefined} + */ + const handleSubmit = async (values, { setSubmitting, setErrors }) => { + setSubmitting(false); + + // Stop here as it is the last step... + if (activeStep === 2) { + return; + } + + setLoading(true); + + try { + let previousValues = {}; + + // Merge the form values here. + if (activeStep === 1) { + previousValues = formValues.reduce((prev, next) => { + return { ...prev, ...next }; + }); + } + + // Instruct the API the current step. + values.step = activeStep; + + const task = await Task.store({ ...previousValues, ...values }); + + // After persisting the previous values. Move to the next step... + let newFormValues = [...formValues]; + newFormValues[activeStep] = values; + + if (activeStep === 1) { + setMessage({ + type: 'success', + body: Lang.get('resources.created', { + name: 'Task', + }), + closed: () => setMessage({}), + }); + } + + setLoading(false); + setFormValues(newFormValues); + setTask(task); + setActiveStep(activeStep + 1); + } catch (error) { + if (!error.response) { + throw new Error('Unknown error'); + } + + const { errors } = error.response.data; + + setErrors(errors); + + setLoading(false); + } + }; + + const { classes, ...other } = props; + const { history } = props; + + const steps = ['Task']; + + const renderForm = () => { + const defaultProfileValues = { + firstname: '', + middlename: '', + lastname: '', + gender: '', + birthdate: null, + address: '', + }; + + switch (activeStep) { + case 0: + return ( + + ); + + default: + throw new Error('Unknown step!'); + } + }; + + return ( + +
+ {loading && } + + +
+ + Task Creation + + + {/**/} + {/* {steps.map(name => (*/} + {/* */} + {/* {name}*/} + {/* */} + {/* ))}*/} + {/**/} + + {renderForm()} +
+
+
+
+ ); +} + +const styles = theme => ({ + pageContentWrapper: { + width: '100%', + marginTop: theme.spacing.unit * 3, + minHeight: '75vh', + overflowX: 'auto', + }, + + pageContent: { + padding: theme.spacing.unit * 3, + }, +}); + +export default withStyles(styles)(Create); diff --git a/resources/js/views/__backoffice/task-management/Edit.js b/resources/js/views/__backoffice/task-management/Edit.js new file mode 100644 index 0000000..1fa468d --- /dev/null +++ b/resources/js/views/__backoffice/task-management/Edit.js @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; + +import { Paper, Typography, withStyles } from '@material-ui/core'; +import Task from '../../../models/Task'; +import { LinearIndeterminate } from '../../../ui/Loaders'; +import { Master as MasterLayout } from '../layouts'; + +import { Task as TaskForm } from './Forms'; + +function Edit(props) { + const [loading, setLoading] = useState(false); + const [formValues, setFormValues] = useState([]); + const [task, setTask] = useState({}); + const [message, setMessage] = useState({}); + + /** + * Task verisini API'den getir. + * + * @param {number} id + */ + const fetchTask = async id => { + setLoading(true); + + try { + const taskData = await Task.show(id); + setTask(taskData); + setLoading(false); + } catch (error) { + setLoading(false); + } + }; + + useEffect(() => { + const { params } = props.match; + fetchTask(params.id); + }, []); + + /** + * Form gönderildiğinde task güncelle. + * + * @param {object} values + * @param {object} formikHelpers + */ + const handleSubmit = async (values, { setSubmitting, setErrors }) => { + setSubmitting(false); + setLoading(true); + + try { + const updatedTask = await Task.update(task.id, values); + + setMessage({ + type: 'success', + body: Lang.get('resources.updated', { name: 'Task' }), + closed: () => setMessage({}), + }); + + setTask(updatedTask); + setFormValues([values]); + setLoading(false); + + // Dilersen başarılı güncellemeden sonra yönlendirme yapılabilir: + // props.history.push(NavigationUtils.route('backoffice.tasks.index')); + } catch (error) { + if (!error.response) { + throw new Error('Bilinmeyen hata'); + } + + const { errors } = error.response.data; + setErrors(errors); + setLoading(false); + } + }; + + const { classes, ...other } = props; + + const defaultTaskValues = { + title: '', + description: '', + due_date: '', + status: '', + }; + + return ( + +
+ {loading && } + + +
+ + Task Düzenleme + + + +
+
+
+
+ ); +} + +const styles = theme => ({ + pageContentWrapper: { + width: '100%', + marginTop: theme.spacing.unit * 3, + minHeight: '75vh', + overflowX: 'auto', + }, + + pageContent: { + padding: theme.spacing.unit * 3, + }, +}); + +export default withStyles(styles)(Edit); diff --git a/resources/js/views/__backoffice/task-management/Forms/Task.js b/resources/js/views/__backoffice/task-management/Forms/Task.js new file mode 100644 index 0000000..64e2cc4 --- /dev/null +++ b/resources/js/views/__backoffice/task-management/Forms/Task.js @@ -0,0 +1,335 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form, Formik } from 'formik'; +import * as Yup from 'yup'; + +import { + Button, + FormControl, + FormHelperText, + Grid, + Input, + InputLabel, + MenuItem, + Select, + withStyles, +} from '@material-ui/core'; + +import { DatePicker, MuiPickersUtilsProvider } from 'material-ui-pickers'; +import MomentUtils from '@date-io/moment'; +import moment from 'moment'; + +const Task = props => { + const { classes, values, handleSubmit } = props; + + return ( + { + let mappedValues = {}; + let valuesArray = Object.values(values); + + // Format values specially the object ones (i.e Moment) + Object.keys(values).forEach((filter, key) => { + if ( + valuesArray[key] !== null && + typeof valuesArray[key] === 'object' && + valuesArray[key].hasOwnProperty('_isAMomentObject') + ) { + mappedValues[filter] = moment(valuesArray[key]).format( + 'YYYY-MM-DD HH:mm', + ); + + return; + } + + mappedValues[filter] = valuesArray[key]; + }); + + await handleSubmit(mappedValues, form); + }} + validateOnBlur={false} + > + {({ + values, + errors, + submitCount, + isSubmitting, + handleChange, + setFieldValue, + }) => ( +
+ + + 0 && + errors.hasOwnProperty('title') + } + > + + Title{' '} + * + + + + + {submitCount > 0 && + errors.hasOwnProperty('title') && ( + + {errors.title} + + )} + + + + + 0 && + errors.hasOwnProperty('user_id') + } + > + + Assigned User{' '} + + + + + {submitCount > 0 && + errors.hasOwnProperty('user_id') && ( + + {errors.user_id} + + )} + + + + + + + 0 && + errors.hasOwnProperty('task_status_id') + } + > + + Status{' '} + + + + + {submitCount > 0 && + errors.hasOwnProperty('task_status_id') && ( + + {errors.task_status_id} + + )} + + + + + 0 && + errors.hasOwnProperty('start_date') + } + > + + + setFieldValue('start_date', date) + } + format="YYYY-MM-DD HH:mm" + keyboard + clearable + disableFuture + /> + + + {submitCount > 0 && + errors.hasOwnProperty('start_date') && ( + + {errors.start_date} + + )} + + + + + 0 && + errors.hasOwnProperty('due_date') + } + > + + + setFieldValue('due_date', date) + } + format="YYYY-MM-DD HH:mm" + maxDate={moment() + .subtract(10, 'y') + .subtract(10, 'd') + .format('YYYY-MM-DD HH:mm')} + keyboard + clearable + disableFuture + /> + + + {submitCount > 0 && + errors.hasOwnProperty('due_date') && ( + + {errors.due_date} + + )} + + + + + + + 0 && + errors.hasOwnProperty('description') + } + > + + Description{' '} + + + + + {submitCount > 0 && + errors.hasOwnProperty('description') && ( + + {errors.description} + + )} + + + + +
+ + + + + + + + )} + + ); +}; + +Task.propTypes = { + values: PropTypes.object.isRequired, + handleSubmit: PropTypes.func.isRequired, +}; + +const styles = theme => ({ + formControl: { + minWidth: '100%', + }, + + required: { + color: theme.palette.error.main, + }, +}); + +export default withStyles(styles)(Task); diff --git a/resources/js/views/__backoffice/task-management/Forms/index.js b/resources/js/views/__backoffice/task-management/Forms/index.js new file mode 100644 index 0000000..aa10057 --- /dev/null +++ b/resources/js/views/__backoffice/task-management/Forms/index.js @@ -0,0 +1,3 @@ +import loadable from '@loadable/component'; + +export const Task = loadable(() => import('./Task')); diff --git a/resources/js/views/__backoffice/task-management/Kanban.js b/resources/js/views/__backoffice/task-management/Kanban.js new file mode 100644 index 0000000..b7122f8 --- /dev/null +++ b/resources/js/views/__backoffice/task-management/Kanban.js @@ -0,0 +1,115 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Task from '../../../models/Task'; +import { Master as MasterLayout } from '../layouts'; +import * as NavigationUtils from '../../../helpers/Navigation'; +import { Label, Value, Wrapper } from 'react-kanban-dnd/docs/components'; +import ReactKanban from 'react-kanban-dnd/src'; + +function Kanban(props) { + const statusLabels = { + todo: 'Pending', + in_progress: 'In Progress', + done: 'Completed', + }; + + const statusList = ['todo', 'in_progress', 'done']; + + const [tasks, setTasks] = useState([]); + const { ...childProps } = props; + const { history } = props; + + const fetchTasks = async () => { + const res = await Task.getAll(); + setTasks(res.data.data); + }; + + useEffect(() => { + fetchTasks(); + }, []); + + const handleDrop = async (task, newStatus) => { + if (task.status !== newStatus) { + await Task.update(task.id, { ...task, status: newStatus }); + + // 5 saniye sonra verileri güncelle + setTimeout(() => { + fetchTasks(); + }, 5000); + } + }; + + const groupTasksByStatus = () => { + return statusList.map((status, statusKey) => ({ + id: status, + title: statusLabels[status], + rows: tasks.filter(task => task.task_status_id === statusKey + 1), + })); + }; + + const tabs = useMemo( + () => [ + { + name: 'Kanban', + active: true, + }, + ], + [], + ); + + const primaryAction = useMemo( + () => ({ + text: Lang.get('resources.create', { name: 'Task' }), + clicked: () => + history.push( + NavigationUtils.route( + 'backoffice.task-management.tasks.create', + ), + ), + }), + [history], + ); + + const handleKanbanDrop = ({ source, destination }) => { + const movedTask = tasks.find(task => task.id === source.id); + if (!movedTask || !destination) return; + + const newStatus = destination.droppableId; + + handleDrop(movedTask, newStatus); + }; + + const renderCard = row => ( + + + + {row.title} + + + {row.description} + + + ); + + return ( + + + + ); +} + +export default Kanban; diff --git a/resources/js/views/__backoffice/task-management/List.js b/resources/js/views/__backoffice/task-management/List.js new file mode 100644 index 0000000..6b48c8b --- /dev/null +++ b/resources/js/views/__backoffice/task-management/List.js @@ -0,0 +1,525 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Grid, IconButton, Tooltip } from '@material-ui/core'; + +import { + Delete as DeleteIcon, + Edit as EditIcon, + RestoreFromTrash as RestoreFromTrashIcon, +} from '@material-ui/icons'; + +import * as NavigationUtils from '../../../helpers/Navigation'; +import * as UrlUtils from '../../../helpers/URL'; +import { Table } from '../../../ui'; +import { Master as MasterLayout } from '../layouts'; +import Task from '../../../models/Task'; + +function List(props) { + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({}); + const [sorting, setSorting] = useState({ + by: 'title', + type: 'asc', + }); + const [filters, setFilters] = useState({}); + const [message, setMessage] = useState({}); + const [alert, setAlert] = useState({}); + + /** + * Event listener that is triggered when a resource delete button is clicked. + * This should prompt for confirmation. + * + * @param {string} resourceId + * + * @return {undefined} + */ + const handleDeleteClick = resourceId => { + setAlert({ + type: 'confirmation', + title: Lang.get('resources.delete_confirmation_title', { + name: 'Task', + }), + body: Lang.get('resources.delete_confirmation_body', { + name: 'Task', + }), + confirmText: Lang.get('actions.continue'), + confirmed: async () => await deleteTask(resourceId), + cancelled: () => setAlert({}), + }); + }; + + const handleRestoreClick = resourceId => { + setAlert({ + type: 'confirmation', + title: Lang.get('resources.restore_confirmation_title', { + name: 'Task', + }), + body: Lang.get('resources.restore_confirmation_body', { + name: 'Task', + }), + confirmText: Lang.get('actions.continue'), + confirmed: async () => await restoreTask(resourceId), + cancelled: () => setAlert({}), + }); + }; + + /** + * Event listener that is triggered when a filter is removed. + * This should re-fetch the resource. + * + * @param {string} key + * + * @return {undefined} + */ + const handleFilterRemove = async key => { + const newFilters = { ...filters }; + delete newFilters[key]; + + await fetchTasks({ + ...defaultQueryString(), + filters: newFilters, + }); + }; + + /** + * Event listener that is triggered when the filter form is submitted. + * This should re-fetch the resource. + * + * @param {object} values + * @param {object} form + * + * @return {undefined} + */ + const handleFiltering = async (values, { setSubmitting }) => { + setSubmitting(false); + + const newFilters = { + ...filters, + [`${values.filterBy}[${values.filterType}]`]: values.filterValue, + }; + + await fetchTasks({ + ...defaultQueryString(), + filters: newFilters, + }); + }; + + /** + * Event listener that is triggered when a sortable TableCell is clicked. + * This should re-fetch the resource. + * + * + * @param sortBy + * @param sortType + */ + const handleSorting = async (sortBy, sortType) => { + await fetchTasks({ + ...defaultQueryString(), + sortBy, + sortType, + }); + }; + + /** + * Event listener that is triggered when a Table Component's Page is changed. + * This should re-fetch the resource. + * + * @param {number} page + * + * @return {undefined} + */ + const handlePageChange = async page => { + await fetchTasks({ + ...defaultQueryString(), + page, + }); + }; + + /** + * Event listener that is triggered when a Table Component's Per Page is changed. + * This should re-fetch the resource. + * + * @param {number} perPage + * @param {number} page + * + * @return {undefined} + */ + const handlePerPageChange = async (perPage, page) => { + await fetchTasks({ + ...defaultQueryString(), + perPage, + page, + }); + }; + + /** + * This should send an API request to restore a deleted resource. + * + * @param {string} resourceId + * + * @return {undefined} + */ + const restoreTask = async resourceId => { + setLoading(true); + + try { + await Task.restore(resourceId); + await fetchTasks(); + + setLoading(false); + setAlert({}); + setMessage({ + type: 'success', + body: Lang.get('resources.restored', { + name: 'Task', + }), + closed: () => setMessage({}), + }); + } catch (error) { + setLoading(false); + setAlert({}); + setMessage({ + type: 'error', + body: Lang.get('resources.not_restored', { + name: 'Task', + }), + closed: () => setMessage({}), + actionText: Lang.get('actions.retry'), + action: () => restoreTask(resourceId), + }); + } + }; + + /** + * This should send an API request to delete a resource. + * + * @param {string} resourceId + * + * @return {undefined} + */ + const deleteTask = async resourceId => { + setLoading(true); + + try { + await Task.delete(resourceId); + await fetchTasks(); + + setLoading(false); + setAlert({}); + setMessage({ + type: 'success', + body: Lang.get('resources.deleted', { + name: 'Task', + }), + closed: () => setMessage({}), + actionText: Lang.get('actions.undo'), + action: () => restoreTask(resourceId), + }); + } catch (error) { + setLoading(false); + setAlert({}); + setMessage({ + type: 'error', + body: Lang.get('resources.not_deleted', { + name: 'Task', + }), + closed: () => setMessage({}), + actionText: Lang.get('actions.retry'), + action: () => deleteTask(resourceId), + }); + } + }; + + /** + * This should send an API request to fetch all resource. + * + * @param {object} params + * + * @return {undefined} + */ + const fetchTasks = async (params = {}) => { + setLoading(true); + + try { + const { + page, + perPage, + sortBy, + sortType, + filters: newFilters, + } = params; + + const queryParams = { + page, + perPage, + sortBy, + sortType, + ...newFilters, + }; + + const pagination = await Task.paginated(queryParams); + + setLoading(false); + setSorting({ + by: sortBy ? sortBy : sorting.by, + type: sortType ? sortType : sorting.type, + }); + setFilters(newFilters ? newFilters : filters); + setPagination(pagination); + setMessage({}); + } catch (error) { + setLoading(false); + } + }; + + /** + * This will provide the default sorting, pagination & filters from state. + * + * @return {object} + */ + const defaultQueryString = () => { + const { sortBy, sortType } = sorting; + const { current_page: page, per_page: perPage } = pagination; + + return { + sortBy, + sortType, + perPage, + page, + filters, + }; + }; + + /** + * This will update the URL query string via history API. + * + * @return {undefined} + */ + const updateQueryString = () => { + const { history, location } = props; + const { current_page: page, per_page: perPage } = pagination; + const { by: sortBy, type: sortType } = sorting; + + const queryString = UrlUtils.queryString({ + page, + perPage, + sortBy, + sortType, + ...filters, + }); + + history.push(`${location.pathname}${queryString}`); + }; + + /** + * Fetch data on initialize. + */ + useEffect(() => { + if (pagination.hasOwnProperty('data')) { + updateQueryString(); + + return; + } + + const { location } = props; + const queryParams = location.search + ? UrlUtils.queryParams(location.search) + : {}; + + const prevFilters = {}; + const queryParamValues = Object.values(queryParams); + + Object.keys(queryParams).forEach((param, key) => { + if (param.search(/\[*]/) > -1 && param.indexOf('_') < 0) { + prevFilters[param] = queryParamValues[key]; + } + }); + + fetchTasks({ + ...queryParams, + filters: prevFilters, + }); + }, [pagination.data]); + const { ...childProps } = props; + const { history } = props; + + const { + data: rawData, + total, + per_page: perPage, + current_page: page, + } = pagination; + + const primaryAction = useMemo( + () => ({ + text: Lang.get('resources.create', { name: 'Task' }), + clicked: () => + history.push( + NavigationUtils.route( + 'backoffice.task-management.tasks.create', + ), + ), + }), + [history], + ); + + const tabs = useMemo( + () => [ + { + name: 'List', + active: true, + }, + ], + [], + ); + + const columns = useMemo( + () => [ + { name: 'Title', property: 'title', sort: true }, + { name: 'Status', property: 'task_status_id', sort: true }, + { name: 'Start Date', property: 'start_date', sort: true }, + { name: 'End Date', property: 'due_date', sort: true }, + { + name: 'Actions', + property: 'actions', + filter: false, + sort: false, + }, + ], + [], + ); + + const data = useMemo(() => { + if (!rawData) return []; + + return rawData.map(task => ({ + title: ( + + {task.title} + + ), + task_status_id: ( + + {task.task_status.name} + + ), + start_date: ( + + {task.start_date} + + ), + due_date: ( + + {task.due_date} + + ), + actions: ( +
+ {task.deleted_at == null && ( + + + history.push( + NavigationUtils.route( + 'backoffice.task-management.tasks.edit', + { id: task.id }, + ), + ) + } + > + + + + )} + {task.deleted_at == null ? ( + + handleDeleteClick(task.id)} + > + + + + ) : ( + + handleRestoreClick(task.id)} + > + + + + )} +
+ ), + })); + }, [rawData, history, handleDeleteClick, handleRestoreClick]); + + return ( + + {!loading && data && ( + + handleSorting( + cellName, + sorting.type === 'asc' ? 'desc' : 'asc', + ) + } + page={parseInt(page)} + perPage={parseInt(perPage)} + onChangePage={handlePageChange} + onChangePerPage={handlePerPageChange} + onFilter={handleFiltering} + onFilterRemove={handleFilterRemove} + /> + )} + + ); +} + +export default List; diff --git a/resources/js/views/__backoffice/task-management/index.js b/resources/js/views/__backoffice/task-management/index.js new file mode 100644 index 0000000..6cc0f84 --- /dev/null +++ b/resources/js/views/__backoffice/task-management/index.js @@ -0,0 +1,6 @@ +import loadable from '@loadable/component'; + +export const List = loadable(() => import('./List')); +export const Create = loadable(() => import('./Create')); +export const Edit = loadable(() => import('./Edit')); +export const Kanban = loadable(() => import('./Kanban')); diff --git a/resources/lang/en/navigation.php b/resources/lang/en/navigation.php index 2a86497..fa94738 100644 --- a/resources/lang/en/navigation.php +++ b/resources/lang/en/navigation.php @@ -57,6 +57,9 @@ 'resources' => 'Resources', 'users' => 'Users', 'roles' => 'Roles', + 'task-management' => 'Task Management', + 'tasks' => 'Tasks', + 'kanban' => 'Kanban', 'citation' => 'Built with ❤️ by' ]; diff --git a/resources/lang/en/resources.php b/resources/lang/en/resources.php index 0a7ca9b..3b47b7a 100644 --- a/resources/lang/en/resources.php +++ b/resources/lang/en/resources.php @@ -15,11 +15,15 @@ 'show' => 'View :name', 'edit' => 'Edit :name', 'delete' => 'Delete :name', + 'restore' => 'Restore :name', 'edit_image' => "Edit :name's image", 'delete_confirmation_title' => 'You are deleting a :name', 'delete_confirmation_body' => "If not undone, the :name won't be recovered anymore.", + 'restore_confirmation_title' => 'You are restoring a :name', + 'restore_confirmation_body' => "If not undone, the :name will be recovered.", + 'fetched' => 'Fetched :total :names.', 'not_fetched' => 'Error fetching :names!', 'created' => ':name sucessfully created!', diff --git a/resources/lang/fil/navigation.php b/resources/lang/fil/navigation.php index a7de268..0c8f8ee 100644 --- a/resources/lang/fil/navigation.php +++ b/resources/lang/fil/navigation.php @@ -57,6 +57,9 @@ 'resources' => 'Mga mapagkukunan', 'users' => 'Mga gumagamit', 'roles' => 'Mga tungkulin', + 'task-management' => 'Pamamahala ng gawain', + 'task' => 'Gawain', + 'kanban' => 'Kanban', 'citation' => 'Binuo ng may ❤️ ni' ]; diff --git a/resources/lang/fil/resources.php b/resources/lang/fil/resources.php index 9d3804b..2af787d 100644 --- a/resources/lang/fil/resources.php +++ b/resources/lang/fil/resources.php @@ -15,11 +15,15 @@ 'show' => 'Tingnan ang :name', 'edit' => 'I-edit ang :name', 'delete' => 'Burahin ang :name', + 'restore' => 'Ibalik ang :name', 'edit_image' => "I-edit ang imahe ng :name", 'delete_confirmation_title' => 'Ikaw ay nagbubura ng isang :name', 'delete_confirmation_body' => "Kung maituloy, ang :name ay di na mare-recover pa.", + 'restore_confirmation_title' => 'Ikaw ay nagbabalik ng isang :name', + 'restore_confirmation_body' => "Kung maituloy, ang :name ay mare-recover.", + 'fetched' => 'Nakakuha ng :total :name.', 'not_fetched' => 'Error sa pagkuha ng mga :name!', 'created' => 'Ang :name ay matagumpay na nagawa!', diff --git a/routes/api.php b/routes/api.php index e198b48..139d494 100755 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,8 @@ | */ +use Illuminate\Support\Facades\Route; + Route::namespace('Api')->name('api.')->group(function () { Route::namespace('V1')->name('v1.')->prefix('v1')->group(function () { Route::namespace('Auth')->name('auth.')->prefix('auth')->group(function () { @@ -48,6 +50,12 @@ Route::delete('/', 'UsersController@destroyAvatar')->name('destroy'); }); }); + + Route::get('tasks/all', 'TaskCTRL@getAll')->name('tasks.all'); + Route::resource('tasks', 'TaskCTRL', ['except' => ['edit', 'create']]); + Route::prefix('tasks')->name('tasks.')->group(function () { + Route::patch('{task}/status-change', 'TaskCTRL@statusChange')->name('statusChange'); + }); }); }); });