implement backlog task conversion and archive workflow
مخزن کامیت contained در
والد
d22b776572
کامیت
9901a25c68
|
|
@ -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%; }
|
||||||
|
}
|
||||||
|
|
|
||||||
بارگذاری…
مرجع در شماره جدید