feat: allow project task reassignment

مخزن کامیت contained در
مسعود نیک زاد 2026-08-29 18:18:17 +03:30
والد 9901a25c68
کامیت 4b9621f41f
6فایلهای تغییر یافته به همراه245 افزوده شده و 11 حذف شده

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

@ -14,6 +14,7 @@ use App\Services\ResourceAccessService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class TaskController extends Controller class TaskController extends Controller
{ {
@ -269,15 +270,26 @@ class TaskController extends Controller
public function updateAssignee(Request $request, Task $task): JsonResponse 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 { try {
$request->validate(['assignee_id' => 'nullable|exists:users,id']);
$oldAssigneeId = $task->assignee_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( $this->notificationService->create(
$request->assignee_id, $assigneeId,
'task_assigned', 'task_assigned',
[ [
'task_id' => $task->id, 'task_id' => $task->id,
@ -298,7 +310,7 @@ class TaskController extends Controller
$task->id, $task->id,
$task->project_id, $task->project_id,
$task->id, $task->id,
['old_assignee' => $oldAssigneeId, 'new_assignee' => $request->assignee_id] ['old_assignee' => $oldAssigneeId, 'new_assignee' => $assigneeId]
); );
return response()->json([ return response()->json([

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

@ -2,7 +2,9 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class UpdateTaskRequest extends FormRequest class UpdateTaskRequest extends FormRequest
{ {
@ -28,4 +30,28 @@ class UpdateTaskRequest extends FormRequest
'actual_time' => 'nullable|numeric', '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', 'مسئول جدید باید از اعضای تأییدشده همین پروژه باشد.');
}
});
}
} }

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

@ -0,0 +1,68 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskAssignmentTest extends TestCase
{
use RefreshDatabase;
public function test_task_can_only_be_transferred_to_an_approved_project_member(): void
{
$admin = User::factory()->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,
]);
}
}

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

@ -106,3 +106,63 @@ test('dashboard command surfaces and settings render without runtime errors', as
expect(runtimeErrors).toEqual([]); 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();
});

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

@ -6,12 +6,14 @@ import PriorityBadge from '../components/PriorityBadge';
import EmptyState from '../components/EmptyState'; 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 { useAuth } from '../context/AuthContext';
import toast from 'react-hot-toast'; 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'; import { formatJalaliDate, formatJalaliDateTime, isPastDate } from '../utils/date';
export default function ProjectDetail() { export default function ProjectDetail() {
const { id } = useParams(); const { id } = useParams();
const { user } = useAuth();
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('overview'); const [tab, setTab] = useState('overview');
@ -26,6 +28,7 @@ export default function ProjectDetail() {
const [meetings, setMeetings] = useState([]); const [meetings, setMeetings] = useState([]);
const [activities, setActivities] = useState([]); const [activities, setActivities] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [transferringTaskId, setTransferringTaskId] = useState(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
useEffect(() => { useEffect(() => {
@ -38,7 +41,7 @@ export default function ProjectDetail() {
}, [id]); }, [id]);
useEffect(() => { 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 === '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(() => {});
@ -83,6 +86,36 @@ export default function ProjectDetail() {
}; };
const availableUsers = users.filter(user => !members.some(member => member.id === user.id)); 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) => { const handleAddMember = async (e) => {
e.preventDefault(); e.preventDefault();
@ -176,10 +209,14 @@ export default function ProjectDetail() {
)} )}
{tab === 'tasks' && ( {tab === 'tasks' && (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}> <div className="card project-tasks-card">
<div className="task-assignment-note">
<UserRoundCog size={19} />
<div><strong>انتقال مسئول تسک</strong><span>مسئول هر تسک را فقط از میان اعضای تأییدشده همین پروژه انتخاب کنید.</span></div>
</div>
<div className="table-container"> <div className="table-container">
<table> <table>
<thead><tr><th>عنوان</th><th>اولویت</th><th>وضعیت</th><th>تخصیصیافته به</th><th>ددلاین</th></tr></thead> <thead><tr><th>عنوان</th><th>اولویت</th><th>وضعیت</th><th>مسئول / انتقال</th><th>ددلاین</th></tr></thead>
<tbody> <tbody>
{tasks.length === 0 ? <tr><td colSpan={5}><EmptyState icon={CheckCircle} title="تسکی وجود ندارد" /></td></tr> : {tasks.length === 0 ? <tr><td colSpan={5}><EmptyState icon={CheckCircle} title="تسکی وجود ندارد" /></td></tr> :
tasks.map(t => ( tasks.map(t => (
@ -187,7 +224,24 @@ export default function ProjectDetail() {
<td style={{ fontWeight: 500 }}>{t.title}</td> <td style={{ fontWeight: 500 }}>{t.title}</td>
<td><PriorityBadge priority={t.priority} /></td> <td><PriorityBadge priority={t.priority} /></td>
<td><StatusBadge status={t.status} /></td> <td><StatusBadge status={t.status} /></td>
<td>{t.assignee?.name || '—'}</td> <td>
{canAssignTasks ? <div className="task-assignee-control">
<select
className="form-select task-assignee-select"
value={t.assignee_id || ''}
disabled={transferringTaskId !== null || approvedAssignees.length === 0}
onChange={(event) => handleTransferTask(t, event.target.value)}
aria-label={`انتقال مسئول تسک ${t.title}`}
>
{!t.assignee_id && <option value="" disabled>انتخاب مسئول</option>}
{t.assignee_id && !approvedAssignees.some((member) => Number(member.id) === Number(t.assignee_id)) && (
<option value={t.assignee_id} disabled>{t.assignee?.name || 'مسئول قبلی'} خارج از اعضای پروژه</option>
)}
{approvedAssignees.map((member) => <option key={member.id} value={member.id}>{member.name}{member.job_title ? `${member.job_title}` : ''}</option>)}
</select>
{transferringTaskId === t.id && <span className="task-assignee-loading" role="status"><LoaderCircle size={16} /> در حال انتقال</span>}
</div> : <span>{t.assignee?.name || 'بدون مسئول'}</span>}
</td>
<td style={{ color: isPastDate(t.due_date) && t.status !== 'done' ? 'var(--danger)' : 'inherit' }}>{formatJalaliDate(t.due_date)}</td> <td style={{ color: isPastDate(t.due_date) && t.status !== 'done' ? 'var(--danger)' : 'inherit' }}>{formatJalaliDate(t.due_date)}</td>
</tr> </tr>
)) ))

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

@ -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'] 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; } 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) { @media (max-width: 640px) {
.backlog-page-header { align-items: stretch; } .backlog-page-header { align-items: stretch; }
.backlog-view-switch { width: 100%; } .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 { grid-template-columns: 1fr; }
.compact-modal-grid .wide { grid-column: auto; } .compact-modal-grid .wide { grid-column: auto; }
.multi-select-menu { min-width: 100%; } .multi-select-menu { min-width: 100%; }
.task-assignment-note { align-items: flex-start; }
.task-assignee-control { min-width: 190px; }
} }