From 7c6c2bb6b8a98489eb653a1d04173061ed760975 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 09:37:06 +0300 Subject: [PATCH] fix task board interactions and finance layout --- backend/api/v1/tasks/views.py | 8 +- backend/tests/api/v1/tasks/test_tasks_api.py | 21 + frontend/src/app/finances/page.tsx | 4 +- frontend/src/app/globals.css | 4 +- frontend/src/app/tasks/page.tsx | 430 ++++++++++++------ .../finance/PayrollDailyWorkSection.tsx | 55 ++- .../finance/admin/PayrollPeriodTable.tsx | 19 +- frontend/src/hooks/useMiddleMousePan.ts | 105 +++++ frontend/src/lib/task-board-layout.test.js | 23 +- frontend/src/lib/tasks/boardLayout.ts | 15 +- 10 files changed, 518 insertions(+), 166 deletions(-) create mode 100644 frontend/src/hooks/useMiddleMousePan.ts 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 (
-
+
+ {taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? ( +
+ {taskAddMenuItems} +
+ ) : null} +
+ ) : undefined} > {viewTask ? (
@@ -7550,123 +7764,22 @@ function TasksPageContent() { - {taskAddMenuOpen ? ( + {taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
- - - - - - - - - + {taskAddMenuItems}
) : null}
@@ -7943,6 +8056,50 @@ function TasksPageContent() {
) : null} + {taskAddPanel === "priority" ? ( +
+
+ + + Приоритет + + +
+
+ {priorityOptions.map((option) => { + const selected = option.value === viewTask.priority; + return ( + + ); + })} +
+
+ ) : 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}
@@ -188,7 +209,7 @@ export function PayrollDailyWorkSection() { {selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
-
+