From 4b9621f41fee8c07b3ca534eab75bbe045aa7d3b Mon Sep 17 00:00:00 2001 From: Toornaa Date: Sat, 29 Aug 2026 18:18:17 +0330 Subject: [PATCH] feat: allow project task reassignment --- .../Http/Controllers/Api/TaskController.php | 24 +++++-- .../app/Http/Requests/UpdateTaskRequest.php | 26 +++++++ backend/tests/Feature/TaskAssignmentTest.php | 68 +++++++++++++++++++ frontend/e2e/command-center.spec.js | 60 ++++++++++++++++ frontend/src/pages/ProjectDetail.jsx | 64 +++++++++++++++-- frontend/src/styles/global.css | 14 ++++ 6 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 backend/tests/Feature/TaskAssignmentTest.php diff --git a/backend/app/Http/Controllers/Api/TaskController.php b/backend/app/Http/Controllers/Api/TaskController.php index 9361bee..95316f0 100644 --- a/backend/app/Http/Controllers/Api/TaskController.php +++ b/backend/app/Http/Controllers/Api/TaskController.php @@ -14,6 +14,7 @@ use App\Services\ResourceAccessService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class TaskController extends Controller { @@ -269,15 +270,26 @@ class TaskController extends Controller public function updateAssignee(Request $request, Task $task): JsonResponse { + $data = $request->validate(['assignee_id' => 'required|exists:users,id']); + $task->loadMissing('project.members:id'); + + $assigneeId = (int) $data['assignee_id']; + $isApprovedProjectMember = $assigneeId === (int) $task->project?->project_manager_id + || $task->project?->members->contains('id', $assigneeId); + + if (! $isApprovedProjectMember) { + throw ValidationException::withMessages([ + 'assignee_id' => 'مسئول جدید باید از اعضای تأییدشده همین پروژه باشد.', + ]); + } + try { - $request->validate(['assignee_id' => 'nullable|exists:users,id']); - $oldAssigneeId = $task->assignee_id; - $task->update(['assignee_id' => $request->assignee_id]); + $task->update(['assignee_id' => $assigneeId]); - if ($request->assignee_id && $request->assignee_id != $oldAssigneeId) { + if ($assigneeId !== (int) $oldAssigneeId) { $this->notificationService->create( - $request->assignee_id, + $assigneeId, 'task_assigned', [ 'task_id' => $task->id, @@ -298,7 +310,7 @@ class TaskController extends Controller $task->id, $task->project_id, $task->id, - ['old_assignee' => $oldAssigneeId, 'new_assignee' => $request->assignee_id] + ['old_assignee' => $oldAssigneeId, 'new_assignee' => $assigneeId] ); return response()->json([ diff --git a/backend/app/Http/Requests/UpdateTaskRequest.php b/backend/app/Http/Requests/UpdateTaskRequest.php index 73fbb8f..39978e3 100644 --- a/backend/app/Http/Requests/UpdateTaskRequest.php +++ b/backend/app/Http/Requests/UpdateTaskRequest.php @@ -2,7 +2,9 @@ namespace App\Http\Requests; +use App\Models\Project; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; class UpdateTaskRequest extends FormRequest { @@ -28,4 +30,28 @@ class UpdateTaskRequest extends FormRequest 'actual_time' => 'nullable|numeric', ]; } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + if (! $this->filled('assignee_id')) { + return; + } + + $task = $this->route('task'); + $projectId = $this->input('project_id', $task?->project_id); + $project = Project::with('members:id')->find($projectId); + if (! $project) { + return; + } + + $assigneeId = (int) $this->input('assignee_id'); + $isApprovedProjectMember = $assigneeId === (int) $project->project_manager_id + || $project->members->contains('id', $assigneeId); + + if (! $isApprovedProjectMember) { + $validator->errors()->add('assignee_id', 'مسئول جدید باید از اعضای تأییدشده همین پروژه باشد.'); + } + }); + } } diff --git a/backend/tests/Feature/TaskAssignmentTest.php b/backend/tests/Feature/TaskAssignmentTest.php new file mode 100644 index 0000000..78253ea --- /dev/null +++ b/backend/tests/Feature/TaskAssignmentTest.php @@ -0,0 +1,68 @@ +create(['status' => 'active']); + $this->grantAdminRole($admin); + $token = $admin->createToken('task-assignment-test')->plainTextToken; + + $approvedMember = User::factory()->create(['status' => 'active']); + $outsideUser = User::factory()->create(['status' => 'active']); + $project = Project::create([ + 'title' => 'پروژه انتقال مسئول', + 'project_manager_id' => $admin->id, + 'created_by' => $admin->id, + 'start_date' => now()->toDateString(), + 'end_date' => now()->addMonth()->toDateString(), + 'status' => 'in_progress', + ]); + $project->members()->attach($approvedMember->id); + + $task = Task::create([ + 'title' => 'تسک قابل انتقال', + 'project_id' => $project->id, + 'assignee_id' => $admin->id, + 'reporter_id' => $admin->id, + 'created_by' => $admin->id, + 'status' => 'todo', + ]); + + $this->withToken($token) + ->putJson("/api/tasks/{$task->id}/assignee", ['assignee_id' => $approvedMember->id]) + ->assertOk() + ->assertJsonPath('data.assignee_id', $approvedMember->id); + + $this->assertDatabaseHas('notifications', [ + 'user_id' => $approvedMember->id, + 'type' => 'task_assigned', + 'notifiable_id' => $task->id, + ]); + + $this->withToken($token) + ->putJson("/api/tasks/{$task->id}/assignee", ['assignee_id' => $outsideUser->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('assignee_id'); + + $this->withToken($token) + ->patchJson("/api/tasks/{$task->id}", ['assignee_id' => $outsideUser->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('assignee_id'); + + $this->assertDatabaseHas('tasks', [ + 'id' => $task->id, + 'assignee_id' => $approvedMember->id, + ]); + } +} diff --git a/frontend/e2e/command-center.spec.js b/frontend/e2e/command-center.spec.js index 091623d..2872cec 100644 --- a/frontend/e2e/command-center.spec.js +++ b/frontend/e2e/command-center.spec.js @@ -106,3 +106,63 @@ test('dashboard command surfaces and settings render without runtime errors', as expect(runtimeErrors).toEqual([]); }); + +test('project task can be transferred to an approved project member', async ({ page }) => { + const assignPermission = { id: 1, name: 'tasks.assign', display_name: 'تخصیص وظایف' }; + const user = { + id: 1, + name: 'مدیر پروژه', + email: 'manager@example.test', + roles: [{ id: 1, name: 'project_manager', permissions: [assignPermission] }], + }; + const approvedMember = { id: 2, name: 'عضو تأییدشده', email: 'member@example.test', job_title: 'توسعه‌دهنده', status: 'active' }; + const project = { + id: 10, + title: 'پروژه انتقال تسک', + description: 'پروژه تست', + project_manager_id: user.id, + project_manager: user, + members: [approvedMember], + status: 'in_progress', + priority: 'high', + progress: 30, + }; + const task = { + id: 50, + title: 'تسک قابل انتقال', + project_id: project.id, + assignee_id: user.id, + assignee: user, + priority: 'medium', + status: 'todo', + due_date: '2026-09-10', + }; + let assignmentPayload = null; + + await page.addInitScript(({ testUser }) => { + localStorage.setItem('token', 'task-assignment-token'); + localStorage.setItem('user', JSON.stringify(testUser)); + }, { testUser: user }); + + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === '/api/user') return route.fulfill({ json: { success: true, data: user } }); + if (url.pathname === `/api/projects/${project.id}`) return route.fulfill({ json: { success: true, data: project } }); + if (url.pathname === '/api/tasks' && route.request().method() === 'GET') return route.fulfill({ json: { success: true, data: [task] } }); + if (url.pathname === `/api/tasks/${task.id}/assignee` && route.request().method() === 'PUT') { + assignmentPayload = route.request().postDataJSON(); + return route.fulfill({ json: { success: true, data: { ...task, assignee_id: approvedMember.id, assignee: approvedMember } } }); + } + return route.fulfill({ json: { success: true, data: [] } }); + }); + + await page.goto(`/projects/${project.id}`); + await page.getByRole('button', { name: 'تسک‌ها', exact: true }).click(); + const assigneeSelect = page.getByLabel('انتقال مسئول تسک تسک قابل انتقال'); + await expect(assigneeSelect).toBeVisible(); + await assigneeSelect.selectOption(String(approvedMember.id)); + + await expect.poll(() => assignmentPayload?.assignee_id).toBe(approvedMember.id); + await expect(assigneeSelect).toHaveValue(String(approvedMember.id)); + await expect(page.getByText(/به عضو تأییدشده منتقل شد/)).toBeVisible(); +}); diff --git a/frontend/src/pages/ProjectDetail.jsx b/frontend/src/pages/ProjectDetail.jsx index c342021..5c47116 100644 --- a/frontend/src/pages/ProjectDetail.jsx +++ b/frontend/src/pages/ProjectDetail.jsx @@ -6,12 +6,14 @@ import PriorityBadge from '../components/PriorityBadge'; import EmptyState from '../components/EmptyState'; import { TableSkeleton } from '../components/LoadingSkeleton'; import FormSelect from '../components/FormSelect'; +import { useAuth } from '../context/AuthContext'; import toast from 'react-hot-toast'; -import { Archive, Search, CheckCircle, Users, Paperclip, Calendar, FileText, UserPlus, Trash2 } from 'lucide-react'; +import { Archive, Search, CheckCircle, Users, Paperclip, Calendar, FileText, LoaderCircle, UserPlus, UserRoundCog, Trash2 } from 'lucide-react'; import { formatJalaliDate, formatJalaliDateTime, isPastDate } from '../utils/date'; export default function ProjectDetail() { const { id } = useParams(); + const { user } = useAuth(); const [project, setProject] = useState(null); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('overview'); @@ -26,6 +28,7 @@ export default function ProjectDetail() { const [meetings, setMeetings] = useState([]); const [activities, setActivities] = useState([]); const [uploading, setUploading] = useState(false); + const [transferringTaskId, setTransferringTaskId] = useState(null); const fileInputRef = useRef(null); useEffect(() => { @@ -38,7 +41,7 @@ export default function ProjectDetail() { }, [id]); useEffect(() => { - if (tab === 'tasks') api.get(`/tasks?project_id=${id}`).then(({ data }) => setTasks(data.data || [])).catch(() => {}); + if (tab === 'tasks') api.get('/tasks', { params: { project_id: id, per_page: 100 } }).then(({ data }) => setTasks(data.data || [])).catch(() => toast.error('خطا در دریافت تسک‌های پروژه')); 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 === 'files') api.get(`/files?fileable_type=App\\Models\\Project&fileable_id=${id}`).then(({ data }) => setFiles(data.data || [])).catch(() => {}); @@ -83,6 +86,36 @@ export default function ProjectDetail() { }; const availableUsers = users.filter(user => !members.some(member => member.id === user.id)); + const approvedAssignees = [...new Map( + [...members, project.project_manager].filter(Boolean).map((member) => [Number(member.id), member]), + ).values()]; + const canAssignTasks = user?.permissions?.includes?.('tasks.assign') + || (user?.roles || []).some((role) => (role.permissions || []).some((permission) => permission.name === 'tasks.assign')); + + const handleTransferTask = async (task, assigneeId) => { + const nextAssigneeId = Number(assigneeId); + if (!nextAssigneeId || nextAssigneeId === Number(task.assignee_id)) return; + + const nextAssignee = approvedAssignees.find((member) => Number(member.id) === nextAssigneeId); + if (!nextAssignee) { + toast.error('فرد انتخاب‌شده عضو تأییدشده این پروژه نیست'); + return; + } + + setTransferringTaskId(task.id); + setTasks((current) => current.map((item) => item.id === task.id ? { ...item, assignee_id: nextAssigneeId, assignee: nextAssignee } : item)); + try { + const { data } = await api.put(`/tasks/${task.id}/assignee`, { assignee_id: nextAssigneeId }); + setTasks((current) => current.map((item) => item.id === task.id ? data.data : item)); + toast.success(`تسک «${task.title}» به ${nextAssignee.name} منتقل شد`); + } catch (error) { + setTasks((current) => current.map((item) => item.id === task.id ? task : item)); + const validationMessage = Object.values(error.response?.data?.errors || {}).flat()[0]; + toast.error(validationMessage || error.response?.data?.message || 'خطا در انتقال مسئول تسک'); + } finally { + setTransferringTaskId(null); + } + }; const handleAddMember = async (e) => { e.preventDefault(); @@ -176,10 +209,14 @@ export default function ProjectDetail() { )} {tab === 'tasks' && ( -
+
+
+ +
انتقال مسئول تسکمسئول هر تسک را فقط از میان اعضای تأییدشده همین پروژه انتخاب کنید.
+
- + {tasks.length === 0 ? : tasks.map(t => ( @@ -187,7 +224,24 @@ export default function ProjectDetail() { - + )) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 6354ad6..43b966d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1889,6 +1889,18 @@ html[data-theme='dark'] .modal-close:hover { color: #fff; background: var(--dang 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; } +.project-tasks-card { padding: 0; overflow: hidden; } +.task-assignment-note { display: flex; align-items: center; gap: .7rem; padding: .85rem 1rem; color: var(--primary); background: var(--surface-muted); border-bottom: 1px solid var(--color-border); } +.task-assignment-note > div { display: grid; gap: .1rem; } +.task-assignment-note strong { color: var(--color-heading); font-size: .875rem; } +.task-assignment-note span { color: var(--color-text-muted); font-size: .75rem; } +.task-assignee-control { min-width: 230px; display: grid; gap: .3rem; } +.task-assignee-select { min-height: 40px; padding-block: .45rem; } +.task-assignee-loading { display: inline-flex; align-items: center; gap: .3rem; color: var(--color-text-muted); font-size: .72rem; } +.task-assignee-loading svg { color: var(--primary); animation: task-assignee-spin .8s linear infinite; } +@keyframes task-assignee-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .task-assignee-loading svg { animation: none; } } + @media (max-width: 640px) { .backlog-page-header { align-items: stretch; } .backlog-view-switch { width: 100%; } @@ -1896,4 +1908,6 @@ html[data-theme='dark'] .app-toast { color: var(--color-text-primary)!important; .compact-modal-grid { grid-template-columns: 1fr; } .compact-modal-grid .wide { grid-column: auto; } .multi-select-menu { min-width: 100%; } + .task-assignment-note { align-items: flex-start; } + .task-assignee-control { min-width: 190px; } }
عنواناولویتوضعیتتخصیص‌یافته بهددلاین
عنواناولویتوضعیتمسئول / انتقالددلاین
{t.title} {t.assignee?.name || '—'} + {canAssignTasks ?
+ + {transferringTaskId === t.id && در حال انتقال} +
: {t.assignee?.name || 'بدون مسئول'}} +
{formatJalaliDate(t.due_date)}