fix task board interactions and finance layout

This commit is contained in:
lizerk 2026-08-07 09:37:06 +03:00
parent b0e2f1803b
commit 7c6c2bb6b8
10 changed files with 518 additions and 166 deletions

View File

@ -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",

View File

@ -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,

View File

@ -127,6 +127,8 @@ export default function FinancesPage() {
<p className="app-card-caption">Финансы</p>
{activeTab === "management" && showPayrollAdmin ? (
<div id="finance-management-actions" className="ml-auto max-w-full" />
) : activeTab === "work" ? (
<div id="finance-work-period-selector" className="ml-auto w-full max-w-full sm:w-auto" />
) : null}
</div>
@ -176,7 +178,7 @@ export default function FinancesPage() {
onDesktopWideModeChange={changeDesktopWideMode}
/>
) : activeTab === "work" ? (
<PayrollDailyWorkSection />
<PayrollDailyWorkSection periodSelectorTargetId="finance-work-period-selector" />
) : (
<>
{loading ? (

View File

@ -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 {

View File

@ -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({
<TaskCoverPreview
taskId={task.id}
cover={task.cover}
className="-mx-3 -mt-3 mb-3 aspect-[16/7] rounded-t-[11px] border-b border-[var(--border-subtle)]"
className="-mx-3 -mt-3 mb-3 aspect-[16/7] max-h-32 rounded-t-[11px] border-b border-[var(--border-subtle)]"
/>
) : null}
<div className="mb-2 flex items-start gap-2">
@ -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}`}
/>
<div className="mt-2 flex items-center justify-end gap-1">
<button
type="button"
onClick={onCancel}
disabled={saving}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-50"
title="Отмена"
aria-label="Отменить создание задачи"
>
<X size={14} />
</button>
<button
type="submit"
disabled={saving || !title.trim()}
className="app-action-primary flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-50"
title="Создать задачу"
aria-label="Создать задачу"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={15} />}
</button>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1" role="group" aria-label="Приоритет новой задачи">
{priorityOptions.map((option) => {
const selected = option.value === priority;
return (
<button
key={option.value}
type="button"
onClick={() => onPriorityChange(option.value)}
disabled={saving}
className={`flex h-6 w-6 items-center justify-center rounded-full transition disabled:opacity-50 ${
selected
? "bg-[var(--surface-tertiary)] ring-2 ring-[var(--accent-primary)]"
: "hover:bg-[var(--surface-tertiary)]"
}`}
title={`Приоритет: ${option.label}`}
aria-label={`Приоритет: ${option.label}`}
aria-pressed={selected}
>
<span className={`h-3 w-3 rounded-full ${option.dotClassName}`} aria-hidden="true" />
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onCancel}
disabled={saving}
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-50"
title="Отмена"
aria-label="Отменить создание задачи"
>
<X size={14} />
</button>
<button
type="submit"
disabled={saving || !title.trim()}
className="app-action-primary flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-50"
title="Создать задачу"
aria-label="Создать задачу"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={15} />}
</button>
</div>
</div>
</form>
);
@ -3286,6 +3321,7 @@ function TasksPageContent() {
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [quickTaskTarget, setQuickTaskTarget] = useState<QuickTaskTarget | null>(null);
const [quickTaskTitle, setQuickTaskTitle] = useState("");
const [quickTaskPriority, setQuickTaskPriority] = useState<TaskPriority>("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<number[]>([]);
const [taskMenuOpen, setTaskMenuOpen] = useState(false);
const [taskAddMenuOpen, setTaskAddMenuOpen] = useState(false);
const [taskAddMenuPlacement, setTaskAddMenuPlacement] = useState<"top" | "bottom">("top");
const [taskAddPanel, setTaskAddPanel] = useState<TaskAddPanel>(null);
const [viewTaskDueDateDraft, setViewTaskDueDateDraft] = useState("");
const [viewColumnMenuOpen, setViewColumnMenuOpen] = useState(false);
@ -3390,6 +3427,7 @@ function TasksPageContent() {
const boardActionsMenuRef = useRef<HTMLDivElement | null>(null);
const taskMenuRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const columnMenuRef = useRef<HTMLDivElement | null>(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 ? (
<QuickTaskComposer
title={quickTaskTitle}
priority={quickTaskPriority}
saving={quickTaskSaving}
columnName={column.name}
onTitleChange={setQuickTaskTitle}
onPriorityChange={setQuickTaskPriority}
onSubmit={() => 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 ? (
<>
<button
type="button"
onClick={() => {
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)]"
>
<CalendarDays size={15} className="app-text-muted" />
Срок
</button>
<button
type="button"
onClick={() => {
setTaskAddPanel("priority");
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)]"
>
<Flag size={15} className="app-text-muted" />
Приоритет
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Tag size={15} className="app-text-muted" />
Метки
</button>
<button
type="button"
onClick={() => 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)]"
>
<ImagePlus size={15} className="app-text-muted" />
Обложка
</button>
<button
type="button"
onClick={() => {
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)]"
>
<ListChecks size={15} className="app-text-muted" />
Чек-лист
</button>
<button
type="button"
onClick={() => {
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)]"
>
<MessageSquare size={15} className="app-text-muted" />
Комментарий
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Link2 size={15} className="app-text-muted" />
Объект или ссылка
</button>
<button
type="button"
onClick={() => {
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"
>
<Paperclip size={15} className="app-text-muted" />
Загрузить файл
</button>
<button
type="button"
onClick={() => {
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)]"
>
<UserPlus size={15} className="app-text-muted" />
Участники
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Workflow size={15} className="app-text-muted" />
Автоматизация
</button>
</>
) : null;
if (loading) {
return (
<AppShell
@ -6617,7 +6805,7 @@ function TasksPageContent() {
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
<section className="app-surface relative z-30 shrink-0 overflow-visible rounded-2xl p-4">
<section className="app-surface relative z-50 shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
@ -7528,10 +7716,36 @@ function TasksPageContent() {
title="Задача"
size="md"
className={taskAddMenuOpen
? "h-[min(34rem,95vh)] sm:h-[min(34rem,90vh)]"
? "min-h-[min(34rem,95vh)] sm:min-h-[min(34rem,90vh)]"
: ""
}
closeOnClickOutside
footer={viewTask ? (
<div ref={taskAddMenuBottomRef} className="relative flex justify-end">
<button
type="button"
onClick={() => {
const bottomMenuOpen = taskAddMenuOpen && taskAddMenuPlacement === "bottom";
setTaskAddMenuPlacement("bottom");
setTaskAddMenuOpen(!bottomMenuOpen);
setTaskAddPanel(null);
}}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold"
title="Добавить к задаче"
aria-label="Добавить +"
aria-expanded={taskAddMenuOpen && taskAddMenuPlacement === "bottom"}
aria-haspopup="menu"
>
<span>Добавить</span>
<Plus size={15} />
</button>
{taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? (
<div className="app-menu absolute bottom-full right-0 z-40 mb-2 w-60 rounded-xl p-1.5">
{taskAddMenuItems}
</div>
) : null}
</div>
) : undefined}
>
{viewTask ? (
<div className="space-y-4">
@ -7550,123 +7764,22 @@ function TasksPageContent() {
<button
type="button"
onClick={() => {
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"
>
<Plus size={14} />
</button>
{taskAddMenuOpen ? (
{taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
<div className="app-menu absolute left-0 top-full z-40 mt-2 w-60 rounded-xl p-1.5">
<button
type="button"
onClick={() => {
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)]"
>
<CalendarDays size={15} className="app-text-muted" />
Срок
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Tag size={15} className="app-text-muted" />
Метки
</button>
<button
type="button"
onClick={() => 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)]"
>
<ImagePlus size={15} className="app-text-muted" />
Обложка
</button>
<button
type="button"
onClick={() => {
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)]"
>
<ListChecks size={15} className="app-text-muted" />
Чек-лист
</button>
<button
type="button"
onClick={() => {
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)]"
>
<MessageSquare size={15} className="app-text-muted" />
Комментарий
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Link2 size={15} className="app-text-muted" />
Объект или ссылка
</button>
<button
type="button"
onClick={() => {
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"
>
<Paperclip size={15} className="app-text-muted" />
Загрузить файл
</button>
<button
type="button"
onClick={() => {
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)]"
>
<UserPlus size={15} className="app-text-muted" />
Участники
</button>
<button
type="button"
onClick={() => {
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)]"
>
<Workflow size={15} className="app-text-muted" />
Автоматизация
</button>
{taskAddMenuItems}
</div>
) : null}
</div>
@ -7943,6 +8056,50 @@ function TasksPageContent() {
</div>
) : null}
{taskAddPanel === "priority" ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
<Flag size={15} className="app-text-muted" />
Приоритет
</span>
<button
type="button"
onClick={() => setTaskAddPanel(null)}
className="app-icon-button flex h-7 w-7 items-center justify-center rounded-lg"
title="Закрыть"
aria-label="Закрыть выбор приоритета"
>
<X size={14} />
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{priorityOptions.map((option) => {
const selected = option.value === viewTask.priority;
return (
<button
key={option.value}
type="button"
onClick={() => 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}
>
<span className={`app-status-pill min-h-0 px-2.5 py-1 text-xs ${option.className}`}>
{option.label}
</span>
{selected ? (
<Check size={14} className="app-accent-text shrink-0" />
) : null}
</button>
);
})}
</div>
</div>
) : null}
{taskAddPanel === "labels" ? (
<div className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<div className="mb-3 flex items-center justify-between gap-3">
@ -8789,6 +8946,7 @@ function TasksPageContent() {
</div>
) : null}
</div>
</div>
) : null}
</Modal>

View File

@ -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<PayrollOwnDailyWorkWorkspace | null>(null);
const [selectedPeriodId, setSelectedPeriodId] = useState<number | null>(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 = (
<select
className="app-select w-full min-w-0 rounded-lg px-3 py-2.5 text-sm sm:w-auto sm:min-w-56"
value={selectedPeriodId || ""}
onChange={(event) => void loadWorkspace(Number(event.target.value))}
aria-label="Расчётный период выработки"
>
{workspace.periods.map((period) => (
<option key={period.id} value={period.id}>{period.name || period.code}</option>
))}
</select>
);
const periodSelectorTarget = periodSelectorTargetId && typeof document !== "undefined"
? document.getElementById(periodSelectorTargetId)
: null;
return (
<div className="space-y-4">
{periodSelectorTarget ? createPortal(periodSelector, periodSelectorTarget) : null}
<section className="app-surface rounded-2xl p-4 sm:p-5">
<form onSubmit={submit}>
<div className="flex items-center gap-2">
@ -188,7 +209,7 @@ export function PayrollDailyWorkSection() {
{selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
</h2>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div className="mt-4 grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-4">
<label>
<span className="app-field-label">Дата</span>
<input
@ -259,29 +280,17 @@ export function PayrollDailyWorkSection() {
</section>
<section className="app-surface rounded-2xl p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="app-card-caption">Выработка</p>
<h2 className="mt-1 text-lg font-semibold text-[var(--foreground)]">Ежедневные баллы</h2>
</div>
<select
className="app-select min-w-0 rounded-lg px-3 py-2.5 text-sm sm:min-w-56"
value={selectedPeriodId || ""}
onChange={(event) => void loadWorkspace(Number(event.target.value))}
aria-label="Расчётный период выработки"
>
{workspace.periods.map((period) => (
<option key={period.id} value={period.id}>{period.name || period.code}</option>
))}
</select>
<div className="min-w-0">
<p className="app-card-caption">Выработка</p>
<h2 className="mt-1 text-lg font-semibold text-[var(--foreground)]">Ежедневные баллы</h2>
</div>
{selectedPeriod ? (
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="mt-4 grid grid-cols-[repeat(auto-fit,minmax(min(100%,10rem),1fr))] gap-3">
<div className="app-surface-muted rounded-xl p-3">
<p className="app-text-muted text-xs">Период</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground)]">{getPayrollPeriodRange(selectedPeriod)}</p>
{periodStatus ? <span className={`app-status-pill mt-2 ${periodStatus.className}`}>{periodStatus.label}</span> : null}
<p className="mt-1 text-sm font-medium leading-5 text-[var(--foreground)]">{getPayrollPeriodRange(selectedPeriod)}</p>
{periodStatus ? <span className={`app-status-pill mt-2 shrink-0 whitespace-nowrap ${periodStatus.className}`}>{periodStatus.label}</span> : null}
</div>
<div className="app-surface-muted rounded-xl p-3">
<p className="app-text-muted text-xs">Норма за период</p>
@ -291,14 +300,14 @@ export function PayrollDailyWorkSection() {
</p>
</div>
<div className="app-surface-muted rounded-xl p-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 grow basis-24">
<p className="app-text-muted text-xs">Фактически за период</p>
<p className="mt-1 text-lg font-semibold text-[var(--foreground)]">
{formatPoints(summary?.actual_points)} / {formatPoints(summary?.target_points)}
</p>
</div>
{aggregateStatus ? <span className={`app-status-pill ${aggregateStatus.className}`}>{aggregateStatus.label}</span> : null}
{aggregateStatus ? <span className={`app-status-pill shrink-0 whitespace-nowrap ${aggregateStatus.className}`}>{aggregateStatus.label}</span> : null}
</div>
</div>
</div>

View File

@ -25,6 +25,7 @@ import {
PayrollCellCommentsModal,
type PayrollCommentCell,
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollPeriodTable,
PayrollPeriodTableComponent,
@ -830,6 +831,12 @@ export function PayrollPeriodTableView({
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const rowsMenuRef = useRef<HTMLDivElement>(null);
const {
finishPan: finishTablePan,
movePan: moveTablePan,
scrollRef: tableScrollRef,
startPan: startTablePan,
} = useMiddleMousePan<HTMLDivElement>();
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
const [hiddenRows, setHiddenRows] = useHiddenPayrollRows(storageUserId);
const [storedColumnOrder, setStoredColumnOrder] = usePayrollColumnOrder(storageUserId);
@ -1358,7 +1365,17 @@ export function PayrollPeriodTableView({
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
<div className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]">
<div
ref={tableScrollRef}
className="app-table-scroll max-h-[min(70vh,760px)] w-full max-w-full overflow-auto overscroll-contain rounded-xl border border-[var(--border-subtle)]"
onPointerDown={startTablePan}
onPointerMove={moveTablePan}
onPointerUp={finishTablePan}
onPointerCancel={finishTablePan}
onAuxClick={(event) => {
if (event.button === 1) event.preventDefault();
}}
>
<table className="table-fixed border-separate border-spacing-0" style={{ width: `max(100%, ${tableWidth}px)` }} aria-label="Итоговая таблица расчётного периода">
<colgroup>
<col style={{ width: 150 }} />

View File

@ -0,0 +1,105 @@
"use client";
import {
useCallback,
useEffect,
useRef,
type PointerEvent as ReactPointerEvent,
} from "react";
type MiddleMousePanState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
scrollLeft: number;
scrollTop: number;
frameId: number | null;
};
export function useMiddleMousePan<T extends HTMLElement>() {
const scrollRef = useRef<T | null>(null);
const panRef = useRef<MiddleMousePanState | null>(null);
const applyPan = useCallback(() => {
const pan = panRef.current;
const container = scrollRef.current;
if (!pan || !container) return;
pan.frameId = null;
container.scrollLeft = pan.scrollLeft - (pan.currentX - pan.startX);
container.scrollTop = pan.scrollTop - (pan.currentY - pan.startY);
}, []);
const startPan = useCallback((event: ReactPointerEvent<T>) => {
if (event.button !== 1) return;
const container = scrollRef.current;
if (!container) return;
event.preventDefault();
event.stopPropagation();
panRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
frameId: null,
};
container.setPointerCapture(event.pointerId);
container.classList.add("cursor-grabbing", "select-none");
}, []);
const movePan = useCallback((event: ReactPointerEvent<T>) => {
const pan = panRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
pan.currentX = event.clientX;
pan.currentY = event.clientY;
if (pan.frameId === null) {
pan.frameId = window.requestAnimationFrame(applyPan);
}
}, [applyPan]);
const finishPan = useCallback((event: ReactPointerEvent<T>) => {
const pan = panRef.current;
const container = scrollRef.current;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
pan.currentX = event.clientX;
pan.currentY = event.clientY;
if (pan.frameId !== null) {
window.cancelAnimationFrame(pan.frameId);
pan.frameId = null;
}
applyPan();
panRef.current = null;
container?.classList.remove("cursor-grabbing", "select-none");
if (container?.hasPointerCapture(event.pointerId)) {
container.releasePointerCapture(event.pointerId);
}
}, [applyPan]);
useEffect(() => () => {
const pan = panRef.current;
if (pan?.frameId !== null && pan?.frameId !== undefined) {
window.cancelAnimationFrame(pan.frameId);
}
scrollRef.current?.classList.remove("cursor-grabbing", "select-none");
panRef.current = null;
}, []);
return {
finishPan,
movePan,
scrollRef,
startPan,
};
}

View File

@ -27,13 +27,13 @@ const row = (id, columnId, position) => ({
updated_at: "",
});
const task = (id, columnId, rowId, position) => ({
const task = (id, columnId, rowId, position, priority = "medium") => ({
id,
board: 1,
column: columnId,
row: rowId,
title: `task-${id}`,
priority: "medium",
priority,
position,
created_at: "",
updated_at: "",
@ -83,6 +83,25 @@ test("board layout groups and orders tasks by subcolumn and lane", () => {
);
});
test("board layout orders tasks by priority and then by time in the column", () => {
const layout = buildTaskBoardLayout(
[column(1, 1000)],
[],
[
task(1, 1, null, 1000, "medium"),
task(2, 1, null, 4000, "critical"),
task(3, 1, null, 2000, "high"),
task(4, 1, null, 3000, "critical"),
task(5, 1, null, 500, "low"),
],
);
assert.deepEqual(
layout.tasksByPlacement.get(taskPlacementKey(1, null))?.map(({ id }) => id),
[4, 2, 3, 1, 5],
);
});
test("top-level column count includes direct tasks and all subcolumn tasks", () => {
const layout = buildTaskBoardLayout(
[column(1, 1000), column(11, 1000, 1), column(12, 2000, 1)],

View File

@ -17,6 +17,19 @@ function byPositionThenId<T extends { id: number; position: number }>(
return left.position - right.position || left.id - right.id;
}
const taskPriorityOrder: Record<TaskCard["priority"], number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};
function byPriorityThenPositionThenId(left: TaskCard, right: TaskCard): number {
const leftPriority = taskPriorityOrder[left.priority] ?? taskPriorityOrder.medium;
const rightPriority = taskPriorityOrder[right.priority] ?? taskPriorityOrder.medium;
return leftPriority - rightPriority || byPositionThenId(left, right);
}
export function taskPlacementKey(columnId: number, rowId: number | null): string {
return `${columnId}:${rowId ?? "base"}`;
}
@ -69,7 +82,7 @@ export function buildTaskBoardLayout(
columnCounts.set(task.column, (columnCounts.get(task.column) || 0) + 1);
}
for (const placementTasks of tasksByPlacement.values()) {
placementTasks.sort(byPositionThenId);
placementTasks.sort(byPriorityThenPositionThenId);
}
for (const column of topLevelColumns) {