diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py
index 3a52c773..1e635764 100644
--- a/backend/api/v1/tasks/views.py
+++ b/backend/api/v1/tasks/views.py
@@ -449,7 +449,13 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance):
recipient_ids = get_task_board_recipient_ids(instance)
board_id = instance.id
- instance.delete()
+ # Task.column is protected so an occupied column cannot be removed on its
+ # own. When the whole board is deleted, remove its tasks first so that
+ # Django can cascade the remaining board structure without raising
+ # ProtectedError.
+ with transaction.atomic():
+ Task.objects.filter(board_id=board_id).delete()
+ instance.delete()
send_task_board_update(
None,
"deleted",
diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py
index 06744275..9728677f 100644
--- a/backend/tests/api/v1/tasks/test_tasks_api.py
+++ b/backend/tests/api/v1/tasks/test_tasks_api.py
@@ -175,6 +175,27 @@ def test_board_summary_list_returns_navigation_counts_without_nested_tasks(
assert "columns" not in result
+def test_owner_can_delete_board_with_tasks(api_client, user):
+ board = TaskBoard.objects.create(name="Доска на удаление", created_by=user)
+ column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
+ task = Task.objects.create(
+ board=board,
+ column=column,
+ title="Задача на удаление",
+ created_by=user,
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.delete(
+ reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
+ )
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ assert not TaskBoard.objects.filter(id=board.id).exists()
+ assert not Task.objects.filter(id=task.id).exists()
+ assert not TaskColumn.objects.filter(id=column.id).exists()
+
+
def test_board_avatar_can_be_uploaded_listed_and_removed(
api_client,
user,
diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx
index 36793d50..6b31a7a5 100644
--- a/frontend/src/app/finances/page.tsx
+++ b/frontend/src/app/finances/page.tsx
@@ -127,6 +127,8 @@ export default function FinancesPage() {
Финансы
{activeTab === "management" && showPayrollAdmin ? (
+ ) : activeTab === "work" ? (
+
) : null}
@@ -176,7 +178,7 @@ export default function FinancesPage() {
onDesktopWideModeChange={changeDesktopWideMode}
/>
) : activeTab === "work" ? (
-
+
) : (
<>
{loading ? (
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 3f40a269..64816d3e 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -19,6 +19,7 @@
--danger-soft: rgba(239, 68, 68, 0.12);
--danger-foreground: #dc2626;
--warning-soft: rgba(245, 158, 11, 0.15);
+ --warning-foreground: #b45309;
--regulation-border: #a9822f;
--regulation-soft: rgba(169, 130, 47, 0.1);
--app-shell-gradient: radial-gradient(circle at top, rgba(125, 211, 252, 0.12), transparent 32%), linear-gradient(180deg, #f5f9ff 0%, #eef3fb 46%, #f4f7fb 100%);
@@ -53,6 +54,7 @@ html[data-bs-theme="dark"] {
--danger-soft: rgba(239, 68, 68, 0.16);
--danger-foreground: #f87171;
--warning-soft: rgba(245, 158, 11, 0.18);
+ --warning-foreground: #fbbf24;
--regulation-border: #d0ad58;
--regulation-soft: rgba(208, 173, 88, 0.12);
--app-shell-gradient: radial-gradient(circle at top, rgba(30, 167, 255, 0.08), transparent 24%), linear-gradient(180deg, #10161d 0%, #111922 38%, #0b1016 100%);
@@ -636,7 +638,7 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
.app-feedback-warning {
border: 1px solid color-mix(in srgb, #f59e0b 20%, var(--border-subtle));
background: var(--warning-soft);
- color: #b45309;
+ color: var(--warning-foreground);
}
.app-feedback-approval {
diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx
index 9a601bcd..678ce604 100644
--- a/frontend/src/app/tasks/page.tsx
+++ b/frontend/src/app/tasks/page.tsx
@@ -34,6 +34,7 @@ import {
Eye,
EyeOff,
ExternalLink as ExternalLinkIcon,
+ Flag,
FileSignature,
FileText,
History,
@@ -171,7 +172,7 @@ type ColumnRowFormState = {
type LinkedItemMode = "document" | "calendar_event" | "external_link";
type LinkedItemTarget = "view" | "form";
-type TaskAddPanel = "due_date" | "labels" | null;
+type TaskAddPanel = "due_date" | "labels" | "priority" | null;
type TaskCoverLinkedOption = {
id: number;
@@ -317,6 +318,7 @@ const priorityOptions: {
urgencyLabel: string;
className: string;
textClassName: string;
+ dotClassName: string;
}[] = [
{
value: "low",
@@ -324,6 +326,7 @@ const priorityOptions: {
urgencyLabel: "Низкая",
className: "app-badge",
textClassName: "app-text-muted",
+ dotClassName: "bg-slate-400",
},
{
value: "medium",
@@ -331,6 +334,7 @@ const priorityOptions: {
urgencyLabel: "Средняя",
className: "app-selected",
textClassName: "text-[var(--accent-primary-strong)]",
+ dotClassName: "bg-sky-500",
},
{
value: "high",
@@ -338,6 +342,7 @@ const priorityOptions: {
urgencyLabel: "Высокая",
className: "app-feedback-warning",
textClassName: "text-[var(--warning-foreground)]",
+ dotClassName: "bg-amber-500",
},
{
value: "critical",
@@ -345,6 +350,7 @@ const priorityOptions: {
urgencyLabel: "Критическая",
className: "app-feedback-danger",
textClassName: "text-[var(--danger-foreground)]",
+ dotClassName: "bg-red-500",
},
];
@@ -539,7 +545,7 @@ function TaskCardView({
) : null}
@@ -779,16 +785,20 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
function QuickTaskComposer({
title,
+ priority,
saving,
columnName,
onTitleChange,
+ onPriorityChange,
onSubmit,
onCancel,
}: {
title: string;
+ priority: TaskPriority;
saving: boolean;
columnName: string;
onTitleChange: (value: string) => void;
+ onPriorityChange: (value: TaskPriority) => void;
onSubmit: () => void;
onCancel: () => void;
}) {
@@ -817,26 +827,51 @@ function QuickTaskComposer({
placeholder="Название задачи"
aria-label={`Название новой задачи в колонке ${columnName}`}
/>
-
-
-
+
+
+ {priorityOptions.map((option) => {
+ const selected = option.value === priority;
+ return (
+
+ );
+ })}
+
+
+
+
+
);
@@ -3286,6 +3321,7 @@ function TasksPageContent() {
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [quickTaskTarget, setQuickTaskTarget] = useState
(null);
const [quickTaskTitle, setQuickTaskTitle] = useState("");
+ const [quickTaskPriority, setQuickTaskPriority] = useState("medium");
const [quickTaskSaving, setQuickTaskSaving] = useState(false);
const [taskParticipantsOpen, setTaskParticipantsOpen] = useState(false);
const [taskParticipantSearch, setTaskParticipantSearch] = useState("");
@@ -3294,6 +3330,7 @@ function TasksPageContent() {
const [participantModalSelection, setParticipantModalSelection] = useState([]);
const [taskMenuOpen, setTaskMenuOpen] = useState(false);
const [taskAddMenuOpen, setTaskAddMenuOpen] = useState(false);
+ const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top");
const [taskAddPanel, setTaskAddPanel] = useState(null);
const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState("");
const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false);
@@ -3390,6 +3427,7 @@ function TasksPageContent() {
const boardActionsMenuRef = useRef(null);
const taskMenuRef = useRef(null);
const taskAddMenuRef = useRef(null);
+ const taskAddMenuBottomRef = useRef(null);
const viewColumnMenuRef = useRef(null);
const cardMenuRef = useRef(null);
const columnMenuRef = useRef(null);
@@ -3992,9 +4030,12 @@ function TasksPageContent() {
if (!taskAddMenuOpen) return undefined;
function handlePointerDown(event: MouseEvent) {
+ const activeMenuRef = taskAddMenuPlacement === "bottom"
+ ? taskAddMenuBottomRef
+ : taskAddMenuRef;
if (
- taskAddMenuRef.current
- && !taskAddMenuRef.current.contains(event.target as Node)
+ activeMenuRef.current
+ && !activeMenuRef.current.contains(event.target as Node)
) {
setTaskAddMenuOpen(false);
}
@@ -4002,7 +4043,7 @@ function TasksPageContent() {
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
- }, [taskAddMenuOpen]);
+ }, [taskAddMenuOpen, taskAddMenuPlacement]);
useEffect(() => {
if (!viewColumnMenuOpen) return undefined;
@@ -4378,6 +4419,7 @@ function TasksPageContent() {
if (quickTaskSaving) return;
setQuickTaskTarget(null);
setQuickTaskTitle("");
+ setQuickTaskPriority("medium");
}, [quickTaskSaving]);
const startQuickTask = useCallback((columnId: number, rowId: number | null = null) => {
@@ -4408,6 +4450,7 @@ function TasksPageContent() {
|| quickTaskTarget?.rowId !== rowId
) {
setQuickTaskTitle("");
+ setQuickTaskPriority("medium");
}
setQuickTaskTarget({ columnId, rowId });
setCardMenuTaskId(null);
@@ -4427,16 +4470,18 @@ function TasksPageContent() {
column: target.columnId,
row: target.rowId,
title,
+ priority: quickTaskPriority,
});
setQuickTaskTarget(null);
setQuickTaskTitle("");
+ setQuickTaskPriority("medium");
await loadBoard(board.id);
} catch (quickCreateError) {
setError(getTaskError(quickCreateError, "Не удалось создать задачу"));
} finally {
setQuickTaskSaving(false);
}
- }, [board, loadBoard, quickTaskSaving, quickTaskTarget, quickTaskTitle, saving]);
+ }, [board, loadBoard, quickTaskPriority, quickTaskSaving, quickTaskTarget, quickTaskTitle, saving]);
const renderQuickTaskComposer = useCallback((
column: TaskColumn,
@@ -4445,14 +4490,16 @@ function TasksPageContent() {
quickTaskTarget?.columnId === column.id && quickTaskTarget.rowId === rowId ? (
void submitQuickTask()}
onCancel={cancelQuickTask}
/>
) : null
- ), [cancelQuickTask, quickTaskSaving, quickTaskTarget, quickTaskTitle, submitQuickTask]);
+ ), [cancelQuickTask, quickTaskPriority, quickTaskSaving, quickTaskTarget, quickTaskTitle, submitQuickTask]);
const openCreateTask = useCallback((columnId?: number, rowId: number | null = null) => {
const requestedColumn = leafColumns.find((column) => column.id === columnId);
@@ -5445,6 +5492,27 @@ function TasksPageContent() {
viewTaskDueDateDraft,
]);
+ const changeViewedTaskPriority = useCallback(async (priority: TaskPriority) => {
+ if (!board || !viewTask || saving) return;
+ if (viewTask.priority === priority) {
+ setTaskAddPanel(null);
+ return;
+ }
+
+ setSaving(true);
+ setError(null);
+ try {
+ await apiClient.updateTask(viewTask.id, { priority });
+ await loadBoard(board.id);
+ if (activityOpen) await loadTaskActivity(viewTask.id);
+ setTaskAddPanel(null);
+ } catch (priorityError) {
+ setError(getTaskError(priorityError, "Не удалось изменить приоритет задачи"));
+ } finally {
+ setSaving(false);
+ }
+ }, [activityOpen, board, loadBoard, loadTaskActivity, saving, viewTask]);
+
const toggleViewedTaskLabel = useCallback(async (labelId: number) => {
if (!board || !viewTask || saving) return;
@@ -6570,6 +6638,126 @@ function TasksPageContent() {
}
}, [activeColumns, board, loadBoard]);
+ const taskAddMenuItems = viewTask ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : null;
+
if (loading) {
return (
-
+
+ ) : undefined}
>
{viewTask ? (
@@ -7550,123 +7764,22 @@ function TasksPageContent() {
{
- setTaskAddMenuOpen((current) => !current);
+ const topMenuOpen = taskAddMenuOpen && taskAddMenuPlacement === "top";
+ setTaskAddMenuPlacement("top");
+ setTaskAddMenuOpen(!topMenuOpen);
setTaskAddPanel(null);
}}
className="app-action-primary inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
title="Добавить к задаче"
aria-label="Добавить к задаче"
- aria-expanded={taskAddMenuOpen}
+ aria-expanded={taskAddMenuOpen && taskAddMenuPlacement === "top"}
aria-haspopup="menu"
>
- {taskAddMenuOpen ? (
+ {taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
-
{
- setViewTaskDueDateDraft(viewTask.due_date || "");
- setTaskAddPanel("due_date");
- setTaskAddMenuOpen(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Срок
-
-
{
- setLabelName("");
- setLabelColor("#38bdf8");
- setTaskLabelSearch("");
- setTaskAddPanel("labels");
- setTaskAddMenuOpen(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Метки
-
-
void openTaskCoverPicker()}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Обложка
-
-
{
- setChecklistOpen(true);
- setTaskAddMenuOpen(false);
- window.requestAnimationFrame(() => taskChecklistDraftRef.current?.focus());
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Чек-лист
-
-
{
- setCommentsOpen(true);
- setTaskAddMenuOpen(false);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Комментарий
-
-
{
- setTaskAddMenuOpen(false);
- openLinkedItemModalForView();
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Объект или ссылка
-
-
{
- setAttachmentsOpen(true);
- setTaskAddMenuOpen(false);
- taskAttachmentInputRef.current?.click();
- }}
- disabled={taskAttachmentsUploading}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
- >
-
- Загрузить файл
-
-
{
- setTaskAddMenuOpen(false);
- openParticipantModal();
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Участники
-
-
{
- setTaskAddMenuOpen(false);
- closeTaskView();
- setAutomationModalOpen(true);
- }}
- className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition hover:bg-[var(--surface-secondary)]"
- >
-
- Автоматизация
-
+ {taskAddMenuItems}
) : null}
@@ -7943,6 +8056,50 @@ function TasksPageContent() {
) : null}
+ {taskAddPanel === "priority" ? (
+
+
+
+
+ Приоритет
+
+ setTaskAddPanel(null)}
+ className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
+ title="Закрыть"
+ aria-label="Закрыть выбор приоритета"
+ >
+
+
+
+
+ {priorityOptions.map((option) => {
+ const selected = option.value === viewTask.priority;
+ return (
+ void changeViewedTaskPriority(option.value)}
+ disabled={saving || selected}
+ className={`app-action-secondary flex min-w-0 items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-left disabled:cursor-default ${
+ selected ? "ring-2 ring-[var(--accent-primary)]" : ""
+ }`}
+ aria-pressed={selected}
+ >
+
+ {option.label}
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ ) : null}
+
{taskAddPanel === "labels" ? (
@@ -8789,6 +8946,7 @@ function TasksPageContent() {
) : null}
+
) : null}
diff --git a/frontend/src/components/finance/PayrollDailyWorkSection.tsx b/frontend/src/components/finance/PayrollDailyWorkSection.tsx
index c3050e86..1fcc8b92 100644
--- a/frontend/src/components/finance/PayrollDailyWorkSection.tsx
+++ b/frontend/src/components/finance/PayrollDailyWorkSection.tsx
@@ -9,6 +9,7 @@ import {
Save,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
+import { createPortal } from "react-dom";
import { apiClient } from "@/lib/api";
import type {
@@ -48,7 +49,11 @@ function currentEntryForDate(
return entries.find((entry) => entry.work_date === workDate) || null;
}
-export function PayrollDailyWorkSection() {
+export function PayrollDailyWorkSection({
+ periodSelectorTargetId,
+}: {
+ periodSelectorTargetId?: string;
+}) {
const [workspace, setWorkspace] = useState(null);
const [selectedPeriodId, setSelectedPeriodId] = useState(null);
const [workDate, setWorkDate] = useState("");
@@ -177,9 +182,25 @@ export function PayrollDailyWorkSection() {
const summary = selectedPeriod?.summary || null;
const aggregateStatus = aggregate ? approvalStatusMeta[aggregate.status] : null;
const periodStatus = selectedPeriod ? periodStatusMeta[selectedPeriod.status] : null;
+ const periodSelector = (
+
+ );
+ const periodSelectorTarget = periodSelectorTargetId && typeof document !== "undefined"
+ ? document.getElementById(periodSelectorTargetId)
+ : null;
return (
+ {periodSelectorTarget ? createPortal(periodSelector, periodSelectorTarget) : null}