feat: allow project task reassignment
مخزن کامیت contained در
والد
9901a25c68
کامیت
4b9621f41f
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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', 'مسئول جدید باید از اعضای تأییدشده همین پروژه باشد.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
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 { 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' && (
|
||||
<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">
|
||||
<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>
|
||||
{tasks.length === 0 ? <tr><td colSpan={5}><EmptyState icon={CheckCircle} title="تسکی وجود ندارد" /></td></tr> :
|
||||
tasks.map(t => (
|
||||
|
|
@ -187,7 +224,24 @@ export default function ProjectDetail() {
|
|||
<td style={{ fontWeight: 500 }}>{t.title}</td>
|
||||
<td><PriorityBadge priority={t.priority} /></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>
|
||||
</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'] .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; }
|
||||
}
|
||||
|
|
|
|||
بارگذاری…
مرجع در شماره جدید