From 5a877880ae6945921a6d44dcf9aa15007fbea667 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 12:34:20 +0300 Subject: [PATCH] Improve desktop layouts and loading states --- frontend/src/app/procurement/page.tsx | 5 +- frontend/src/app/requests/page.tsx | 6 +- frontend/src/app/tasks/page.tsx | 35 +++++- .../requests/RequestListControls.tsx | 10 +- .../components/tasks/TaskBoardNavigator.tsx | 35 +++--- .../components/ui/AutoRevealStickyHeader.tsx | 8 +- .../src/components/ui/CompactSortMenu.tsx | 13 +++ .../components/ui/HorizontalChipScroller.tsx | 109 +++++++++++++++++- frontend/src/hooks/useRequestsPage.ts | 31 +++-- 9 files changed, 219 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index 8d9c8bae..e5f8b292 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -930,7 +930,10 @@ export default function ProcurementPage() { ) : (
{/* ── header ── */} - +

Закупки

diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 03e24618..bb7a7d42 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -98,6 +98,7 @@ function RequestsPageContent() { pendingDecisionCount: h.pendingDecisionCount, periodFromFilter: h.periodFromFilter, periodToFilter: h.periodToFilter, + refreshing: h.refreshing, search: h.search, scopeCounts: h.scopeCounts, statusFilter: h.statusFilter, @@ -136,7 +137,10 @@ function RequestsPageContent() { /> ) : ( <> - + -
+
- {board ? ( + {board && (requestedBoardId <= 0 || board.id === requestedBoardId) ? ( <> - +
@@ -8047,12 +8050,32 @@ function TasksPageContent() { ) : null} + ) : requestedBoardId > 0 && !error ? ( +
+
+ +

Загрузка доски...

+
+
) : (
- -

Выберите доску

-

Доска откроется справа от списка

+ {error && requestedBoardId > 0 ? ( + <> +

Не удалось загрузить доску

+

{error}

+ + ) : ( + <> + +

Выберите доску

+

Доска откроется справа от списка

+ + )}
)} diff --git a/frontend/src/components/requests/RequestListControls.tsx b/frontend/src/components/requests/RequestListControls.tsx index 1daa3f42..275fda04 100644 --- a/frontend/src/components/requests/RequestListControls.tsx +++ b/frontend/src/components/requests/RequestListControls.tsx @@ -11,7 +11,7 @@ import { HorizontalChipScroller } from "@/components/ui/HorizontalChipScroller"; import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction"; import { displayUserName } from "@/lib/shared"; import type { User } from "@/types/api"; -import { Filter, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react"; +import { Filter, Loader2, Maximize2, Minimize2, Plus, Search, Zap } from "lucide-react"; type RequestListControlsState = { activeFiltersCount: number; @@ -25,6 +25,7 @@ type RequestListControlsState = { pendingDecisionCount: number; periodFromFilter: string; periodToFilter: string; + refreshing: boolean; search: string; scopeCounts: RequestScopeCounts; statusFilter: string; @@ -81,6 +82,13 @@ export function RequestListControls({

Заявления

+ {state.refreshing ? ( + + ) : null} {state.pendingDecisionCount > 0 ? ( {hiddenGroups.length > 0 ? ( +
) : null} -
+
{visibleBoards.length > 0 ? (
{visibleBoards.map((board) => ( diff --git a/frontend/src/components/ui/AutoRevealStickyHeader.tsx b/frontend/src/components/ui/AutoRevealStickyHeader.tsx index 6c776cb4..9deb7799 100644 --- a/frontend/src/components/ui/AutoRevealStickyHeader.tsx +++ b/frontend/src/components/ui/AutoRevealStickyHeader.tsx @@ -7,12 +7,14 @@ import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext"; type AutoRevealStickyHeaderProps = { children: ReactNode; className?: string; + disableOnDesktop?: boolean; surface?: "solid" | "transparent"; }; export function AutoRevealStickyHeader({ children, className = "", + disableOnDesktop = false, surface = "solid", }: AutoRevealStickyHeaderProps) { const { mobileNavPlacement } = useMobileNavPlacement(); @@ -76,7 +78,11 @@ export function AutoRevealStickyHeader({
setVisible(true)} - className={`sticky z-30 transition-[transform,box-shadow] duration-200 ease-out will-change-transform lg:static lg:translate-y-0 lg:bg-transparent lg:shadow-none ${ + className={`sticky z-30 transition-[transform,box-shadow] duration-200 ease-out will-change-transform ${ + disableOnDesktop + ? "lg:static lg:translate-y-0 lg:bg-transparent lg:shadow-none" + : "lg:top-14" + } ${ surface === "transparent" ? "bg-transparent" : "bg-[var(--surface-primary-solid)]" } ${ mobileNavPlacement === "top" ? "top-10" : "top-0" diff --git a/frontend/src/components/ui/CompactSortMenu.tsx b/frontend/src/components/ui/CompactSortMenu.tsx index d037be59..d019ca6b 100644 --- a/frontend/src/components/ui/CompactSortMenu.tsx +++ b/frontend/src/components/ui/CompactSortMenu.tsx @@ -9,6 +9,7 @@ import { Barcode, CalendarArrowDown, CalendarArrowUp, + CalendarRange, Check, Clock3, LogIn, @@ -35,8 +36,12 @@ export type SortIconType = | "identifier" | "updated" | "created" + | "created-asc" + | "created-desc" | "date-asc" | "date-desc" + | "period-asc" + | "period-desc" | "access-start-asc" | "access-start-desc" | "access-end-asc" @@ -60,8 +65,12 @@ const SORT_ICONS: Record = { identifier: Barcode, updated: Clock3, created: PackagePlus, + "created-asc": PackagePlus, + "created-desc": PackagePlus, "date-asc": CalendarArrowUp, "date-desc": CalendarArrowDown, + "period-asc": CalendarRange, + "period-desc": CalendarRange, "access-start-asc": LogIn, "access-start-desc": LogIn, "access-end-asc": LogOut, @@ -75,6 +84,10 @@ const SORT_ICONS: Record = { }; const SORT_DIRECTIONS: Partial> = { + "created-asc": ArrowUp, + "created-desc": ArrowDown, + "period-asc": ArrowUp, + "period-desc": ArrowDown, "access-start-asc": ArrowUp, "access-start-desc": ArrowDown, "access-end-asc": ArrowUp, diff --git a/frontend/src/components/ui/HorizontalChipScroller.tsx b/frontend/src/components/ui/HorizontalChipScroller.tsx index 4a219a51..641435b2 100644 --- a/frontend/src/components/ui/HorizontalChipScroller.tsx +++ b/frontend/src/components/ui/HorizontalChipScroller.tsx @@ -2,6 +2,8 @@ import { type CSSProperties, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, @@ -9,6 +11,13 @@ import { useState, } from "react"; +type MouseDragState = { + pointerId: number; + startScrollLeft: number; + startX: number; + dragged: boolean; +}; + type HorizontalChipScrollerProps = { children: ReactNode; className?: string; @@ -25,6 +34,9 @@ export function HorizontalChipScroller({ fadeColor = "var(--surface-primary)", }: HorizontalChipScrollerProps) { const containerRef = useRef(null); + const mouseDragRef = useRef(null); + const suppressClickRef = useRef(false); + const [isMouseDragging, setIsMouseDragging] = useState(false); const [scrollHints, setScrollHints] = useState({ left: false, right: false, @@ -59,6 +71,92 @@ export function HorizontalChipScroller({ }; }, [children, updateScrollHints]); + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleWheel = (event: WheelEvent) => { + if (container.scrollWidth <= container.clientWidth + 1) return; + + const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (rawDelta === 0) return; + + const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? container.clientWidth + : 1; + const delta = rawDelta * scale; + const canScrollLeft = container.scrollLeft > 1; + const canScrollRight = + container.scrollLeft + container.clientWidth < container.scrollWidth - 1; + + if ((delta < 0 && !canScrollLeft) || (delta > 0 && !canScrollRight)) { + return; + } + + event.preventDefault(); + container.scrollLeft += delta; + }; + + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, []); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.pointerType !== "mouse" || event.button !== 0) return; + + mouseDragRef.current = { + pointerId: event.pointerId, + startScrollLeft: event.currentTarget.scrollLeft, + startX: event.clientX, + dragged: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const handlePointerMove = useCallback((event: ReactPointerEvent) => { + const drag = mouseDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const distance = event.clientX - drag.startX; + if (!drag.dragged && Math.abs(distance) < 5) return; + + if (!drag.dragged) { + drag.dragged = true; + suppressClickRef.current = true; + setIsMouseDragging(true); + } + + event.preventDefault(); + event.currentTarget.scrollLeft = drag.startScrollLeft - distance; + }, []); + + const finishMouseDrag = useCallback((event: ReactPointerEvent) => { + const drag = mouseDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + mouseDragRef.current = null; + setIsMouseDragging(false); + + if (drag.dragged) { + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + } + }, []); + + const handleClickCapture = useCallback((event: ReactMouseEvent) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + }, []); + const fadeStyle = { "--chip-scroll-fade-color": fadeColor, } as CSSProperties; @@ -72,8 +170,17 @@ export function HorizontalChipScroller({
*]:shrink-0 [&::-webkit-scrollbar]:hidden ${contentClassName}`} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={finishMouseDrag} + onPointerCancel={finishMouseDrag} + onClickCapture={handleClickCapture} + onDragStart={(event) => event.preventDefault()} + className={`${ + isMouseDragging ? "cursor-grabbing select-none" : "cursor-grab" + } flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1 touch-pan-x [scrollbar-width:none] [&>*]:shrink-0 [&::-webkit-scrollbar]:hidden ${contentClassName}`} aria-label={ariaLabel} + data-mouse-dragging={isMouseDragging ? "true" : "false"} > {children}
diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index fb08d528..5019bffc 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -62,11 +62,11 @@ export const requestTypeLabels: Record = { }; export const orderingOptions = [ - { value: "-created_at", label: "Сначала новые", icon: "date-desc" }, - { value: "created_at", label: "Сначала старые", icon: "date-asc" }, + { value: "-created_at", label: "Сначала новые", icon: "created-desc" }, + { value: "created_at", label: "Сначала старые", icon: "created-asc" }, { value: "title", label: "По названию", icon: "alphabetical" }, - { value: "date_from", label: "По периоду ↑", icon: "date-asc" }, - { value: "-date_from", label: "По периоду ↓", icon: "date-desc" }, + { value: "date_from", label: "По периоду ↑", icon: "period-asc" }, + { value: "-date_from", label: "По периоду ↓", icon: "period-desc" }, ] as const; export type RequestQuickScope = "" | "mine" | "addressed" | "decision"; @@ -190,12 +190,14 @@ export function useRequestsPage(_userId: number | null | undefined) { const [attachmentPreview, setAttachmentPreview] = useState(null); const [detailsRequest, setDetailsRequest] = useState(null); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [actionError, setActionError] = useState(null); const [actionSuccess, setActionSuccess] = useState(null); const [busyKey, setBusyKey] = useState(null); const [nextPage, setNextPage] = useState(null); + const requestsLoadedRef = useRef(false); const departmentNameMap = useMemo( () => new Map((departments || []).map((d) => [d.id, d.name])), @@ -238,10 +240,16 @@ export function useRequestsPage(_userId: number | null | undefined) { /* ── load data ── */ useEffect(() => { let cancelled = false; + const initialLoad = !requestsLoadedRef.current; (async () => { try { - setLoading(true); - setError(null); + if (initialLoad) { + setLoading(true); + setError(null); + } else { + setRefreshing(true); + setActionError(null); + } const response = await apiClient.getRequests(buildRequestParams(1)); if (cancelled) return; setRequests(response.results || []); @@ -250,10 +258,15 @@ export function useRequestsPage(_userId: number | null | undefined) { } catch (err) { if (!cancelled) { console.error("Ошибка загрузки заявлений:", err); - setError("Не удалось загрузить заявления"); + if (initialLoad) setError("Не удалось загрузить заявления"); + else setActionError("Не удалось обновить заявления"); } } finally { - if (!cancelled) setLoading(false); + if (!cancelled) { + requestsLoadedRef.current = true; + setLoading(false); + setRefreshing(false); + } } })(); return () => { cancelled = true; }; @@ -650,7 +663,7 @@ export function useRequestsPage(_userId: number | null | undefined) { /* UI */ attachmentPreview, setAttachmentPreview, detailsRequest, setDetailsRequest, - loading, loadingMore, error, + loading, refreshing, loadingMore, error, actionError, actionSuccess, busyKey, nextPage, loadMoreRef,