implement backlog task conversion and archive workflow

مخزن کامیت contained در
مسعود نیک زاد 2026-08-29 17:16:29 +03:30
والد d22b776572
کامیت 9901a25c68
15فایلهای تغییر یافته به همراه685 افزوده شده و 164 حذف شده

مشاهده پرونده

@ -7,19 +7,29 @@ use App\Http\Requests\StoreBacklogItemRequest;
use App\Http\Resources\BacklogItemResource; use App\Http\Resources\BacklogItemResource;
use App\Http\Resources\TaskResource; use App\Http\Resources\TaskResource;
use App\Models\BacklogItem; use App\Models\BacklogItem;
use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use App\Services\ResourceAccessService; use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class BacklogController extends Controller 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 public function index(Request $request): JsonResponse
{ {
try { try {
$query = BacklogItem::with(['project', 'assignedSprint', 'creator']); $query = BacklogItem::with(['project', 'assignedSprint', 'creator', 'convertedTask.assignee']);
if (! $this->resourceAccess->canSeeAll($request->user())) { if (! $this->resourceAccess->canSeeAll($request->user())) {
$query->where(function ($scope) use ($request) { $query->where(function ($scope) use ($request) {
$scope->where('created_by', $request->user()->id) $scope->where('created_by', $request->user()->id)
@ -40,6 +50,13 @@ class BacklogController extends Controller
$query->where('priority', $request->priority); $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); $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) $sortBy = in_array($request->input('sort_by'), ['id', 'title', 'priority', 'status', 'created_at', 'updated_at'], true)
? $request->input('sort_by') ? $request->input('sort_by')
@ -96,6 +113,7 @@ class BacklogController extends Controller
); );
} }
$data['created_by'] = $request->user()->id; $data['created_by'] = $request->user()->id;
$data['status'] = 'active';
$item = BacklogItem::create($data); $item = BacklogItem::create($data);
$item->load(['project', 'assignedSprint', 'creator']); $item->load(['project', 'assignedSprint', 'creator']);
@ -114,21 +132,33 @@ class BacklogController extends Controller
public function update(Request $request, BacklogItem $backlogItem): JsonResponse public function update(Request $request, BacklogItem $backlogItem): JsonResponse
{ {
try { if ($backlogItem->archived_at !== null) {
$request->validate([ return response()->json([
'title' => 'sometimes|required|string|max:255', 'success' => false,
'description' => 'nullable|string', 'message' => 'بک‌لاگ آرشیوشده قابل ویرایش نیست.',
'type' => 'sometimes|required|string|max:50', ], 422);
'priority' => 'nullable|string|max:50', }
'estimated_effort' => 'nullable|numeric',
'status' => 'nullable|string|max:50',
'assigned_sprint_id' => 'nullable|exists:sprints,id',
]);
$backlogItem->update($request->only([ $data = $request->validate([
'title', 'description', 'type', 'priority', 'title' => 'sometimes|required|string|max:255',
'estimated_effort', 'status', 'assigned_sprint_id', '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']); $backlogItem->load(['project', 'assignedSprint', 'creator']);
return response()->json([ return response()->json([
@ -163,38 +193,102 @@ class BacklogController extends Controller
public function convertToTask(Request $request, BacklogItem $backlogItem): JsonResponse public function convertToTask(Request $request, BacklogItem $backlogItem): JsonResponse
{ {
try { $data = $request->validate([
$request->validate([ 'assignee_id' => 'required|exists:users,id',
'assignee_id' => 'nullable|exists:users,id', 'priority' => ['required', Rule::in(['low', 'medium', 'high', 'urgent'])],
'priority' => 'nullable|string', 'start_date' => 'nullable|date',
'due_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([ $task = Task::create([
'title' => $backlogItem->title, 'title' => $lockedItem->title,
'description' => $backlogItem->description, 'description' => $lockedItem->description,
'project_id' => $backlogItem->project_id, 'project_id' => $lockedItem->project_id,
'assignee_id' => $request->assignee_id, 'assignee_id' => $data['assignee_id'],
'reporter_id' => $request->user()->id, 'reporter_id' => $request->user()->id,
'created_by' => $request->user()->id, 'created_by' => $request->user()->id,
'priority' => $request->priority ?? $backlogItem->priority, 'priority' => $data['priority'] ?? $lockedItem->priority,
'status' => 'todo', 'status' => 'todo',
'start_date' => $data['start_date'] ?? null,
'due_date' => $data['due_date'] ?? null,
'estimated_time' => $data['estimated_time'] ?? null,
]); ]);
$backlogItem->update(['status' => 'converted']); $lockedItem->update([
'status' => 'converted',
$task->load(['project', 'assignee', 'reporter']); 'converted_to_task_id' => $task->id,
'archived_at' => now(),
return response()->json([
'success' => true,
'data' => new TaskResource($task),
'message' => 'آیتم بکلاگ به وظیفه تبدیل شد',
]); ]);
} catch (\Exception $e) {
return response()->json([ $this->notificationService->create(
'success' => false, $task->assignee_id,
'message' => 'خطا در تبدیل آیتم بکلاگ به وظیفه', 'task_assigned',
], 500); [
} '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);
} }
} }

مشاهده پرونده

@ -15,11 +15,12 @@ class StoreBacklogItemRequest extends FormRequest
{ {
return [ return [
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|string|max:50', 'type' => 'required|string|max:50',
'project_id' => 'nullable|exists:projects,id', 'project_id' => 'nullable|exists:projects,id',
'priority' => 'nullable|string|max:50', 'priority' => 'nullable|string|max:50',
'estimated_effort' => 'nullable|numeric', 'estimated_effort' => 'nullable|numeric',
'status' => 'nullable|string|max:50', 'status' => 'nullable|in:active',
]; ];
} }
} }

مشاهده پرونده

@ -19,6 +19,10 @@ class BacklogItemResource extends JsonResource
'priority' => $this->priority, 'priority' => $this->priority,
'estimated_effort' => $this->estimated_effort, 'estimated_effort' => $this->estimated_effort,
'status' => $this->status, '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_id' => $this->assigned_sprint_id,
'assigned_sprint' => new SprintResource($this->whenLoaded('assignedSprint')), 'assigned_sprint' => new SprintResource($this->whenLoaded('assignedSprint')),
'created_by' => $this->created_by, 'created_by' => $this->created_by,

مشاهده پرونده

@ -9,7 +9,12 @@ class BacklogItem extends Model
{ {
protected $fillable = [ protected $fillable = [
'title', 'description', 'type', 'project_id', 'priority', '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 public function project(): BelongsTo
@ -26,4 +31,9 @@ class BacklogItem extends Model
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
public function convertedTask(): BelongsTo
{
return $this->belongsTo(Task::class, 'converted_to_task_id');
}
} }

مشاهده پرونده

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('backlog_items', function (Blueprint $table) {
$table->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();
});
}
};

مشاهده پرونده

@ -19,7 +19,7 @@ class BacklogSeeder extends Seeder
'title' => 'اضافه کردن قابلیت ثبت اثرانگشت', 'title' => 'اضافه کردن قابلیت ثبت اثرانگشت',
'description' => 'امکان ثبت حضور و غیاب با استفاده از دستگاه اثرانگشت و همگام‌سازی خودکار با سامانه', 'description' => 'امکان ثبت حضور و غیاب با استفاده از دستگاه اثرانگشت و همگام‌سازی خودکار با سامانه',
'type' => 'feature', 'type' => 'feature',
'status' => 'ready', 'status' => 'active',
'priority' => 'high', 'priority' => 'high',
'estimated_effort' => '8', 'estimated_effort' => '8',
'created_by' => $users['member@pm.com'], 'created_by' => $users['member@pm.com'],
@ -31,7 +31,7 @@ class BacklogSeeder extends Seeder
'title' => 'گزارش خطا در محاسبه اضافه کار', 'title' => 'گزارش خطا در محاسبه اضافه کار',
'description' => 'در برخی موارد، محاسبه اضافه کار پرسنل شیفت شب به درستی انجام نمی‌شود', 'description' => 'در برخی موارد، محاسبه اضافه کار پرسنل شیفت شب به درستی انجام نمی‌شود',
'type' => 'bug', 'type' => 'bug',
'status' => 'new', 'status' => 'active',
'priority' => 'urgent', 'priority' => 'urgent',
'estimated_effort' => '5', 'estimated_effort' => '5',
'created_by' => $users['observer@pm.com'], 'created_by' => $users['observer@pm.com'],
@ -43,7 +43,7 @@ class BacklogSeeder extends Seeder
'title' => 'ایجاد داشبورد اختصاصی برای مدیران', 'title' => 'ایجاد داشبورد اختصاصی برای مدیران',
'description' => 'داشبورد شخصی‌سازی شده با KPI های کلیدی برای هر مدیر', 'description' => 'داشبورد شخصی‌سازی شده با KPI های کلیدی برای هر مدیر',
'type' => 'idea', 'type' => 'idea',
'status' => 'reviewed', 'status' => 'active',
'priority' => 'medium', 'priority' => 'medium',
'estimated_effort' => '13', 'estimated_effort' => '13',
'created_by' => $users['ceo@pm.com'], 'created_by' => $users['ceo@pm.com'],
@ -55,7 +55,7 @@ class BacklogSeeder extends Seeder
'title' => 'بهبود سرعت بارگذاری گزارشات', 'title' => 'بهبود سرعت بارگذاری گزارشات',
'description' => 'بهینه‌سازی کوئری‌های دیتابیس برای کاهش زمان بارگذاری گزارشات حجیم', 'description' => 'بهینه‌سازی کوئری‌های دیتابیس برای کاهش زمان بارگذاری گزارشات حجیم',
'type' => 'improvement', 'type' => 'improvement',
'status' => 'ready', 'status' => 'active',
'priority' => 'medium', 'priority' => 'medium',
'estimated_effort' => '3', 'estimated_effort' => '3',
'created_by' => $users['manager@pm.com'], 'created_by' => $users['manager@pm.com'],
@ -67,7 +67,7 @@ class BacklogSeeder extends Seeder
'title' => 'ایجاد ماژول آموزش پرسنل', 'title' => 'ایجاد ماژول آموزش پرسنل',
'description' => 'امکان تعریف دوره‌های آموزشی، ثبت نام و پیگیری پیشرفت پرسنل', 'description' => 'امکان تعریف دوره‌های آموزشی، ثبت نام و پیگیری پیشرفت پرسنل',
'type' => 'task', 'type' => 'task',
'status' => 'rejected', 'status' => 'active',
'priority' => 'low', 'priority' => 'low',
'estimated_effort' => '21', 'estimated_effort' => '21',
'created_by' => $users['member@pm.com'], 'created_by' => $users['member@pm.com'],

مشاهده پرونده

@ -85,7 +85,7 @@ class SettingSeeder extends Seeder
], ],
[ [
'key' => 'backlog_statuses', 'key' => 'backlog_statuses',
'value' => json_encode(['new', 'reviewed', 'ready', 'rejected']), 'value' => json_encode(['active', 'converted']),
'group' => 'backlog', 'group' => 'backlog',
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,

مشاهده پرونده

@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Models\BacklogItem;
use App\Models\Project;
use App\Models\User;
use App\Services\ResourceAccessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BacklogLifecycleTest extends TestCase
{
use RefreshDatabase;
private function authenticatedAdmin(): array
{
$user = User::factory()->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);
}
}

مشاهده پرونده

@ -126,7 +126,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Toaster position="top-center" /> <Toaster position="top-center" toastOptions={{ className: 'app-toast' }} />
<AppRoutes /> <AppRoutes />
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>

مشاهده پرونده

@ -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 (
<div className={`multi-select${open ? ' open' : ''}`} ref={rootRef}>
<button
type="button"
className="multi-select-trigger"
onClick={() => !disabled && setOpen((current) => !current)}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
>
<span className="multi-select-value">
{selectedOptions.length === 0 ? <span className="multi-select-placeholder">{placeholder}</span> : selectedOptions.map((option) => (
<span className="multi-select-chip" key={option.value}>
{option.label}
<X size={13} role="button" aria-label={`حذف ${option.label}`} onClick={(event) => remove(event, option.value)} />
</span>
))}
</span>
<ChevronDown className="multi-select-chevron" size={18} />
</button>
{open && (
<div className="multi-select-menu">
<label className="multi-select-search">
<Search size={16} />
<input ref={searchRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} />
</label>
<div className="multi-select-options" role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? <div className="multi-select-empty">عضوی یافت نشد</div> : filteredOptions.map((option) => {
const selected = selectedIds.has(Number(option.value));
return (
<button
type="button"
role="option"
aria-selected={selected}
className={selected ? 'selected' : ''}
key={option.value}
onClick={() => toggle(option.value)}
>
<span>{option.label}</span>
<span className="multi-select-check">{selected && <Check size={15} />}</span>
</button>
);
})}
</div>
{selectedOptions.length > 0 && (
<button type="button" className="multi-select-clear" onClick={() => onChange([])}>پاککردن همه انتخابها</button>
)}
</div>
)}
</div>
);
}

مشاهده پرونده

@ -19,6 +19,7 @@ const statusColors = {
'نیازمند بررسی': 'badge-warning', 'نیازمند بررسی': 'badge-warning',
'active': 'badge-success', 'active': 'badge-success',
'فعال': 'badge-success', 'فعال': 'badge-success',
'converted': 'badge-gray',
'new': 'badge-info', 'new': 'badge-info',
'جدید': 'badge-info', 'جدید': 'badge-info',
'ready': 'badge-primary', 'ready': 'badge-primary',
@ -42,6 +43,7 @@ const statusLabels = {
'done': 'انجام‌شده', 'done': 'انجام‌شده',
'review': 'نیازمند بررسی', 'review': 'نیازمند بررسی',
'active': 'فعال', 'active': 'فعال',
'converted': 'تبدیل‌شده و آرشیوشده',
'new': 'جدید', 'new': 'جدید',
'ready': 'آماده اجرا', 'ready': 'آماده اجرا',
'rejected': 'رد شده', 'rejected': 'رد شده',

مشاهده پرونده

@ -1,182 +1,236 @@
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import api from '../services/api'; import api from '../services/api';
import StatusBadge from '../components/StatusBadge';
import PriorityBadge from '../components/PriorityBadge'; import PriorityBadge from '../components/PriorityBadge';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog'; import ConfirmDialog from '../components/ConfirmDialog';
import EmptyState from '../components/EmptyState'; import EmptyState from '../components/EmptyState';
import PersianDateInput from '../components/PersianDateInput';
import { TableSkeleton } from '../components/LoadingSkeleton'; import { TableSkeleton } from '../components/LoadingSkeleton';
import toast from 'react-hot-toast'; 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() { export default function Backlog() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true); 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 [projects, setProjects] = useState([]);
const [users, setUsers] = useState([]);
const [conversionUsers, setConversionUsers] = useState([]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null); const [deleting, setDeleting] = useState(null);
const [converting, setConverting] = useState(null);
const [saving, setSaving] = useState(false); 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); setLoading(true);
const params = {}; const params = { archive_state: archiveView };
Object.keys(filters).forEach(k => { if (filters[k]) params[k] = filters[k]; }); 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)); 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(() => { useEffect(() => {
api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {}); 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 = () => { const openCreate = () => {
setEditing(null); setEditing(null);
setForm({ title: '', description: '', type: 'feature', project_id: '', priority: 'medium', status: 'new', estimated_effort: '' }); setForm(emptyForm);
setShowModal(true); setShowModal(true);
}; };
const openEdit = (item) => { const openEdit = (item) => {
setEditing(item); setEditing(item);
setForm({ setForm({
title: item.title, description: item.description || '', type: item.type, project_id: item.project_id || '', title: item.title,
priority: item.priority, status: item.status, estimated_effort: item.estimated_effort || '', description: item.description || '',
type: item.type,
project_id: item.project_id || '',
priority: item.priority,
estimated_effort: item.estimated_effort || '',
}); });
setShowModal(true); setShowModal(true);
}; };
const handleSave = async (e) => { const handleSave = async (event) => {
e.preventDefault(); event.preventDefault();
if (!form.title) { toast.error('عنوان الزامی است'); return; } if (!form.title.trim()) { toast.error('عنوان الزامی است'); return; }
setSaving(true); setSaving(true);
try { try {
if (editing) { await api.put(`/backlog-items/${editing.id}`, form); toast.success('به‌روزرسانی شد'); } if (editing) {
else { await api.post('/backlog-items', form); toast.success('ایجاد شد'); } await api.put(`/backlog-items/${editing.id}`, form);
setShowModal(false); fetchItems(); toast.success('بک‌لاگ به‌روزرسانی شد');
} catch (err) { toast.error(err.response?.data?.message || 'خطا'); } finally { setSaving(false); } } else {
await api.post('/backlog-items', form);
toast.success('بک‌لاگ ایجاد شد');
}
setShowModal(false);
fetchItems();
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSaving(false);
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
try { await api.delete(`/backlog-items/${deleting.id}`); toast.success('حذف شد'); setDeleting(null); fetchItems(); } try {
catch { toast.error('خطا'); } 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 { try {
await api.post(`/backlog-items/${item.id}/convert-to-task`); const { data } = await api.get(`/projects/${item.project_id}`);
toast.success('به تسک تبدیل شد'); 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(); fetchItems();
} catch { toast.error('خطا'); } } catch (error) {
toast.error(errorMessage(error, 'خطا در تبدیل بک‌لاگ'));
} finally {
setConvertingTask(false);
}
}; };
const typeBadge = (type) => { const typeBadge = (type) => {
const colors = { idea: 'badge-info', feature: 'badge-primary', bug: 'badge-danger', improvement: 'badge-warning', task: 'badge-success' }; const colors = { idea: 'badge-info', feature: 'badge-primary', bug: 'badge-danger', improvement: 'badge-warning', task: 'badge-success' };
return <span className={`badge ${colors[type] || 'badge-gray'}`}>{type}</span>; const labels = { idea: 'ایده', feature: 'فیچر', bug: 'باگ', improvement: 'بهبود', task: 'تسک' };
return <span className={`badge ${colors[type] || 'badge-gray'}`}>{labels[type] || type}</span>;
}; };
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header"> <div className="page-header backlog-page-header">
<button className="btn btn-primary" onClick={openCreate}>+ آیتم جدید</button> <div className="backlog-view-switch" role="tablist" aria-label="وضعیت بک‌لاگ‌ها">
<button type="button" role="tab" aria-selected={archiveView === 'active'} className={archiveView === 'active' ? 'active' : ''} onClick={() => setArchiveView('active')}><ListTodo size={17} /> فعال</button>
<button type="button" role="tab" aria-selected={archiveView === 'archived'} className={archiveView === 'archived' ? 'active' : ''} onClick={() => setArchiveView('archived')}><Archive size={17} /> آرشیوشده</button>
</div>
{archiveView === 'active' && <button className="btn btn-primary" onClick={openCreate}>+ آیتم جدید</button>}
</div> </div>
<div className="filter-bar"> <div className="filter-bar">
<select className="form-select" value={filters.type} onChange={e => setFilters({...filters, type: e.target.value})}> <select className="form-select" aria-label="فیلتر نوع بک‌لاگ" value={filters.type} onChange={(event) => setFilters({ ...filters, type: event.target.value })}>
<option value="">همه انواع</option> <option value="">همه انواع</option><option value="idea">ایده</option><option value="feature">فیچر</option><option value="bug">باگ</option><option value="improvement">بهبود</option><option value="task">تسک</option>
<option value="idea">ایده</option>
<option value="feature">فیچر</option>
<option value="bug">باگ</option>
<option value="improvement">بهبود</option>
<option value="task">تسک</option>
</select> </select>
<select className="form-select" value={filters.status} onChange={e => setFilters({...filters, status: e.target.value})}> <select className="form-select" aria-label="فیلتر پروژه" value={filters.project_id} onChange={(event) => setFilters({ ...filters, project_id: event.target.value })}>
<option value="">همه وضعیتها</option> <option value="">همه پروژهها</option>{projects.map((project) => <option key={project.id} value={project.id}>{project.title}</option>)}
<option value="new">جدید</option>
<option value="reviewed">بررسیشده</option>
<option value="ready">آماده اجرا</option>
<option value="rejected">رد شده</option>
</select>
<select className="form-select" value={filters.project_id} onChange={e => setFilters({...filters, project_id: e.target.value})}>
<option value="">همه پروژهها</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
</select> </select>
</div> </div>
{loading ? <TableSkeleton rows={6} cols={5} /> : items.length === 0 ? ( {loading ? <TableSkeleton rows={6} cols={7} /> : items.length === 0 ? (
<EmptyState icon={Inbox} title="آیتمی در بک‌لاگ وجود ندارد" description="هنوز هیچ آیتمی به بک‌لاگ اضافه نشده" action={<button className="btn btn-primary" onClick={openCreate}>ایجاد آیتم جدید</button>} /> <EmptyState icon={archiveView === 'active' ? Inbox : Archive} title={archiveView === 'active' ? 'بک‌لاگ فعالی وجود ندارد' : 'بک‌لاگ آرشیوشده‌ای وجود ندارد'} description={archiveView === 'active' ? 'هنوز آیتم فعالی به بک‌لاگ اضافه نشده است' : 'پس از تبدیل بک‌لاگ به تسک، در این بخش نمایش داده می‌شود'} action={archiveView === 'active' ? <button className="btn btn-primary" onClick={openCreate}>ایجاد آیتم جدید</button> : null} />
) : ( ) : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}> <div className="card table-card">
<div className="table-container"> <div className="table-container">
<table> <table>
<thead><tr><th>عنوان</th><th>نوع</th><th>اولویت</th><th>وضعیت</th><th>پروژه</th><th>تلاش</th><th>عملیات</th></tr></thead> <thead><tr><th>عنوان</th><th>نوع</th><th>اولویت</th><th>وضعیت</th><th>پروژه</th><th>{archiveView === 'active' ? 'تلاش' : 'تسک تخصیص‌یافته'}</th><th>عملیات</th></tr></thead>
<tbody> <tbody>{items.map((item) => (
{items.map(item => ( <tr key={item.id}>
<tr key={item.id}> <td><div className="backlog-title-cell"><strong>{item.title}</strong>{item.description && <small>{item.description}</small>}</div></td>
<td style={{ fontWeight: 500 }}>{item.title}</td> <td>{typeBadge(item.type)}</td>
<td>{typeBadge(item.type)}</td> <td><PriorityBadge priority={item.priority} /></td>
<td><PriorityBadge priority={item.priority} /></td> <td><span className={`badge ${item.is_archived ? 'badge-gray' : 'badge-success'}`}>{item.is_archived ? 'تبدیل‌شده و آرشیوشده' : 'فعال و تبدیل‌نشده'}</span></td>
<td><StatusBadge status={item.status} /></td> <td className="muted-cell">{item.project?.title || '—'}</td>
<td style={{ fontSize: '0.875rem', color: 'var(--gray-500)' }}>{item.project?.title || '—'}</td> <td>{item.is_archived ? <div className="backlog-task-cell"><strong>{item.converted_task?.title || 'تسک ایجادشده'}</strong><small><UserCheck size={13} /> {item.converted_task?.assignee?.name || 'بدون مسئول'} · {formatJalaliDate(item.archived_at)}</small></div> : (item.estimated_effort || '—')}</td>
<td>{item.estimated_effort || '—'}</td> <td><div className="table-actions">
<td> {!item.is_archived && <button className="btn btn-sm btn-success" onClick={() => openConvert(item)} title="تبدیل و تخصیص به تسک" aria-label={`تبدیل ${item.title} به تسک`}><ArrowLeft size={15} /></button>}
<div style={{ display: 'flex', gap: '0.25rem' }}> {!item.is_archived && <button className="btn btn-sm btn-secondary" onClick={() => openEdit(item)} title="ویرایش" aria-label={`ویرایش ${item.title}`}><Edit3 size={15} /></button>}
<button className="btn btn-sm btn-success" onClick={() => convertToTask(item)} title="تبدیل به تسک"><ArrowLeft size={14} /></button> <button className="btn btn-sm btn-danger" onClick={() => setDeleting(item)} title="حذف" aria-label={`حذف ${item.title}`}><Trash2 size={15} /></button>
<button className="btn btn-sm btn-secondary" onClick={() => openEdit(item)}><Edit3 size={14} /></button> </div></td>
<button className="btn btn-sm btn-danger" onClick={() => setDeleting(item)}><Trash2 size={14} /></button> </tr>
</div> ))}</tbody>
</td>
</tr>
))}
</tbody>
</table> </table>
</div> </div>
</div> </div>
)} )}
{showModal && ( {showModal && (
<Modal title={editing ? 'ویرایش آیتم' : 'آیتم جدید'} onClose={() => setShowModal(false)} size="xl" footer={ <Modal title={editing ? 'ویرایش بک‌لاگ' : 'بک‌لاگ جدید'} onClose={() => setShowModal(false)} size="xl" footer={<><button className="btn btn-secondary" onClick={() => setShowModal(false)}>انصراف</button><button className="btn btn-primary" onClick={handleSave} disabled={saving}>{saving ? 'در حال ذخیره...' : 'ذخیره'}</button></>}>
<><button className="btn btn-secondary" onClick={() => setShowModal(false)}>انصراف</button><button className="btn btn-primary" onClick={handleSave} disabled={saving}>{saving ? '...' : 'ذخیره'}</button></> <form onSubmit={handleSave} className="compact-modal-form"><div className="compact-modal-grid">
}> <div className="form-group"><label className="form-label" htmlFor="backlog-title">عنوان *</label><input id="backlog-title" className="form-input" value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} /></div>
<form onSubmit={handleSave} className="compact-modal-form"> <div className="form-group wide"><label className="form-label" htmlFor="backlog-description">توضیحات</label><textarea id="backlog-description" className="form-textarea" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></div>
<div className="form-group"><label className="form-label" htmlFor="backlog-type">نوع</label><select id="backlog-type" className="form-select" value={form.type} onChange={(event) => setForm({ ...form, type: event.target.value })}><option value="idea">ایده</option><option value="feature">فیچر</option><option value="bug">باگ</option><option value="improvement">بهبود</option><option value="task">تسک</option></select></div>
<div className="form-group"><label className="form-label" htmlFor="backlog-project">پروژه</label><select id="backlog-project" className="form-select" value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">انتخاب کنید</option>{projects.map((project) => <option key={project.id} value={project.id}>{project.title}</option>)}</select></div>
<div className="form-group"><label className="form-label" htmlFor="backlog-priority">اولویت</label><select id="backlog-priority" className="form-select" value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="urgent">فوری</option></select></div>
<div className="form-group"><label className="form-label" htmlFor="backlog-effort">تلاش تخمینی</label><select id="backlog-effort" className="form-select" value={form.estimated_effort} onChange={(event) => setForm({ ...form, estimated_effort: event.target.value })}><option value="">انتخاب کنید</option><option value="1">۱</option><option value="2">۲</option><option value="3">۳</option><option value="5">۵</option><option value="8">۸</option><option value="13">۱۳</option><option value="21">۲۱</option></select></div>
</div></form>
</Modal>
)}
{converting && (
<Modal title="تبدیل بک‌لاگ به تسک" onClose={() => !convertingTask && setConverting(null)} size="lg" footer={<><button className="btn btn-secondary" onClick={() => setConverting(null)} disabled={convertingTask}>انصراف</button><button className="btn btn-success" onClick={convertToTask} disabled={convertingTask}>{convertingTask ? 'در حال تبدیل...' : 'ایجاد و تخصیص تسک'}</button></>}>
<form onSubmit={convertToTask} className="conversion-form">
<div className="conversion-summary"><ListTodo size={20} /><div><strong>{converting.title}</strong><span>{converting.project?.title}</span></div></div>
<p className="form-helper">پس از ایجاد موفق تسک، این بکلاگ بهصورت خودکار آرشیو میشود و در فهرست آرشیوی باقی میماند.</p>
<div className="compact-modal-grid"> <div className="compact-modal-grid">
<div className="form-group"><label className="form-label">عنوان *</label><input className="form-input" value={form.title} onChange={e => setForm({...form, title: e.target.value})} /></div> <div className="form-group wide"><label className="form-label" htmlFor="task-assignee">مسئول تسک *</label><select id="task-assignee" className="form-select" value={conversion.assignee_id} onChange={(event) => setConversion({ ...conversion, assignee_id: event.target.value })}><option value="">انتخاب مسئول</option>{conversionUsers.map((user) => <option key={user.id} value={user.id}>{user.name}{user.job_title ? `${user.job_title}` : ''}</option>)}</select>{conversionUsers.length === 0 && <span className="form-helper">در حال دریافت اعضای پروژه...</span>}</div>
<div className="form-group wide"><label className="form-label">توضیحات</label><textarea className="form-textarea" value={form.description} onChange={e => setForm({...form, description: e.target.value})} /></div> <div className="form-group"><label className="form-label" htmlFor="task-priority">اولویت</label><select id="task-priority" className="form-select" value={conversion.priority} onChange={(event) => setConversion({ ...conversion, priority: event.target.value })}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="urgent">فوری</option></select></div>
<div className="form-group"><label className="form-label">نوع</label> <div className="form-group"><label className="form-label" htmlFor="task-estimate">زمان تخمینی (ساعت)</label><input id="task-estimate" type="number" min="0" step="0.5" className="form-input" value={conversion.estimated_time} onChange={(event) => setConversion({ ...conversion, estimated_time: event.target.value })} /></div>
<select className="form-select" value={form.type} onChange={e => setForm({...form, type: e.target.value})}> <div className="form-group"><label className="form-label">تاریخ شروع</label><PersianDateInput value={conversion.start_date} onChange={(value) => setConversion({ ...conversion, start_date: value })} /></div>
<option value="idea">ایده</option><option value="feature">فیچر</option><option value="bug">باگ</option> <div className="form-group"><label className="form-label">تاریخ پایان</label><PersianDateInput value={conversion.due_date} onChange={(value) => setConversion({ ...conversion, due_date: value })} /></div>
<option value="improvement">بهبود</option><option value="task">تسک</option>
</select>
</div>
<div className="form-group"><label className="form-label">پروژه</label>
<select className="form-select" value={form.project_id} onChange={e => setForm({...form, project_id: e.target.value})}>
<option value="">انتخاب کنید</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</div>
<div className="form-group"><label className="form-label">اولویت</label>
<select className="form-select" value={form.priority} onChange={e => setForm({...form, priority: e.target.value})}>
<option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="urgent">فوری</option>
</select>
</div>
<div className="form-group"><label className="form-label">وضعیت</label>
<select className="form-select" value={form.status} onChange={e => setForm({...form, status: e.target.value})}>
<option value="new">جدید</option><option value="reviewed">بررسیشده</option><option value="ready">آماده اجرا</option><option value="rejected">رد شده</option>
</select>
</div>
<div className="form-group"><label className="form-label">تلاش تخمینی</label>
<select className="form-select" value={form.estimated_effort} onChange={e => setForm({...form, estimated_effort: e.target.value})}>
<option value="">انتخاب کنید</option>
<option value="1">۱</option><option value="2">۲</option><option value="3">۳</option><option value="5">۵</option>
<option value="8">۸</option><option value="13">۱۳</option><option value="21">۲۱</option>
</select>
</div>
</div> </div>
</form> </form>
</Modal> </Modal>
)} )}
{deleting && <ConfirmDialog title="حذف آیتم" message={`آیا از حذف "${deleting.title}" اطمینان دارید؟`} onConfirm={handleDelete} onCancel={() => setDeleting(null)} danger />} {deleting && <ConfirmDialog title="حذف بک‌لاگ" message={`آیا از حذف «${deleting.title}» اطمینان دارید؟`} onConfirm={handleDelete} onCancel={() => setDeleting(null)} danger />}
</div> </div>
); );
} }

مشاهده پرونده

@ -7,7 +7,7 @@ import EmptyState from '../components/EmptyState';
import { TableSkeleton } from '../components/LoadingSkeleton'; import { TableSkeleton } from '../components/LoadingSkeleton';
import FormSelect from '../components/FormSelect'; import FormSelect from '../components/FormSelect';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Search, CheckCircle, Users, Paperclip, Calendar, FileText, UserPlus, Trash2 } from 'lucide-react'; import { Archive, Search, CheckCircle, Users, Paperclip, Calendar, FileText, UserPlus, Trash2 } from 'lucide-react';
import { formatJalaliDate, formatJalaliDateTime, isPastDate } from '../utils/date'; import { formatJalaliDate, formatJalaliDateTime, isPastDate } from '../utils/date';
export default function ProjectDetail() { export default function ProjectDetail() {
@ -16,6 +16,7 @@ export default function ProjectDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('overview'); const [tab, setTab] = useState('overview');
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [backlogItems, setBacklogItems] = useState([]);
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [selectedMemberId, setSelectedMemberId] = useState(''); const [selectedMemberId, setSelectedMemberId] = useState('');
@ -38,6 +39,7 @@ export default function ProjectDetail() {
useEffect(() => { useEffect(() => {
if (tab === 'tasks') api.get(`/tasks?project_id=${id}`).then(({ data }) => setTasks(data.data || [])).catch(() => {}); if (tab === 'tasks') api.get(`/tasks?project_id=${id}`).then(({ data }) => setTasks(data.data || [])).catch(() => {});
if (tab === 'backlog') api.get('/backlog-items', { params: { project_id: id, archive_state: 'all', per_page: 100 } }).then(({ data }) => setBacklogItems(data.data || [])).catch(() => toast.error('خطا در دریافت بک‌لاگ‌های پروژه'));
if (tab === 'members') api.get('/users?per_page=100').then(({ data }) => setUsers(data.data || [])).catch(() => toast.error('خطا در دریافت لیست کاربران')); if (tab === 'members') api.get('/users?per_page=100').then(({ data }) => setUsers(data.data || [])).catch(() => toast.error('خطا در دریافت لیست کاربران'));
if (tab === 'files') api.get(`/files?fileable_type=App\\Models\\Project&fileable_id=${id}`).then(({ data }) => setFiles(data.data || [])).catch(() => {}); if (tab === 'files') api.get(`/files?fileable_type=App\\Models\\Project&fileable_id=${id}`).then(({ data }) => setFiles(data.data || [])).catch(() => {});
if (tab === 'meetings') api.get(`/meetings?project_id=${id}`).then(({ data }) => setMeetings(data.data || [])).catch(() => {}); if (tab === 'meetings') api.get(`/meetings?project_id=${id}`).then(({ data }) => setMeetings(data.data || [])).catch(() => {});
@ -50,6 +52,7 @@ export default function ProjectDetail() {
const tabs = [ const tabs = [
{ key: 'overview', label: 'نمای کلی' }, { key: 'overview', label: 'نمای کلی' },
{ key: 'tasks', label: 'تسک‌ها' }, { key: 'tasks', label: 'تسک‌ها' },
{ key: 'backlog', label: 'بک‌لاگ‌ها' },
{ key: 'members', label: 'اعضا' }, { key: 'members', label: 'اعضا' },
{ key: 'files', label: 'فایل‌ها' }, { key: 'files', label: 'فایل‌ها' },
{ key: 'meetings', label: 'جلسات' }, { key: 'meetings', label: 'جلسات' },
@ -195,6 +198,28 @@ export default function ProjectDetail() {
</div> </div>
)} )}
{tab === 'backlog' && (
<div className="card table-card">
<div className="table-container">
<table>
<thead><tr><th>عنوان</th><th>نوع</th><th>اولویت</th><th>وضعیت</th><th>تسک مرتبط</th><th>تاریخ آرشیو</th></tr></thead>
<tbody>
{backlogItems.length === 0 ? <tr><td colSpan={6}><EmptyState icon={Archive} title="بک‌لاگی برای این پروژه وجود ندارد" /></td></tr> : backlogItems.map((item) => (
<tr key={item.id}>
<td><div className="backlog-title-cell"><strong>{item.title}</strong>{item.description && <small>{item.description}</small>}</div></td>
<td><span className="badge badge-info">{item.type}</span></td>
<td><PriorityBadge priority={item.priority} /></td>
<td><span className={`badge ${item.is_archived ? 'badge-gray' : 'badge-success'}`}>{item.is_archived ? 'تبدیل‌شده و آرشیوشده' : 'فعال و تبدیل‌نشده'}</span></td>
<td>{item.converted_task ? <div className="backlog-task-cell"><strong>{item.converted_task.title}</strong><small>{item.converted_task.assignee?.name || 'بدون مسئول'}</small></div> : '—'}</td>
<td>{item.archived_at ? formatJalaliDate(item.archived_at) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === 'members' && ( {tab === 'members' && (
<> <>
<form className="card" onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <form className="card" onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-end', marginBottom: '1.5rem', flexWrap: 'wrap' }}>

مشاهده پرونده

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import api from '../services/api'; import api from '../services/api';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import ConfirmDialog from '../components/ConfirmDialog'; import ConfirmDialog from '../components/ConfirmDialog';
@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState';
import PriorityBadge from '../components/PriorityBadge'; import PriorityBadge from '../components/PriorityBadge';
import { CardSkeleton } from '../components/LoadingSkeleton'; import { CardSkeleton } from '../components/LoadingSkeleton';
import PersianDateInput from '../components/PersianDateInput'; import PersianDateInput from '../components/PersianDateInput';
import MultiSelectDropdown from '../components/MultiSelectDropdown';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ArrowRight, CalendarDays, CheckCircle, Edit3, Play, Plus, Timer, Trash2, XCircle } from 'lucide-react'; import { ArrowRight, CalendarDays, CheckCircle, Edit3, Play, Plus, Timer, Trash2, XCircle } from 'lucide-react';
import { formatJalaliDate, isPastDate } from '../utils/date'; import { formatJalaliDate, isPastDate } from '../utils/date';
@ -68,7 +69,7 @@ export default function Sprints() {
const [draggedTask, setDraggedTask] = useState(null); const [draggedTask, setDraggedTask] = useState(null);
const [retro, setRetro] = useState({ went_well: '', problems: '', improvements: '' }); const [retro, setRetro] = useState({ went_well: '', problems: '', improvements: '' });
const fetchSprints = async () => { const fetchSprints = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const params = { per_page: 100 }; const params = { per_page: 100 };
@ -81,9 +82,9 @@ export default function Sprints() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [filters.project_id, filters.status]);
useEffect(() => { fetchSprints(); }, [filters]); useEffect(() => { fetchSprints(); }, [fetchSprints]);
useEffect(() => { useEffect(() => {
api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {}); api.get('/projects', { params: { per_page: 100 } }).then(({ data }) => setProjects(data.data || [])).catch(() => {});
@ -593,9 +594,12 @@ export default function Sprints() {
<Field label="تاریخ پایان" required><PersianDateInput value={form.end_date} onChange={(value) => setForm({ ...form, end_date: value })} /></Field> <Field label="تاریخ پایان" required><PersianDateInput value={form.end_date} onChange={(value) => setForm({ ...form, end_date: value })} /></Field>
<Field label="ظرفیت اختیاری (ساعت)"><input className="form-input" type="number" min="0" value={form.capacity_hours} onChange={(e) => setForm({ ...form, capacity_hours: e.target.value })} /></Field> <Field label="ظرفیت اختیاری (ساعت)"><input className="form-input" type="number" min="0" value={form.capacity_hours} onChange={(e) => setForm({ ...form, capacity_hours: e.target.value })} /></Field>
<Field label="اعضای Sprint"> <Field label="اعضای Sprint">
<select className="form-select" multiple value={form.member_ids.map(String)} onChange={(e) => setForm({ ...form, member_ids: Array.from(e.target.selectedOptions).map((option) => Number(option.value)) })}> <MultiSelectDropdown
{users.map((user) => <option key={user.id} value={user.id}>{user.name}</option>)} options={users.map((user) => ({ value: user.id, label: user.name }))}
</select> value={form.member_ids}
onChange={(memberIds) => setForm({ ...form, member_ids: memberIds })}
placeholder="انتخاب اعضای Sprint"
/>
</Field> </Field>
<Field label="هدف Sprint" className="wide"> <Field label="هدف Sprint" className="wide">
<textarea className="form-textarea" value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="تا پایان این Sprint، نسخه اولیه پنل کاربر آماده تست داخلی شود." /> <textarea className="form-textarea" value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="تا پایان این Sprint، نسخه اولیه پنل کاربر آماده تست داخلی شود." />

مشاهده پرونده

@ -143,6 +143,7 @@ body {
} }
html[data-theme='dark'] { html[data-theme='dark'] {
color-scheme: dark;
--gray-50: #1f252c; --gray-50: #1f252c;
--gray-100: #2a313a; --gray-100: #2a313a;
--gray-200: #39424d; --gray-200: #39424d;
@ -255,7 +256,7 @@ html[data-theme='dark'] .drawer {
.btn-outline:hover { background: var(--gray-50); border-color: var(--gray-400); } .btn-outline:hover { background: var(--gray-50); border-color: var(--gray-400); }
.btn:disabled, .btn:disabled,
button:disabled { button:disabled {
color: var(--color-disabled-text); opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
@ -1831,3 +1832,68 @@ html[data-theme='dark'] .org-alert .btn-outline:hover {
@media (max-width:900px) { .sprint-meeting-type-grid { grid-template-columns: repeat(2,1fr); }.sprint-meeting-schedule > div { grid-template-columns: 1fr 1fr; } } @media (max-width:900px) { .sprint-meeting-type-grid { grid-template-columns: repeat(2,1fr); }.sprint-meeting-schedule > div { grid-template-columns: 1fr 1fr; } }
@media (max-width:560px) { .sprint-meeting-type-grid,.sprint-meeting-schedule > div { grid-template-columns: 1fr; } } @media (max-width:560px) { .sprint-meeting-type-grid,.sprint-meeting-schedule > div { grid-template-columns: 1fr; } }
@media (max-width:640px) { .settings-unsaved-bar { align-items: stretch; flex-direction: column; }.settings-unsaved-bar > div:last-child { justify-content: flex-end; } } @media (max-width:640px) { .settings-unsaved-bar { align-items: stretch; flex-direction: column; }.settings-unsaved-bar > div:last-child { justify-content: flex-end; } }
/* Backlog lifecycle, member dropdown and theme-safe data surfaces */
.table-card { padding: 0; overflow: hidden; }
.muted-cell { color: var(--color-text-muted); font-size: .875rem; }
.table-actions { display: flex; align-items: center; gap: .35rem; }
.table-actions .btn { min-width: 36px; min-height: 36px; justify-content: center; padding: .4rem; }
.backlog-page-header { gap: 1rem; flex-wrap: wrap; }
.backlog-view-switch { display: inline-flex; gap: .25rem; padding: .3rem; background: var(--surface); border: 1px solid var(--color-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.backlog-view-switch button { min-height: 40px; display: inline-flex; align-items: center; gap: .45rem; padding: 0 .9rem; color: var(--color-text-secondary); border-radius: var(--radius-sm); transition: var(--transition); }
.backlog-view-switch button:hover { color: var(--color-heading); background: var(--surface-muted); }
.backlog-view-switch button.active { color: var(--text-on-primary); background: var(--primary); font-weight: 700; }
.backlog-title-cell,.backlog-task-cell { min-width: 160px; display: grid; gap: .2rem; }
.backlog-title-cell strong,.backlog-task-cell strong { color: var(--color-heading); font-weight: 700; }
.backlog-title-cell small { max-width: 42ch; overflow: hidden; color: var(--color-text-muted); text-overflow: ellipsis; white-space: nowrap; }
.backlog-task-cell small { display: inline-flex; align-items: center; gap: .3rem; color: var(--color-text-muted); }
.conversion-form { display: grid; gap: 1rem; }
.conversion-summary { display: flex; align-items: center; gap: .75rem; padding: .85rem; color: var(--primary); background: var(--surface-muted); border: 1px solid var(--color-border); border-radius: var(--radius); }
.conversion-summary > div { display: grid; }
.conversion-summary strong { color: var(--color-heading); }
.conversion-summary span,.form-helper { color: var(--color-text-muted); font-size: .78rem; }
.form-group > .form-helper { display: block; margin-top: .3rem; }
.multi-select { position: relative; width: 100%; }
.modal-body:has(.multi-select.open),.modal-body:has(.persian-date-popover) { overflow: visible; }
.multi-select-trigger { width: 100%; min-height: 44px; display: flex; align-items: center; gap: .5rem; padding: .45rem .75rem; color: var(--color-input-text); background: var(--color-input-bg); border: 1px solid var(--color-input-border); border-radius: var(--radius-sm); text-align: right; transition: var(--transition); }
.multi-select.open .multi-select-trigger,.multi-select-trigger:focus-visible { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb,var(--primary) 18%,transparent); outline: none; }
.multi-select-value { flex: 1; min-width: 0; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
.multi-select-placeholder { color: var(--color-placeholder); }
.multi-select-chip { min-height: 28px; display: inline-flex; align-items: center; gap: .3rem; padding: .15rem .5rem; color: var(--color-text-primary); background: var(--surface-active); border: 1px solid var(--color-border); border-radius: 999px; }
.multi-select-chip svg { color: var(--color-text-muted); }
.multi-select-chevron { flex: 0 0 auto; color: var(--color-text-muted); transition: transform .2s ease; }
.multi-select.open .multi-select-chevron { transform: rotate(180deg); }
.multi-select-menu { position: absolute; z-index: 180; top: calc(100% + 6px); right: 0; left: 0; min-width: 260px; padding: .5rem; background: var(--surface-elevated); border: 1px solid var(--color-border); border-radius: var(--radius); box-shadow: var(--shadow-lg); }
.multi-select-search { min-height: 40px; display: flex; align-items: center; gap: .45rem; padding: 0 .65rem; color: var(--color-text-muted); background: var(--color-input-bg); border: 1px solid var(--color-input-border); border-radius: var(--radius-sm); }
.multi-select-search:focus-within { border-color: var(--primary); }
.multi-select-search input { width: 100%; color: var(--color-input-text); background: transparent; border: 0; }
.multi-select-options { max-height: 220px; overflow-y: auto; display: grid; gap: .2rem; margin-top: .45rem; }
.multi-select-options > button { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: 0 .65rem; color: var(--color-text-primary); border-radius: var(--radius-sm); text-align: right; }
.multi-select-options > button:hover,.multi-select-options > button.selected { background: var(--surface-active); }
.multi-select-options > button.selected { color: var(--primary-light); font-weight: 700; }
.multi-select-check { width: 22px; height: 22px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--color-input-border); border-radius: 6px; }
.multi-select-options > button.selected .multi-select-check { color: var(--text-on-primary); background: var(--primary); border-color: var(--primary); }
.multi-select-empty { padding: 1rem; color: var(--color-text-muted); text-align: center; }
.multi-select-clear { width: 100%; min-height: 36px; margin-top: .35rem; color: var(--danger); border-top: 1px solid var(--color-divider); }
html[data-theme='dark'] table { color: var(--color-text-primary); background: var(--color-card-bg); }
html[data-theme='dark'] thead th { color: var(--color-text-secondary); background: var(--surface-muted); border-color: var(--color-border); }
html[data-theme='dark'] tbody td { color: var(--color-text-primary); border-color: var(--color-divider); }
html[data-theme='dark'] tbody tr:hover { background: var(--surface-elevated); }
html[data-theme='dark'] select option { color: var(--color-input-text); background: var(--color-input-bg); }
html[data-theme='dark'] .modal-overlay,html[data-theme='dark'] .drawer-overlay { background: rgba(0,0,0,.68); }
html[data-theme='dark'] .modal-header,html[data-theme='dark'] .modal-footer,html[data-theme='dark'] .drawer-header,html[data-theme='dark'] .drawer-footer { border-color: var(--color-border); }
html[data-theme='dark'] .modal-close { color: #fecaca; background: rgba(239,68,68,.16); border-color: rgba(248,113,113,.45); }
html[data-theme='dark'] .modal-close:hover { color: #fff; background: var(--danger); }
html[data-theme='dark'] input[type='date'],html[data-theme='dark'] input[type='time'],html[data-theme='dark'] input[type='number'] { color-scheme: dark; }
html[data-theme='dark'] .app-toast { color: var(--color-text-primary)!important; background: var(--surface-elevated)!important; border: 1px solid var(--color-border)!important; }
@media (max-width: 640px) {
.backlog-page-header { align-items: stretch; }
.backlog-view-switch { width: 100%; }
.backlog-view-switch button { flex: 1; justify-content: center; }
.compact-modal-grid { grid-template-columns: 1fr; }
.compact-modal-grid .wide { grid-column: auto; }
.multi-select-menu { min-width: 100%; }
}