Improve desktop layouts and loading states

This commit is contained in:
lizerk 2026-08-19 12:34:20 +03:00
parent 205fd7252d
commit 5a877880ae
9 changed files with 219 additions and 33 deletions

View File

@ -930,7 +930,10 @@ export default function ProcurementPage() {
) : (
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:flex lg:flex-col lg:overflow-hidden" : ""}`}>
{/* ── header ── */}
<AutoRevealStickyHeader className="rounded-b-xl">
<AutoRevealStickyHeader
className="rounded-b-xl"
disableOnDesktop={desktopWideMode}
>
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="shrink-0">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>

View File

@ -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() {
/>
) : (
<>
<AutoRevealStickyHeader className="rounded-b-xl">
<AutoRevealStickyHeader
className="rounded-b-xl"
disableOnDesktop={desktopWideMode}
>
<RequestListControls
actions={requestControlsActions}
desktopWideMode={desktopWideMode}

View File

@ -7448,7 +7448,7 @@ function TasksPageContent() {
desktopWideMode={desktopWideMode}
onDesktopWideModeChange={changeDesktopWideMode}
>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "mx-auto w-full max-w-[1600px] xl:flex xl:h-[calc(100dvh-5.5rem)] xl:min-h-0 xl:flex-col"}`}>
<div className={`${desktopWideMode ? "w-full lg:flex lg:min-h-0 lg:flex-1 lg:flex-col" : "mx-auto w-full max-w-[1600px]"}`}>
<div className={`min-h-0 gap-4 ${desktopWideMode ? "lg:flex lg:flex-1" : "xl:flex xl:flex-1"}`}>
<TaskBoardNavigator
boards={boards}
@ -7474,9 +7474,12 @@ 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" : ""}`}>
{board ? (
{board && (requestedBoardId <= 0 || board.id === requestedBoardId) ? (
<>
<AutoRevealStickyHeader className="rounded-b-2xl">
<AutoRevealStickyHeader
className="rounded-b-2xl"
disableOnDesktop={desktopWideMode}
>
<section className="app-surface relative z-[35] 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">
@ -8047,12 +8050,32 @@ function TasksPageContent() {
</TaskCardMenuPopover>
) : null}
</>
) : requestedBoardId > 0 && !error ? (
<section
className="app-surface-muted flex h-full min-h-32 flex-1 items-center justify-center rounded-xl border border-[var(--border-subtle)]"
aria-live="polite"
aria-busy="true"
>
<div className="max-w-xs text-center">
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
<p className="text-sm font-semibold text-[var(--foreground)]">Загрузка доски...</p>
</div>
</section>
) : (
<section className="app-surface-muted flex h-full min-h-0 flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)]">
<div className="max-w-xs text-center">
<Kanban size={28} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-semibold text-[var(--foreground)]">Выберите доску</p>
<p className="app-text-muted mt-1 text-xs">Доска откроется справа от списка</p>
{error && requestedBoardId > 0 ? (
<>
<p className="text-sm font-semibold text-[var(--danger-foreground)]">Не удалось загрузить доску</p>
<p className="app-text-muted mt-1 text-xs">{error}</p>
</>
) : (
<>
<Kanban size={28} className="app-text-muted mx-auto mb-3" />
<p className="text-sm font-semibold text-[var(--foreground)]">Выберите доску</p>
<p className="app-text-muted mt-1 text-xs">Доска откроется справа от списка</p>
</>
)}
</div>
</section>
)}

View File

@ -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({
<div className="mb-4 flex min-w-0 flex-nowrap items-center justify-between gap-2">
<div className="flex shrink-0 items-center gap-3">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Заявления</p>
{state.refreshing ? (
<Loader2
size={14}
className="app-text-muted animate-spin"
aria-label="Обновление списка заявлений"
/>
) : null}
{state.pendingDecisionCount > 0 ? (
<button
type="button"

View File

@ -505,13 +505,18 @@ export default function TaskBoardNavigator({
<>
<aside
ref={menusRef}
className={`flex min-h-0 flex-col overflow-visible transition-[width] duration-200 lg:overflow-hidden ${
className={`flex min-h-0 flex-col overflow-visible transition-[width] duration-200 ${
wideMode ? "lg:overflow-hidden" : ""
} ${
compactNavigator ? "bg-transparent" : "app-surface rounded-2xl"
} ${
wideMode ? (compactNavigator ? "lg:w-[4.75rem]" : "lg:w-[24rem]") : ""
} ${className}`}
>
<AutoRevealStickyHeader className={compactNavigator ? "" : "rounded-2xl"}>
<AutoRevealStickyHeader
className={compactNavigator ? "" : "rounded-2xl"}
disableOnDesktop={wideMode}
>
<div
className={`flex min-w-0 items-center gap-2 pb-0 ${
compactNavigator ? "justify-center p-3" : "justify-between p-4"
@ -637,7 +642,8 @@ export default function TaskBoardNavigator({
</div>
</CompactFilterPanel>
<HorizontalChipScroller className="mb-4" ariaLabel="Группы досок">
<div className="mb-4 flex min-w-0 items-start gap-2">
<HorizontalChipScroller className="min-w-0 flex-1" ariaLabel="Группы досок">
{[
{ key: "all" as const, label: "Все", stats: groupStats.all },
{ key: "ungrouped" as const, label: "Без группы", stats: groupStats.ungrouped },
@ -715,15 +721,6 @@ export default function TaskBoardNavigator({
</div>
);
})}
<button
type="button"
onClick={onCreateGroup}
className="app-pill inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition"
title="Создать группу"
aria-label="Создать группу досок"
>
<Plus size={14} />
</button>
{hiddenGroups.length > 0 ? (
<button
type="button"
@ -739,11 +736,23 @@ export default function TaskBoardNavigator({
</button>
) : null}
</HorizontalChipScroller>
<button
type="button"
onClick={onCreateGroup}
className="app-pill inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition"
title="Создать группу"
aria-label="Создать группу досок"
>
<Plus size={14} />
</button>
</div>
</div>
) : null}
</AutoRevealStickyHeader>
<div className={`tasks-column-scroll pb-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
<div className={`tasks-column-scroll pb-4 ${
wideMode ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain" : ""
} ${compactNavigator ? "px-2 pt-2" : "px-4"}`}>
{visibleBoards.length > 0 ? (
<div className="space-y-1">
{visibleBoards.map((board) => (

View File

@ -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({
<div
data-auto-reveal-header="true"
onFocusCapture={() => 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"

View File

@ -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<SortIconType, LucideIcon> = {
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<SortIconType, LucideIcon> = {
};
const SORT_DIRECTIONS: Partial<Record<SortIconType, LucideIcon>> = {
"created-asc": ArrowUp,
"created-desc": ArrowDown,
"period-asc": ArrowUp,
"period-desc": ArrowDown,
"access-start-asc": ArrowUp,
"access-start-desc": ArrowDown,
"access-end-asc": ArrowUp,

View File

@ -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<HTMLDivElement>(null);
const mouseDragRef = useRef<MouseDragState | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
}, []);
const fadeStyle = {
"--chip-scroll-fade-color": fadeColor,
} as CSSProperties;
@ -72,8 +170,17 @@ export function HorizontalChipScroller({
<div
ref={containerRef}
onScroll={updateScrollHints}
className={`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}`}
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}
</div>

View File

@ -62,11 +62,11 @@ export const requestTypeLabels: Record<string, string> = {
};
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<RequestAttachmentPreview | null>(null);
const [detailsRequest, setDetailsRequest] = useState<Request | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(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,