feat: allow project task reassignment
مخزن کامیت contained در
والد
9901a25c68
کامیت
4b9621f41f
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
بارگذاری…
مرجع در شماره جدید