diff --git a/backend/app/Http/Controllers/Api/BacklogController.php b/backend/app/Http/Controllers/Api/BacklogController.php index a976b8f..973c2fe 100644 --- a/backend/app/Http/Controllers/Api/BacklogController.php +++ b/backend/app/Http/Controllers/Api/BacklogController.php @@ -7,19 +7,29 @@ use App\Http\Requests\StoreBacklogItemRequest; use App\Http\Resources\BacklogItemResource; use App\Http\Resources\TaskResource; use App\Models\BacklogItem; +use App\Models\Project; use App\Models\Task; +use App\Services\ActivityLogService; +use App\Services\NotificationService; use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; class BacklogController extends Controller { - public function __construct(private readonly ResourceAccessService $resourceAccess) {} + public function __construct( + private readonly ResourceAccessService $resourceAccess, + private readonly NotificationService $notificationService, + private readonly ActivityLogService $activityLogService, + ) {} public function index(Request $request): JsonResponse { try { - $query = BacklogItem::with(['project', 'assignedSprint', 'creator']); + $query = BacklogItem::with(['project', 'assignedSprint', 'creator', 'convertedTask.assignee']); if (! $this->resourceAccess->canSeeAll($request->user())) { $query->where(function ($scope) use ($request) { $scope->where('created_by', $request->user()->id) @@ -40,6 +50,13 @@ class BacklogController extends Controller $query->where('priority', $request->priority); } + $archiveState = $request->input('archive_state', 'active'); + if ($archiveState === 'archived') { + $query->whereNotNull('archived_at'); + } elseif ($archiveState !== 'all') { + $query->whereNull('archived_at'); + } + $perPage = min(max((int) $request->input('per_page', 15), 1), 100); $sortBy = in_array($request->input('sort_by'), ['id', 'title', 'priority', 'status', 'created_at', 'updated_at'], true) ? $request->input('sort_by') @@ -96,6 +113,7 @@ class BacklogController extends Controller ); } $data['created_by'] = $request->user()->id; + $data['status'] = 'active'; $item = BacklogItem::create($data); $item->load(['project', 'assignedSprint', 'creator']); @@ -114,21 +132,33 @@ class BacklogController extends Controller public function update(Request $request, BacklogItem $backlogItem): JsonResponse { - try { - $request->validate([ - 'title' => 'sometimes|required|string|max:255', - 'description' => 'nullable|string', - 'type' => 'sometimes|required|string|max:50', - 'priority' => 'nullable|string|max:50', - 'estimated_effort' => 'nullable|numeric', - 'status' => 'nullable|string|max:50', - 'assigned_sprint_id' => 'nullable|exists:sprints,id', - ]); + if ($backlogItem->archived_at !== null) { + return response()->json([ + 'success' => false, + 'message' => 'بک‌لاگ آرشیوشده قابل ویرایش نیست.', + ], 422); + } - $backlogItem->update($request->only([ - 'title', 'description', 'type', 'priority', - 'estimated_effort', 'status', 'assigned_sprint_id', - ])); + $data = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'type' => 'sometimes|required|string|max:50', + 'project_id' => 'nullable|exists:projects,id', + 'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], + 'estimated_effort' => 'nullable|numeric', + 'assigned_sprint_id' => 'nullable|exists:sprints,id', + ]); + + if (array_key_exists('project_id', $data) && $data['project_id']) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخاب‌شده دسترسی ندارید.' + ); + } + + try { + $backlogItem->update([...$data, 'status' => 'active']); $backlogItem->load(['project', 'assignedSprint', 'creator']); return response()->json([ @@ -163,38 +193,102 @@ class BacklogController extends Controller public function convertToTask(Request $request, BacklogItem $backlogItem): JsonResponse { - try { - $request->validate([ - 'assignee_id' => 'nullable|exists:users,id', - 'priority' => 'nullable|string', - 'due_date' => 'nullable|date', + $data = $request->validate([ + 'assignee_id' => 'required|exists:users,id', + 'priority' => ['required', Rule::in(['low', 'medium', 'high', 'urgent'])], + 'start_date' => 'nullable|date', + 'due_date' => 'nullable|date', + 'estimated_time' => 'nullable|numeric|min:0', + ]); + + if (! $backlogItem->project_id) { + throw ValidationException::withMessages([ + 'project_id' => 'برای تبدیل بک‌لاگ به تسک، انتخاب پروژه الزامی است.', ]); + } + + if (! empty($data['start_date']) && ! empty($data['due_date']) && $data['due_date'] < $data['start_date']) { + throw ValidationException::withMessages([ + 'due_date' => 'تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد.', + ]); + } + + $project = Project::with('members:id')->findOrFail($backlogItem->project_id); + abort_unless($this->resourceAccess->canAccessProject($request->user(), $project), 403); + + $assigneeId = (int) $data['assignee_id']; + $canAssign = $this->resourceAccess->canSeeAll($request->user()) + || $assigneeId === (int) $request->user()->id + || $assigneeId === (int) $project->project_manager_id + || $project->members->contains('id', $assigneeId); + + if (! $canAssign) { + throw ValidationException::withMessages([ + 'assignee_id' => 'مسئول انتخاب‌شده عضو این پروژه نیست.', + ]); + } + + $task = DB::transaction(function () use ($backlogItem, $data, $request) { + $lockedItem = BacklogItem::query()->lockForUpdate()->findOrFail($backlogItem->id); + if ($lockedItem->archived_at !== null || $lockedItem->converted_to_task_id !== null) { + throw ValidationException::withMessages([ + 'backlog_item' => 'این بک‌لاگ قبلاً به تسک تبدیل و آرشیو شده است.', + ]); + } $task = Task::create([ - 'title' => $backlogItem->title, - 'description' => $backlogItem->description, - 'project_id' => $backlogItem->project_id, - 'assignee_id' => $request->assignee_id, + 'title' => $lockedItem->title, + 'description' => $lockedItem->description, + 'project_id' => $lockedItem->project_id, + 'assignee_id' => $data['assignee_id'], 'reporter_id' => $request->user()->id, 'created_by' => $request->user()->id, - 'priority' => $request->priority ?? $backlogItem->priority, + 'priority' => $data['priority'] ?? $lockedItem->priority, 'status' => 'todo', + 'start_date' => $data['start_date'] ?? null, + 'due_date' => $data['due_date'] ?? null, + 'estimated_time' => $data['estimated_time'] ?? null, ]); - $backlogItem->update(['status' => 'converted']); - - $task->load(['project', 'assignee', 'reporter']); - - return response()->json([ - 'success' => true, - 'data' => new TaskResource($task), - 'message' => 'آیتم بکلاگ به وظیفه تبدیل شد', + $lockedItem->update([ + 'status' => 'converted', + 'converted_to_task_id' => $task->id, + 'archived_at' => now(), ]); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'خطا در تبدیل آیتم بکلاگ به وظیفه', - ], 500); - } + + $this->notificationService->create( + $task->assignee_id, + 'task_assigned', + [ + 'task_id' => $task->id, + 'project_id' => $task->project_id, + 'url' => '/tasks', + 'notifiable_type' => Task::class, + 'notifiable_id' => $task->id, + ], + "وظیفه جدید {$task->title} به شما اختصاص داده شد" + ); + + $this->activityLogService->log( + $request->user()->id, + 'convert_backlog_to_task', + "بک‌لاگ {$lockedItem->title} به وظیفه تبدیل و آرشیو شد", + 'backlog_item', + $lockedItem->id, + $task->project_id, + $task->id, + ['backlog_item_id' => $lockedItem->id, 'task_id' => $task->id] + ); + + return $task; + }); + + $task->load(['project', 'assignee', 'reporter']); + + return response()->json([ + 'success' => true, + 'data' => new TaskResource($task), + 'message' => 'آیتم بک‌لاگ به وظیفه تبدیل و آرشیو شد', + ], 201); } } diff --git a/backend/app/Http/Requests/StoreBacklogItemRequest.php b/backend/app/Http/Requests/StoreBacklogItemRequest.php index 0b026a8..719d9c0 100644 --- a/backend/app/Http/Requests/StoreBacklogItemRequest.php +++ b/backend/app/Http/Requests/StoreBacklogItemRequest.php @@ -15,11 +15,12 @@ class StoreBacklogItemRequest extends FormRequest { return [ 'title' => 'required|string|max:255', + 'description' => 'nullable|string', 'type' => 'required|string|max:50', 'project_id' => 'nullable|exists:projects,id', 'priority' => 'nullable|string|max:50', 'estimated_effort' => 'nullable|numeric', - 'status' => 'nullable|string|max:50', + 'status' => 'nullable|in:active', ]; } } diff --git a/backend/app/Http/Resources/BacklogItemResource.php b/backend/app/Http/Resources/BacklogItemResource.php index 7b30b5c..56e5990 100644 --- a/backend/app/Http/Resources/BacklogItemResource.php +++ b/backend/app/Http/Resources/BacklogItemResource.php @@ -19,6 +19,10 @@ class BacklogItemResource extends JsonResource 'priority' => $this->priority, 'estimated_effort' => $this->estimated_effort, 'status' => $this->status, + 'is_archived' => $this->archived_at !== null, + 'archived_at' => $this->archived_at, + 'converted_to_task_id' => $this->converted_to_task_id, + 'converted_task' => new TaskResource($this->whenLoaded('convertedTask')), 'assigned_sprint_id' => $this->assigned_sprint_id, 'assigned_sprint' => new SprintResource($this->whenLoaded('assignedSprint')), 'created_by' => $this->created_by, diff --git a/backend/app/Models/BacklogItem.php b/backend/app/Models/BacklogItem.php index b6eac8f..5cacb54 100644 --- a/backend/app/Models/BacklogItem.php +++ b/backend/app/Models/BacklogItem.php @@ -9,7 +9,12 @@ class BacklogItem extends Model { protected $fillable = [ 'title', 'description', 'type', 'project_id', 'priority', - 'estimated_effort', 'status', 'assigned_sprint_id', 'created_by', + 'estimated_effort', 'status', 'assigned_sprint_id', 'converted_to_task_id', + 'archived_at', 'created_by', + ]; + + protected $casts = [ + 'archived_at' => 'datetime', ]; public function project(): BelongsTo @@ -26,4 +31,9 @@ class BacklogItem extends Model { return $this->belongsTo(User::class, 'created_by'); } + + public function convertedTask(): BelongsTo + { + return $this->belongsTo(Task::class, 'converted_to_task_id'); + } } diff --git a/backend/database/migrations/2026_08_29_000001_add_archive_lifecycle_to_backlog_items.php b/backend/database/migrations/2026_08_29_000001_add_archive_lifecycle_to_backlog_items.php new file mode 100644 index 0000000..5f5b84a --- /dev/null +++ b/backend/database/migrations/2026_08_29_000001_add_archive_lifecycle_to_backlog_items.php @@ -0,0 +1,43 @@ +foreignId('converted_to_task_id') + ->nullable() + ->after('assigned_sprint_id') + ->constrained('tasks') + ->nullOnDelete(); + $table->timestamp('archived_at')->nullable()->after('converted_to_task_id')->index(); + }); + + DB::table('backlog_items') + ->where('status', 'converted') + ->update(['archived_at' => now()]); + + DB::table('backlog_items') + ->where('status', '!=', 'converted') + ->update(['status' => 'active']); + + Schema::table('backlog_items', function (Blueprint $table) { + $table->string('status')->default('active')->change(); + }); + } + + public function down(): void + { + Schema::table('backlog_items', function (Blueprint $table) { + $table->dropForeign(['converted_to_task_id']); + $table->dropIndex(['archived_at']); + $table->dropColumn(['converted_to_task_id', 'archived_at']); + $table->string('status')->default('new')->change(); + }); + } +}; diff --git a/backend/database/seeders/BacklogSeeder.php b/backend/database/seeders/BacklogSeeder.php index bcbbf14..96769cc 100644 --- a/backend/database/seeders/BacklogSeeder.php +++ b/backend/database/seeders/BacklogSeeder.php @@ -19,7 +19,7 @@ class BacklogSeeder extends Seeder 'title' => 'اضافه کردن قابلیت ثبت اثرانگشت', 'description' => 'امکان ثبت حضور و غیاب با استفاده از دستگاه اثرانگشت و همگام‌سازی خودکار با سامانه', 'type' => 'feature', - 'status' => 'ready', + 'status' => 'active', 'priority' => 'high', 'estimated_effort' => '8', 'created_by' => $users['member@pm.com'], @@ -31,7 +31,7 @@ class BacklogSeeder extends Seeder 'title' => 'گزارش خطا در محاسبه اضافه کار', 'description' => 'در برخی موارد، محاسبه اضافه کار پرسنل شیفت شب به درستی انجام نمی‌شود', 'type' => 'bug', - 'status' => 'new', + 'status' => 'active', 'priority' => 'urgent', 'estimated_effort' => '5', 'created_by' => $users['observer@pm.com'], @@ -43,7 +43,7 @@ class BacklogSeeder extends Seeder 'title' => 'ایجاد داشبورد اختصاصی برای مدیران', 'description' => 'داشبورد شخصی‌سازی شده با KPI های کلیدی برای هر مدیر', 'type' => 'idea', - 'status' => 'reviewed', + 'status' => 'active', 'priority' => 'medium', 'estimated_effort' => '13', 'created_by' => $users['ceo@pm.com'], @@ -55,7 +55,7 @@ class BacklogSeeder extends Seeder 'title' => 'بهبود سرعت بارگذاری گزارشات', 'description' => 'بهینه‌سازی کوئری‌های دیتابیس برای کاهش زمان بارگذاری گزارشات حجیم', 'type' => 'improvement', - 'status' => 'ready', + 'status' => 'active', 'priority' => 'medium', 'estimated_effort' => '3', 'created_by' => $users['manager@pm.com'], @@ -67,7 +67,7 @@ class BacklogSeeder extends Seeder 'title' => 'ایجاد ماژول آموزش پرسنل', 'description' => 'امکان تعریف دوره‌های آموزشی، ثبت نام و پیگیری پیشرفت پرسنل', 'type' => 'task', - 'status' => 'rejected', + 'status' => 'active', 'priority' => 'low', 'estimated_effort' => '21', 'created_by' => $users['member@pm.com'], diff --git a/backend/database/seeders/SettingSeeder.php b/backend/database/seeders/SettingSeeder.php index 5612b8c..00d2675 100644 --- a/backend/database/seeders/SettingSeeder.php +++ b/backend/database/seeders/SettingSeeder.php @@ -85,7 +85,7 @@ class SettingSeeder extends Seeder ], [ 'key' => 'backlog_statuses', - 'value' => json_encode(['new', 'reviewed', 'ready', 'rejected']), + 'value' => json_encode(['active', 'converted']), 'group' => 'backlog', 'created_at' => $now, 'updated_at' => $now, diff --git a/backend/tests/Feature/BacklogLifecycleTest.php b/backend/tests/Feature/BacklogLifecycleTest.php new file mode 100644 index 0000000..6025fd0 --- /dev/null +++ b/backend/tests/Feature/BacklogLifecycleTest.php @@ -0,0 +1,120 @@ +create(['status' => 'active']); + $this->grantAdminRole($user); + + return [$user, $user->createToken('backlog-test')->plainTextToken]; + } + + private function project(User $user): Project + { + return Project::create([ + 'title' => 'پروژه بک‌لاگ', + 'project_manager_id' => $user->id, + 'created_by' => $user->id, + 'start_date' => now()->toDateString(), + 'end_date' => now()->addMonth()->toDateString(), + 'status' => 'in_progress', + ]); + } + + public function test_backlog_description_is_preserved_when_created_and_edited(): void + { + [$user, $token] = $this->authenticatedAdmin(); + $project = $this->project($user); + + $response = $this->withToken($token)->postJson('/api/backlog-items', [ + 'title' => 'بک‌لاگ توضیح‌دار', + 'description' => 'توضیحات اولیه', + 'type' => 'feature', + 'project_id' => $project->id, + 'priority' => 'medium', + ])->assertCreated()->assertJsonPath('data.description', 'توضیحات اولیه'); + + $backlogId = $response->json('data.id'); + + $this->withToken($token)->putJson("/api/backlog-items/{$backlogId}", [ + 'title' => 'بک‌لاگ توضیح‌دار', + 'description' => 'توضیحات ویرایش‌شده', + 'type' => 'feature', + 'project_id' => $project->id, + 'priority' => 'medium', + ])->assertOk()->assertJsonPath('data.description', 'توضیحات ویرایش‌شده'); + + $this->assertDatabaseHas('backlog_items', [ + 'id' => $backlogId, + 'description' => 'توضیحات ویرایش‌شده', + 'status' => 'active', + ]); + } + + public function test_conversion_assigns_task_and_archives_backlog_atomically(): void + { + [$admin, $token] = $this->authenticatedAdmin(); + $assignee = User::factory()->create(['status' => 'active']); + $project = $this->project($admin); + $backlog = BacklogItem::create([ + 'title' => 'بک‌لاگ قابل تبدیل', + 'description' => 'شرح تسک', + 'type' => 'bug', + 'project_id' => $project->id, + 'priority' => 'high', + 'status' => 'active', + 'created_by' => $admin->id, + ]); + + $response = $this->withToken($token)->postJson("/api/backlog-items/{$backlog->id}/convert-to-task", [ + 'assignee_id' => $assignee->id, + 'priority' => 'urgent', + 'start_date' => now()->addDay()->toDateString(), + 'due_date' => now()->addDays(3)->toDateString(), + 'estimated_time' => 6, + ])->assertCreated()->assertJsonPath('data.assignee_id', $assignee->id); + + $taskId = $response->json('data.id'); + $this->assertDatabaseHas('tasks', [ + 'id' => $taskId, + 'assignee_id' => $assignee->id, + 'description' => 'شرح تسک', + 'priority' => 'urgent', + ]); + $this->assertDatabaseHas('backlog_items', [ + 'id' => $backlog->id, + 'status' => 'converted', + 'converted_to_task_id' => $taskId, + ]); + $this->assertNotNull($backlog->fresh()->archived_at); + $this->assertDatabaseHas('notifications', [ + 'user_id' => $assignee->id, + 'type' => 'task_assigned', + 'notifiable_id' => $taskId, + ]); + $this->assertTrue(app(ResourceAccessService::class)->tasks($assignee)->whereKey($taskId)->exists()); + + $this->withToken($token)->getJson('/api/backlog-items?archive_state=active') + ->assertOk()->assertJsonCount(0, 'data'); + $this->withToken($token)->getJson('/api/backlog-items?archive_state=archived') + ->assertOk()->assertJsonPath('data.0.converted_to_task_id', $taskId); + + $this->withToken($token)->postJson("/api/backlog-items/{$backlog->id}/convert-to-task", [ + 'assignee_id' => $assignee->id, + 'priority' => 'high', + ])->assertUnprocessable(); + $this->assertDatabaseCount('tasks', 1); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d747f83..2a9d5cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -126,7 +126,7 @@ export default function App() { return ( - + diff --git a/frontend/src/components/MultiSelectDropdown.jsx b/frontend/src/components/MultiSelectDropdown.jsx new file mode 100644 index 0000000..b2cd633 --- /dev/null +++ b/frontend/src/components/MultiSelectDropdown.jsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Check, ChevronDown, Search, X } from 'lucide-react'; + +export default function MultiSelectDropdown({ + options, + value = [], + onChange, + placeholder = 'انتخاب کنید', + searchPlaceholder = 'جست‌وجوی اعضا...', + disabled = false, +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const rootRef = useRef(null); + const searchRef = useRef(null); + const selectedIds = useMemo(() => new Set(value.map(Number)), [value]); + const selectedOptions = options.filter((option) => selectedIds.has(Number(option.value))); + const filteredOptions = options.filter((option) => option.label.toLocaleLowerCase('fa').includes(query.trim().toLocaleLowerCase('fa'))); + + useEffect(() => { + if (!open) return undefined; + const dismiss = (event) => { + if (event.key === 'Escape' || (event.type === 'pointerdown' && !rootRef.current?.contains(event.target))) { + setOpen(false); + } + }; + document.addEventListener('pointerdown', dismiss); + document.addEventListener('keydown', dismiss); + requestAnimationFrame(() => searchRef.current?.focus()); + return () => { + document.removeEventListener('pointerdown', dismiss); + document.removeEventListener('keydown', dismiss); + }; + }, [open]); + + const toggle = (optionValue) => { + const id = Number(optionValue); + onChange(selectedIds.has(id) ? value.filter((item) => Number(item) !== id) : [...value, id]); + }; + + const remove = (event, optionValue) => { + event.stopPropagation(); + onChange(value.filter((item) => Number(item) !== Number(optionValue))); + }; + + return ( +
+ + + {open && ( +
+ +
+ {filteredOptions.length === 0 ?
عضوی یافت نشد
: filteredOptions.map((option) => { + const selected = selectedIds.has(Number(option.value)); + return ( + + ); + })} +
+ {selectedOptions.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx index 2cdfa2c..51fd802 100644 --- a/frontend/src/components/StatusBadge.jsx +++ b/frontend/src/components/StatusBadge.jsx @@ -19,6 +19,7 @@ const statusColors = { 'نیازمند بررسی': 'badge-warning', 'active': 'badge-success', 'فعال': 'badge-success', + 'converted': 'badge-gray', 'new': 'badge-info', 'جدید': 'badge-info', 'ready': 'badge-primary', @@ -42,6 +43,7 @@ const statusLabels = { 'done': 'انجام‌شده', 'review': 'نیازمند بررسی', 'active': 'فعال', + 'converted': 'تبدیل‌شده و آرشیوشده', 'new': 'جدید', 'ready': 'آماده اجرا', 'rejected': 'رد شده', diff --git a/frontend/src/pages/Backlog.jsx b/frontend/src/pages/Backlog.jsx index f23ed2d..18a409f 100644 --- a/frontend/src/pages/Backlog.jsx +++ b/frontend/src/pages/Backlog.jsx @@ -1,182 +1,236 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import api from '../services/api'; -import StatusBadge from '../components/StatusBadge'; import PriorityBadge from '../components/PriorityBadge'; import Modal from '../components/Modal'; import ConfirmDialog from '../components/ConfirmDialog'; import EmptyState from '../components/EmptyState'; +import PersianDateInput from '../components/PersianDateInput'; import { TableSkeleton } from '../components/LoadingSkeleton'; import toast from 'react-hot-toast'; -import { Inbox, Edit3, Trash2, ArrowLeft } from 'lucide-react'; +import { Archive, ArrowLeft, Edit3, Inbox, ListTodo, Trash2, UserCheck } from 'lucide-react'; +import { formatJalaliDate } from '../utils/date'; + +const emptyForm = { + title: '', description: '', type: 'feature', project_id: '', priority: 'medium', estimated_effort: '', +}; + +const emptyConversion = { + assignee_id: '', priority: 'medium', start_date: '', due_date: '', estimated_time: '', +}; + +function errorMessage(error, fallback = 'خطایی رخ داد') { + const errors = error.response?.data?.errors; + const firstError = errors && Object.values(errors).flat()[0]; + return firstError || error.response?.data?.message || fallback; +} export default function Backlog() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); - const [filters, setFilters] = useState({ type: '', status: '', priority: '', project_id: '' }); + const [filters, setFilters] = useState({ type: '', project_id: '' }); + const [archiveView, setArchiveView] = useState('active'); const [projects, setProjects] = useState([]); + const [users, setUsers] = useState([]); + const [conversionUsers, setConversionUsers] = useState([]); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [converting, setConverting] = useState(null); const [saving, setSaving] = useState(false); - const [form, setForm] = useState({ title: '', description: '', type: 'feature', project_id: '', priority: 'medium', status: 'new', estimated_effort: '' }); + const [convertingTask, setConvertingTask] = useState(false); + const [form, setForm] = useState(emptyForm); + const [conversion, setConversion] = useState(emptyConversion); - const fetchItems = () => { + const fetchItems = useCallback(() => { setLoading(true); - const params = {}; - Object.keys(filters).forEach(k => { if (filters[k]) params[k] = filters[k]; }); - api.get('/backlog-items', { params }).then(({ data }) => setItems(data.data || [])).catch(() => toast.error('خطا')).finally(() => setLoading(false)); - }; + const params = { archive_state: archiveView }; + Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; }); + api.get('/backlog-items', { params }) + .then(({ data }) => setItems(data.data || [])) + .catch(() => toast.error('خطا در دریافت بک‌لاگ‌ها')) + .finally(() => setLoading(false)); + }, [archiveView, filters]); - useEffect(() => { fetchItems(); }, [filters]); + useEffect(() => { fetchItems(); }, [fetchItems]); useEffect(() => { api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {}); + api.get('/users', { params: { per_page: 200 } }).then(({ data }) => setUsers(data.data || [])).catch(() => {}); }, []); const openCreate = () => { setEditing(null); - setForm({ title: '', description: '', type: 'feature', project_id: '', priority: 'medium', status: 'new', estimated_effort: '' }); + setForm(emptyForm); setShowModal(true); }; const openEdit = (item) => { setEditing(item); setForm({ - title: item.title, description: item.description || '', type: item.type, project_id: item.project_id || '', - priority: item.priority, status: item.status, estimated_effort: item.estimated_effort || '', + title: item.title, + description: item.description || '', + type: item.type, + project_id: item.project_id || '', + priority: item.priority, + estimated_effort: item.estimated_effort || '', }); setShowModal(true); }; - const handleSave = async (e) => { - e.preventDefault(); - if (!form.title) { toast.error('عنوان الزامی است'); return; } + const handleSave = async (event) => { + event.preventDefault(); + if (!form.title.trim()) { toast.error('عنوان الزامی است'); return; } setSaving(true); try { - if (editing) { await api.put(`/backlog-items/${editing.id}`, form); toast.success('به‌روزرسانی شد'); } - else { await api.post('/backlog-items', form); toast.success('ایجاد شد'); } - setShowModal(false); fetchItems(); - } catch (err) { toast.error(err.response?.data?.message || 'خطا'); } finally { setSaving(false); } + if (editing) { + await api.put(`/backlog-items/${editing.id}`, form); + toast.success('بک‌لاگ به‌روزرسانی شد'); + } else { + await api.post('/backlog-items', form); + toast.success('بک‌لاگ ایجاد شد'); + } + setShowModal(false); + fetchItems(); + } catch (error) { + toast.error(errorMessage(error)); + } finally { + setSaving(false); + } }; const handleDelete = async () => { - try { await api.delete(`/backlog-items/${deleting.id}`); toast.success('حذف شد'); setDeleting(null); fetchItems(); } - catch { toast.error('خطا'); } + try { + await api.delete(`/backlog-items/${deleting.id}`); + toast.success('بک‌لاگ حذف شد'); + setDeleting(null); + fetchItems(); + } catch { + toast.error('خطا در حذف بک‌لاگ'); + } }; - const convertToTask = async (item) => { + const openConvert = async (item) => { + if (!item.project_id) { + toast.error('پیش از تبدیل، یک پروژه برای بک‌لاگ انتخاب کنید'); + return; + } + + setConverting(item); + setConversion({ ...emptyConversion, priority: item.priority || 'medium' }); + setConversionUsers([]); try { - await api.post(`/backlog-items/${item.id}/convert-to-task`); - toast.success('به تسک تبدیل شد'); + const { data } = await api.get(`/projects/${item.project_id}`); + const project = data.data; + const candidates = [...(project.members || []), project.project_manager].filter(Boolean); + const uniqueCandidates = [...new Map(candidates.map((candidate) => [candidate.id, candidate])).values()]; + setConversionUsers(uniqueCandidates.length ? uniqueCandidates : users); + } catch { + setConversionUsers(users); + } + }; + + const convertToTask = async (event) => { + event.preventDefault(); + if (!conversion.assignee_id) { toast.error('انتخاب مسئول تسک الزامی است'); return; } + if (conversion.start_date && conversion.due_date && conversion.due_date < conversion.start_date) { + toast.error('تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد'); + return; + } + + setConvertingTask(true); + try { + await api.post(`/backlog-items/${converting.id}/convert-to-task`, conversion); + toast.success('تسک تخصیص داده شد و بک‌لاگ آرشیو شد'); + setConverting(null); fetchItems(); - } catch { toast.error('خطا'); } + } catch (error) { + toast.error(errorMessage(error, 'خطا در تبدیل بک‌لاگ')); + } finally { + setConvertingTask(false); + } }; const typeBadge = (type) => { const colors = { idea: 'badge-info', feature: 'badge-primary', bug: 'badge-danger', improvement: 'badge-warning', task: 'badge-success' }; - return {type}; + const labels = { idea: 'ایده', feature: 'فیچر', bug: 'باگ', improvement: 'بهبود', task: 'تسک' }; + return {labels[type] || type}; }; return (
-
- +
+
+ + +
+ {archiveView === 'active' && }
+
- setFilters({ ...filters, type: event.target.value })}> + - - setFilters({ ...filters, project_id: event.target.value })}> + {projects.map((project) => )}
- {loading ? : items.length === 0 ? ( - ایجاد آیتم جدید} /> + {loading ? : items.length === 0 ? ( + ایجاد آیتم جدید : null} /> ) : ( -
+
- - - {items.map(item => ( - - - - - - - - - - ))} - + + {items.map((item) => ( + + + + + + + + + + ))}
عنواننوعاولویتوضعیتپروژهتلاشعملیات
{item.title}{typeBadge(item.type)}{item.project?.title || '—'}{item.estimated_effort || '—'} -
- - - -
-
عنواننوعاولویتوضعیتپروژه{archiveView === 'active' ? 'تلاش' : 'تسک تخصیص‌یافته'}عملیات
{item.title}{item.description && {item.description}}
{typeBadge(item.type)}{item.is_archived ? 'تبدیل‌شده و آرشیوشده' : 'فعال و تبدیل‌نشده'}{item.project?.title || '—'}{item.is_archived ?
{item.converted_task?.title || 'تسک ایجادشده'} {item.converted_task?.assignee?.name || 'بدون مسئول'} · {formatJalaliDate(item.archived_at)}
: (item.estimated_effort || '—')}
+ {!item.is_archived && } + {!item.is_archived && } + +
)} {showModal && ( - setShowModal(false)} size="xl" footer={ - <> - }> -
+ setShowModal(false)} size="xl" footer={<>}> +
+
setForm({ ...form, title: event.target.value })} />
+