Files
iistwin/client/src/components/Sidebar.tsx

1592 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link, useLocation } from 'wouter';
import { UserAvatar } from '@/components/UserAvatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Users,
User,
Settings,
BarChart3,
CheckSquare,
Bell,
LogOut,
FileText,
FileSignature,
Table2,
Moon,
Sun,
ChevronLeft,
ChevronRight,
FolderOpen,
Folder,
Bookmark,
Layers,
PanelLeftClose,
PanelLeft,
FileCode,
Wallet,
MessageSquare,
Home,
Calendar,
GripVertical,
Edit,
Trash2,
MoreHorizontal,
Pencil,
Download,
Upload,
Check,
Send,
Reply,
X,
WifiOff,
PiggyBank,
LayoutDashboard,
ChartGantt,
HeartPulse,
} from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/use-theme';
import { Switch } from '@/components/ui/switch';
import { useBookmarkFolders, useBookmarksByFolder, useReorderBookmarks, useReorderBookmarkFolders, useUpdateBookmarkFolder, useDeleteBookmarkFolder, useDeleteBookmark } from '@/hooks/useBookmarks';
import { BookmarkFolderDropdown } from './BookmarkFolderDropdown';
import { EditBookmarkModal } from './EditBookmarkModal';
import { AddBookmarkButton } from './AddBookmarkButton';
import { NotificationsDropdown } from './NotificationsDropdown';
import { SystemNotificationsDropdown } from './SystemNotificationsDropdown';
import { CreateBookmarkModal } from './CreateBookmarkModal';
import { CreateFolderModal } from './CreateFolderModal';
import type { BookmarkFolder as BookmarkFolderType, Bookmark as BookmarkType } from '@shared/schema';
import { useState, createContext, useContext, useCallback, useEffect, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useToast } from '@/hooks/use-toast';
import { apiRequest, BadRequestError } from '@/lib/queryClient';
import { useEvents } from '@/hooks/useEvents';
import { useInboxCount } from '@/hooks/useInboxCount';
import { queryClient } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-mobile';
import { MobileBottomNav } from './MobileBottomNav';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
import { isAdmin, hasPermission } from '@/lib/permissions';
import { OfflineSyncBanner } from '@/components/OfflineSyncBanner';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
interface SidebarContextType {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}
const SidebarContext = createContext<SidebarContextType>({
collapsed: false,
setCollapsed: () => {}
});
export const useSidebar = () => useContext(SidebarContext);
interface NavItemProps {
href: string;
icon: React.ReactNode;
label: string;
collapsed: boolean;
testId?: string;
}
function NavItem({ href, icon, label, collapsed, testId }: NavItemProps) {
const [location] = useLocation();
const isActive = location === href || location.startsWith(href + '/');
const content = (
<Link href={href}>
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer",
isActive
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
collapsed && "justify-center px-1.5"
)}
data-testid={testId}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">{icon}</span>
{!collapsed && <span className="truncate">{label}</span>}
</div>
</Link>
);
if (collapsed) {
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
{content}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
{label}
</TooltipContent>
</Tooltip>
);
}
return content;
}
function getAbbr(name: string): string {
const words = name.trim().split(/\s+/);
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
// Shared bookmark URL helpers (mirrors BookmarkItem.tsx logic to avoid regressions)
const SIDEBAR_APP_ROUTE_PREFIXES = ['/forms/', '/tasks/', '/kanban/', '/reports/'];
const SIDEBAR_REPLIT_HOSTNAME_PATTERN = /\.(replit\.dev|repl\.co)$/i;
function sidebarGetBookmarkUrl(bookmark: BookmarkType): string {
switch (bookmark.type) {
case 'form': return `/forms/${bookmark.targetId}/tasks`;
case 'task':
return bookmark.formId
? `/forms/${bookmark.formId}/tasks/${bookmark.targetId}`
: `/tasks/${bookmark.targetId}`;
case 'kanban': return `/kanban/${bookmark.targetId}`;
case 'report': return `/reports/${bookmark.targetId}`;
case 'dashboard': return '/';
case 'custom_url': return bookmark.url || '#';
default: return '/';
}
}
function sidebarIsInternalUrl(url: string): boolean {
if (!url || url === '#') return false;
if (url.startsWith('/')) return true;
try {
const parsed = new URL(url);
if (parsed.hostname === window.location.hostname) return true;
if (SIDEBAR_REPLIT_HOSTNAME_PATTERN.test(parsed.hostname)) {
if (SIDEBAR_APP_ROUTE_PREFIXES.some(p => parsed.pathname.startsWith(p))) return true;
}
} catch { return false; }
return false;
}
function sidebarToInternalPath(url: string): string {
if (url.startsWith('/')) return url;
try {
const parsed = new URL(url);
return parsed.pathname + parsed.search + parsed.hash;
} catch { return url; }
}
// Sortable bookmark row used inside SortableBookmarkList
function SortableBookmarkRow({ bookmark, onEdit, editMode }: { bookmark: BookmarkType; onEdit: (b: BookmarkType) => void; editMode?: boolean }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: bookmark.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
const deleteBookmark = useDeleteBookmark();
const [, navigate] = useLocation();
const url = sidebarGetBookmarkUrl(bookmark);
const internal = sidebarIsInternalUrl(url);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (editMode) return;
if (!url || url === '#') return;
if (internal) navigate(sidebarToInternalPath(url));
else window.open(url, '_blank', 'noopener,noreferrer');
};
const handleDelete = () => {
if (window.confirm(`Удалить закладку "${bookmark.name}"?`)) {
deleteBookmark.mutate(bookmark.id);
}
};
if (!editMode) {
return (
<div className="flex items-center">
<button
onClick={handleClick}
className="flex-1 min-w-0 text-left"
data-testid={`bookmark-${bookmark.id}`}
>
<div className="flex items-center gap-2 px-1 py-1.5 rounded text-xs text-muted-foreground transition-colors cursor-pointer hover:bg-muted/50 hover:text-foreground">
<Bookmark className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{bookmark.name}</span>
</div>
</button>
</div>
);
}
return (
<div ref={setNodeRef} style={style} className="flex items-center">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing w-4 shrink-0 flex items-center justify-center text-muted-foreground/60"
>
<GripVertical className="w-3 h-3" />
</div>
<button
onClick={handleClick}
className="flex-1 min-w-0 text-left"
data-testid={`bookmark-${bookmark.id}`}
>
<div className="flex items-center gap-2 px-1 py-1.5 rounded text-xs text-muted-foreground transition-colors cursor-pointer hover:bg-muted/30">
<Bookmark className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{bookmark.name}</span>
</div>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onEdit(bookmark)}
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Редактировать"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={handleDelete}
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Удалить"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
}
// Sortable list of bookmarks with DnD (used for root and inside folders)
function SortableBookmarkList({
bookmarks: initialBookmarks,
onEdit,
editMode,
}: {
bookmarks: BookmarkType[];
onEdit: (b: BookmarkType) => void;
editMode?: boolean;
}) {
const [items, setItems] = useState<BookmarkType[]>(() =>
[...initialBookmarks].sort((a, b) => a.position - b.position)
);
const reorder = useReorderBookmarks();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
// Sync local state when server data changes (e.g. after add/delete)
const initialKey = [...initialBookmarks].sort((a, b) => a.position - b.position).map(b => b.id).join(',');
const prevKeyRef = useRef(initialKey);
useEffect(() => {
if (prevKeyRef.current !== initialKey) {
prevKeyRef.current = initialKey;
setItems([...initialBookmarks].sort((a, b) => a.position - b.position));
}
}, [initialKey]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems(current => {
const oldIdx = current.findIndex(b => b.id === active.id);
const newIdx = current.findIndex(b => b.id === over.id);
const reordered = arrayMove(current, oldIdx, newIdx);
reorder.mutate(reordered.map((b, idx) => ({ id: b.id, position: idx })));
return reordered;
});
};
if (items.length === 0) return null;
if (!editMode) {
return (
<div>
{items.map(bookmark => (
<SortableBookmarkRow key={bookmark.id} bookmark={bookmark} onEdit={onEdit} editMode={false} />
))}
</div>
);
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map(b => b.id)} strategy={verticalListSortingStrategy}>
{items.map(bookmark => (
<SortableBookmarkRow key={bookmark.id} bookmark={bookmark} onEdit={onEdit} editMode={editMode} />
))}
</SortableContext>
</DndContext>
);
}
// Sortable list of folders with DnD (used for root and inside folders)
function SortableFolderList({
folders: initialFolders,
allFolders,
onEdit,
editMode,
}: {
folders: BookmarkFolderType[];
allFolders: BookmarkFolderType[];
onEdit: (b: BookmarkType) => void;
editMode?: boolean;
}) {
const [items, setItems] = useState<BookmarkFolderType[]>(() =>
[...initialFolders].sort((a, b) => a.position - b.position)
);
const reorder = useReorderBookmarkFolders();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const initialKey = [...initialFolders].sort((a, b) => a.position - b.position).map(f => f.id).join(',');
const prevKeyRef = useRef(initialKey);
useEffect(() => {
if (prevKeyRef.current !== initialKey) {
prevKeyRef.current = initialKey;
setItems([...initialFolders].sort((a, b) => a.position - b.position));
}
}, [initialKey]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems(current => {
const oldIdx = current.findIndex(f => f.id === active.id);
const newIdx = current.findIndex(f => f.id === over.id);
const reordered = arrayMove(current, oldIdx, newIdx);
reorder.mutate(reordered.map((f, idx) => ({ id: f.id, position: idx })));
return reordered;
});
};
if (items.length === 0) return null;
if (!editMode) {
return (
<div>
{items.map(folder => (
<SortableFolderRow key={folder.id} folder={folder} allFolders={allFolders} onEdit={onEdit} editMode={false} />
))}
</div>
);
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map(f => f.id)} strategy={verticalListSortingStrategy}>
{items.map(folder => (
<SortableFolderRow key={folder.id} folder={folder} allFolders={allFolders} onEdit={onEdit} editMode={editMode} />
))}
</SortableContext>
</DndContext>
);
}
// Sortable folder row — used at any nesting level in the sidebar
function SortableFolderRow({
folder,
allFolders,
onEdit,
editMode,
}: {
folder: BookmarkFolderType;
allFolders: BookmarkFolderType[];
onEdit: (b: BookmarkType) => void;
editMode?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: folder.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
const [expanded, setExpanded] = useState(folder.isExpanded ?? false);
const [renamingFolder, setRenamingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState(folder.name);
const [defaultExpanded, setDefaultExpanded] = useState(folder.isExpanded ?? false);
const { data: bookmarksData } = useBookmarksByFolder(folder.id);
const updateFolder = useUpdateBookmarkFolder();
const deleteFolder = useDeleteBookmarkFolder();
const bookmarks = bookmarksData?.bookmarks || [];
const subfolders = allFolders.filter(f => f.parentId === folder.id);
const handleToggle = () => {
if (!editMode) setExpanded(e => !e);
};
const handleDelete = () => {
if (window.confirm(`Удалить папку "${folder.name}" и все её содержимое?`)) {
deleteFolder.mutate(folder.id);
}
};
const openRenameDialog = () => {
setNewFolderName(folder.name);
setDefaultExpanded(folder.isExpanded ?? false);
setRenamingFolder(true);
};
const handleSaveRename = () => {
const nameChanged = newFolderName.trim() && newFolderName.trim() !== folder.name;
const expandedChanged = defaultExpanded !== (folder.isExpanded ?? false);
if (nameChanged || expandedChanged) {
updateFolder.mutate({
id: folder.id,
...(nameChanged ? { name: newFolderName.trim() } : {}),
isExpanded: defaultExpanded,
});
}
setRenamingFolder(false);
};
const folderContent = expanded && (
<div className="ml-4 mt-0.5">
<SortableBookmarkList bookmarks={bookmarks} onEdit={onEdit} editMode={editMode} />
<SortableFolderList folders={subfolders} allFolders={allFolders} onEdit={onEdit} editMode={editMode} />
</div>
);
if (!editMode) {
return (
<div>
<div className="flex items-center">
<button
onClick={handleToggle}
className="flex items-center gap-2 flex-1 min-w-0 px-1 py-1.5 rounded text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground cursor-pointer"
>
{expanded
? <FolderOpen className="w-3.5 h-3.5 shrink-0" />
: <Folder className="w-3.5 h-3.5 shrink-0" />
}
<span className="truncate">{folder.name}</span>
</button>
</div>
{folderContent}
</div>
);
}
return (
<>
<div ref={setNodeRef} style={style}>
<div className="flex items-center">
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing w-4 shrink-0 flex items-center justify-center text-muted-foreground/60"
>
<GripVertical className="w-3 h-3" />
</div>
<button
onClick={handleToggle}
className="flex items-center gap-2 flex-1 min-w-0 px-1 py-1.5 rounded text-xs text-muted-foreground transition-colors cursor-default hover:bg-muted/30"
>
{expanded
? <FolderOpen className="w-3.5 h-3.5 shrink-0" />
: <Folder className="w-3.5 h-3.5 shrink-0" />
}
<span className="truncate">{folder.name}</span>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<CreateBookmarkModal
folderId={folder.id}
trigger={
<button
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Добавить закладку"
>
<Bookmark className="w-3 h-3" />
</button>
}
/>
<CreateFolderModal
parentId={folder.id}
trigger={
<button
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Создать подпапку"
>
<FolderOpen className="w-3 h-3" />
</button>
}
/>
<button
onClick={openRenameDialog}
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-foreground"
title="Переименовать"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={handleDelete}
className="w-5 h-5 flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Удалить"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{folderContent}
</div>
<Dialog open={renamingFolder} onOpenChange={setRenamingFolder}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать папку</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-4">
<Input
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
placeholder="Название папки"
autoFocus
onKeyDown={e => { if (e.key === 'Enter') handleSaveRename(); }}
/>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="w-4 h-4 rounded"
checked={defaultExpanded}
onChange={e => setDefaultExpanded(e.target.checked)}
/>
<span className="text-sm">Развёрнута по умолчанию</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenamingFolder(false)}>Отмена</Button>
<Button onClick={handleSaveRename} disabled={updateFolder.isPending}>
{updateFolder.isPending ? 'Сохранение...' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function BookmarksSection({ collapsed }: { collapsed: boolean }) {
const [editingBookmark, setEditingBookmark] = useState<BookmarkType | null>(null);
const [isEditMode, setIsEditMode] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const { toast } = useToast();
const importInputRef = useRef<HTMLInputElement>(null);
const { data: foldersData, isLoading: foldersLoading } = useBookmarkFolders();
const { data: rootBookmarksData, isLoading: bookmarksLoading } = useBookmarksByFolder(null);
const handleExport = async () => {
try {
const res = await apiRequest('GET', '/api/bookmarks/export');
const data = await res.json();
if (!data.success) throw new Error(data.error);
const blob = new Blob([JSON.stringify(data.bookmarks, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bookmarks.json';
a.click();
URL.revokeObjectURL(url);
} catch (err) {
const message = err instanceof Error ? err.message : 'Неизвестная ошибка';
toast({ title: 'Ошибка экспорта', description: message, variant: 'destructive' });
}
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
try {
setIsImporting(true);
const text = await file.text();
let parsed: unknown;
try { parsed = JSON.parse(text); } catch {
toast({ title: 'Ошибка', description: 'Не удалось прочитать JSON-файл', variant: 'destructive' });
return;
}
const body = Array.isArray(parsed) ? { bookmarks: parsed } : parsed;
const res = await apiRequest('POST', '/api/bookmarks/import', body);
const data = await res.json();
if (!data.success) throw new Error(data.error);
toast({ title: 'Импорт выполнен', description: data.message });
queryClient.invalidateQueries({ queryKey: ['/api/bookmark-folders'] });
queryClient.invalidateQueries({ queryKey: ['/api/bookmarks/by-folder'] });
} catch (err) {
let message = 'Неизвестная ошибка';
if (err instanceof BadRequestError) {
const data = err.serverData as Record<string, unknown> | undefined;
message = typeof data?.error === 'string' ? data.error : 'Некорректный формат файла';
} else if (err instanceof Error) {
message = err.message;
}
toast({ title: 'Ошибка импорта', description: message, variant: 'destructive' });
} finally {
setIsImporting(false);
}
};
if (foldersLoading || bookmarksLoading) {
return (
<div className={cn("px-2 py-1", collapsed && "text-center")}>
<span className="text-[10px] text-muted-foreground">...</span>
</div>
);
}
const folders = foldersData?.folders || [];
const rootFolders = folders.filter(f => f.parentId === null);
const rootBookmarks = [...(rootBookmarksData?.bookmarks || [])].sort((a, b) => a.position - b.position);
const handleEditBookmark = (bookmark: BookmarkType) => setEditingBookmark(bookmark);
// In collapsed sidebar: abbreviation badges with tooltips, no DnD
if (collapsed) {
return (
<div className="space-y-0.5">
{rootBookmarks.map(bookmark => (
<Tooltip key={bookmark.id} delayDuration={0}>
<TooltipTrigger asChild>
<Link href={bookmark.url || ''}>
<div
className="flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors cursor-pointer"
data-testid={`bookmark-${bookmark.id}`}
>
<span className="w-6 h-6 flex items-center justify-center rounded bg-muted text-foreground text-[9px] font-semibold shrink-0 leading-none select-none">
{getAbbr(bookmark.name)}
</span>
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
{bookmark.name}
</TooltipContent>
</Tooltip>
))}
{rootFolders
.sort((a, b) => a.position - b.position)
.map(folder => (
<BookmarkFolderDropdown
key={folder.id}
folder={folder}
allFolders={folders}
collapsed={true}
/>
))}
</div>
);
}
// Expanded sidebar: full DnD-enabled accordion view
return (
<div className="space-y-0.5">
<input
ref={importInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportFile}
/>
<div className="section-header flex items-center justify-between pr-1">
<span>Закладки</span>
<div className="flex items-center gap-0.5">
{isEditMode && <AddBookmarkButton className="h-5 w-5" />}
{isEditMode && (
<button
onClick={handleExport}
className="h-5 w-5 flex items-center justify-center rounded transition-colors text-muted-foreground hover:text-foreground hover:bg-muted/50"
title="Экспортировать закладки"
>
<Download className="w-3 h-3" />
</button>
)}
{isEditMode && (
<button
onClick={() => importInputRef.current?.click()}
disabled={isImporting}
className="h-5 w-5 flex items-center justify-center rounded transition-colors text-muted-foreground hover:text-foreground hover:bg-muted/50 disabled:opacity-50"
title="Импортировать закладки"
>
<Upload className="w-3 h-3" />
</button>
)}
<button
onClick={() => setIsEditMode(m => !m)}
className={cn(
"h-5 w-5 flex items-center justify-center rounded transition-colors",
isEditMode
? "text-primary bg-primary/10 hover:bg-primary/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
title={isEditMode ? 'Выйти из режима редактирования' : 'Режим редактирования'}
>
<Settings className="w-3 h-3" />
</button>
</div>
</div>
{/* Root bookmarks — sortable list */}
<SortableBookmarkList bookmarks={rootBookmarks} onEdit={handleEditBookmark} editMode={isEditMode} />
{/* Root folders — sortable list of accordion items */}
<SortableFolderList folders={rootFolders} allFolders={folders} onEdit={handleEditBookmark} editMode={isEditMode} />
{editingBookmark && (
<EditBookmarkModal
bookmark={editingBookmark}
open={!!editingBookmark}
onOpenChange={(open) => !open && setEditingBookmark(null)}
/>
)}
</div>
);
}
function MessengerNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/chat' || location.startsWith('/chat/');
const [popoverOpen, setPopoverOpen] = useState(false);
const [replyingTo, setReplyingTo] = useState<number | null>(null);
const [replyText, setReplyText] = useState<Record<number, string>>({});
const { data: countData } = useQuery<{ success: boolean; unreadCount: number }>({
queryKey: ['/api/messenger/unread-count'],
refetchInterval: 30000,
staleTime: 10000,
});
const unreadCount = countData?.unreadCount ?? 0;
const { data: convsData } = useQuery<{ success: boolean; conversations: any[] }>({
queryKey: ['/api/messenger/conversations'],
enabled: unreadCount > 0 || popoverOpen,
staleTime: 15000,
});
useEvents({
onConvMessageCreated: useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
}, []),
onConvRead: useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
}, []),
onConvCreated: useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
}, []),
});
const markAsReadMutation = useMutation({
mutationFn: (convId: number) =>
apiRequest('POST', `/api/messenger/conversations/${convId}/read`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
},
});
const sendMessageMutation = useMutation({
mutationFn: ({ convId, message }: { convId: number; message: string }) =>
apiRequest('POST', `/api/messenger/conversations/${convId}/messages`, { message }),
onSuccess: (_data, { convId }) => {
setReplyText(prev => ({ ...prev, [convId]: '' }));
setReplyingTo(null);
// Mark conversation as read since we just replied to it
apiRequest('POST', `/api/messenger/conversations/${convId}/read`).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
},
});
const unreadConvs = (convsData?.conversations ?? [])
.filter((c: any) => (c.unreadCount ?? 0) > 0)
.slice(0, 5)
.map((c: any) => {
const msg = c.lastMessage;
let senderName = '';
if (msg) {
const author = (c.members ?? []).find((m: any) => m.id === msg.authorId);
senderName = author ? `${author.firstName} ${author.lastName}`.trim() : '';
}
const text = msg?.isDeleted ? 'Сообщение удалено' : (msg?.message ?? '');
const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;
return { id: c.id, name: c.displayName ?? 'Диалог', senderName, preview, unread: c.unreadCount };
});
const iconEl = (
<span className="relative w-4 h-4 flex items-center justify-center shrink-0">
<MessageSquare className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full min-w-[13px] h-[13px] flex items-center justify-center px-0.5 leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</span>
);
// Plain link when no unread messages
if (unreadCount === 0) {
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/chat">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer relative",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
{iconEl}
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">Чат</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/chat">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
{iconEl}
<span className="truncate">Чат</span>
</div>
</Link>
);
}
// With unread messages — interactive Popover
const triggerEl = (
<div className={cn(
collapsed
? "flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer relative"
: "w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
{iconEl}
{!collapsed && (
<>
<span className="truncate">Чат</span>
<span className="ml-auto bg-primary text-primary-foreground text-[10px] rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</>
)}
</div>
);
return (
<Popover open={popoverOpen} onOpenChange={(o) => { setPopoverOpen(o); if (!o) setReplyingTo(null); }}>
<PopoverTrigger asChild>
{triggerEl}
</PopoverTrigger>
<PopoverContent side="right" align="end" sideOffset={8} className="w-80 p-3 max-h-[70vh] overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium">
Чат — {unreadCount} непрочитанных
</span>
<Link href="/chat" onClick={() => setPopoverOpen(false)}>
<span className="text-xs text-primary hover:underline cursor-pointer">Открыть чат</span>
</Link>
</div>
{unreadConvs.length === 0 ? (
<div className="text-xs text-muted-foreground py-2 text-center">Загрузка...</div>
) : (
<div className="flex flex-col gap-2">
{unreadConvs.map((conv) => (
<div key={conv.id} className="rounded-md border bg-muted/30 p-2.5">
<div className="flex items-center justify-between mb-1 gap-2">
<Link href={`/chat/${conv.id}`} onClick={() => setPopoverOpen(false)}>
<span className="text-xs font-semibold hover:underline cursor-pointer truncate block max-w-[170px]">
{conv.name}
</span>
</Link>
<div className="flex items-center gap-1 shrink-0">
<span className="text-[10px] bg-primary text-primary-foreground rounded-full px-1.5 py-0.5 leading-none">
{conv.unread}
</span>
<button
className="h-5 w-5 flex items-center justify-center rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
onClick={() => markAsReadMutation.mutate(conv.id)}
title="Отметить прочитанным"
disabled={markAsReadMutation.isPending}
>
<Check className="w-3 h-3" />
</button>
</div>
</div>
{conv.senderName && (
<div className="text-[11px] text-muted-foreground">{conv.senderName}:</div>
)}
<div className="text-[11px] line-clamp-2 mb-2 opacity-80">{conv.preview}</div>
{replyingTo === conv.id ? (
<div className="flex gap-1 mt-1">
<input
className="flex-1 h-7 rounded border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Написать сообщение..."
value={replyText[conv.id] ?? ''}
onChange={(e) => setReplyText(prev => ({ ...prev, [conv.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const msg = replyText[conv.id]?.trim();
if (msg) sendMessageMutation.mutate({ convId: conv.id, message: msg });
}
if (e.key === 'Escape') setReplyingTo(null);
}}
autoFocus
/>
<button
className="h-7 w-7 flex items-center justify-center rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors shrink-0"
onClick={() => {
const msg = replyText[conv.id]?.trim();
if (msg) sendMessageMutation.mutate({ convId: conv.id, message: msg });
}}
disabled={!replyText[conv.id]?.trim() || sendMessageMutation.isPending}
>
<Send className="w-3 h-3" />
</button>
<button
className="h-7 w-7 flex items-center justify-center rounded border hover:bg-accent transition-colors shrink-0"
onClick={() => setReplyingTo(null)}
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<button
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setReplyingTo(conv.id)}
>
<Reply className="w-3 h-3" />
Ответить
</button>
)}
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
);
}
function FinanceNavItem({ collapsed }: { collapsed: boolean }) {
const { user } = useAuth();
const [location] = useLocation();
const isActive = location === '/finance' || location.startsWith('/finance/');
if (!user || (!isAdmin(user) && !hasPermission(user, 'finance.view'))) return null;
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/finance">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<PiggyBank className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">Финансы</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/finance">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<PiggyBank className="w-4 h-4 shrink-0" />
<span className="truncate">Финансы</span>
</div>
</Link>
);
}
function UsersNavItem({ collapsed }: { collapsed: boolean }) {
const { user } = useAuth();
const [location] = useLocation();
const isActive = location === '/users' || location.startsWith('/users/');
if (!user || !hasPermission(user, 'users.manage')) return null;
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/users">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Users className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">Пользователи</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/users">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Users className="w-4 h-4 shrink-0" />
<span className="truncate">Пользователи</span>
</div>
</Link>
);
}
function DashboardNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/dashboard';
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/dashboard">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<LayoutDashboard className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
Дашборд
</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/dashboard">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<LayoutDashboard className="w-4 h-4 shrink-0" />
<span className="truncate">Дашборд</span>
</div>
</Link>
);
}
function CalendarNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/calendar';
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/calendar">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Calendar className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
Календарь
</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/calendar">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Calendar className="w-4 h-4 shrink-0" />
<span className="truncate">Календарь</span>
</div>
</Link>
);
}
function GanttNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/gantt';
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/gantt">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<ChartGantt className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
Гантт
</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/gantt">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<ChartGantt className="w-4 h-4 shrink-0" />
<span className="truncate">Гантт</span>
</div>
</Link>
);
}
function MedScheduleNavItem({ collapsed }: { collapsed: boolean }) {
const { user } = useAuth();
const [location] = useLocation();
const isActive = location === '/med' || location.startsWith('/med/');
if (user?.organization?.slug !== 'medschedule') return null;
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/med">
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<HeartPulse className="w-4 h-4 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
MedSchedule
</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/med">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<HeartPulse className="w-4 h-4 shrink-0" />
<span className="truncate">MedSchedule</span>
</div>
</Link>
);
}
function HomeNavItem({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const isActive = location === '/home';
const inboxCount = useInboxCount();
if (collapsed) {
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Link href="/home">
<div className={cn(
"relative flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Home className="w-4 h-4 shrink-0" />
{inboxCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 leading-none">
{inboxCount > 99 ? '99+' : inboxCount}
</span>
)}
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
Главная
</TooltipContent>
</Tooltip>
);
}
return (
<Link href="/home">
<div className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<Home className="w-4 h-4 shrink-0" />
<span className="truncate flex-1">Главная</span>
{inboxCount > 0 && (
<span className="bg-primary text-primary-foreground text-[8px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 leading-none">
{inboxCount > 99 ? '99+' : inboxCount}
</span>
)}
</div>
</Link>
);
}
function JsPagesSection({ collapsed }: { collapsed: boolean }) {
const [location] = useLocation();
const { data } = useQuery<{ success: boolean; pages: any[] }>({
queryKey: ['/api/custom-pages'],
});
const pages = (data?.pages ?? []).filter((p: any) => p.isActive && p.showInSidebar);
if (!pages.length) return null;
return (
<div className="space-y-0.5">
{!collapsed && <div className="section-header">Страницы</div>}
{pages.map((page: any) => {
const href = `/pages/${page.slug}`;
const isActive = location === href;
if (collapsed) {
return (
<Tooltip key={page.id} delayDuration={0}>
<TooltipTrigger asChild>
<Link href={href}>
<div className={cn(
"flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer",
isActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<FileCode className="w-3.5 h-3.5 shrink-0" />
</div>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">{page.name}</TooltipContent>
</Tooltip>
);
}
return (
<Link href={href} key={page.id}>
<div className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors cursor-pointer",
isActive ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}>
<FileCode className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{page.name}</span>
</div>
</Link>
);
})}
</div>
);
}
interface SidebarProps {
children?: React.ReactNode;
}
export function Sidebar({ children }: SidebarProps) {
const { user, logout, isOfflineSession, isExpiredOffline } = useAuth();
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const [collapsed, setCollapsed] = useState(() => {
const saved = localStorage.getItem('sidebar-collapsed');
return saved === 'true';
});
const { isOnline, pendingCount } = useOfflineSyncContext();
const handleCollapse = (value: boolean) => {
setCollapsed(value);
localStorage.setItem('sidebar-collapsed', String(value));
};
if (!user) {
return <>{children}</>;
}
if (isMobile) {
return (
<SidebarContext.Provider value={{ collapsed: true, setCollapsed: handleCollapse }}>
<div className="flex flex-col h-screen overflow-hidden">
{!isOnline && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800 shrink-0 z-10">
<WifiOff className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-xs text-amber-700 dark:text-amber-300">
{pendingCount > 0
? `Офлайн-режим. ${pendingCount} изм. ожидают синхронизации.`
: 'Офлайн-режим: данные могут быть устаревшими.'}
</span>
</div>
)}
<OfflineSyncBanner />
<main className="flex-1 overflow-auto pb-14">
{children}
</main>
<MobileBottomNav />
</div>
</SidebarContext.Provider>
);
}
return (
<SidebarContext.Provider value={{ collapsed, setCollapsed: handleCollapse }}>
<div className="flex h-screen overflow-hidden">
<aside
className={cn(
"flex flex-col bg-background border-r border-border transition-all duration-150 shrink-0",
collapsed ? "w-11" : "w-48"
)}
data-testid="sidebar"
>
{/* Header */}
<div className="flex items-center justify-between p-2 border-b border-border">
{!collapsed && (
<span className="text-xs font-semibold text-foreground px-1">iistwin</span>
)}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
onClick={() => handleCollapse(!collapsed)}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
data-testid={collapsed ? "button-expand-sidebar" : "button-collapse-sidebar"}
>
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="text-xs">
{collapsed ? 'Развернуть' : 'Свернуть'}
</TooltipContent>
</Tooltip>
</div>
{/* Navigation + Bookmarks + JS Pages */}
<nav className="flex-1 overflow-y-auto p-1.5 space-y-2">
<div className="space-y-0.5">
<HomeNavItem collapsed={collapsed} />
<DashboardNavItem collapsed={collapsed} />
<CalendarNavItem collapsed={collapsed} />
<GanttNavItem collapsed={collapsed} />
<MedScheduleNavItem collapsed={collapsed} />
<UsersNavItem collapsed={collapsed} />
<FinanceNavItem collapsed={collapsed} />
</div>
<JsPagesSection collapsed={collapsed} />
<BookmarksSection collapsed={collapsed} />
</nav>
{/* Footer */}
<div className="border-t border-border p-1.5 space-y-0.5">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors",
collapsed && "justify-center"
)}
onClick={toggleTheme}
data-testid="button-theme-toggle"
>
{theme === 'dark' ? (
<Moon className="w-3.5 h-3.5 shrink-0" />
) : (
<Sun className="w-3.5 h-3.5 shrink-0" />
)}
{!collapsed && <span>Тема</span>}
</button>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right" sideOffset={8} className="text-xs">
{theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
</TooltipContent>
)}
</Tooltip>
<div className={cn("", collapsed ? "flex justify-center" : "")}>
<MessengerNavItem collapsed={collapsed} />
</div>
<div className={cn("", collapsed ? "flex justify-center" : "")}>
<SystemNotificationsDropdown collapsed={collapsed} />
</div>
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"w-full flex items-center gap-2 p-1.5 rounded text-xs hover:bg-muted/50 transition-colors",
collapsed && "justify-center"
)}
data-testid="button-user-menu"
>
<UserAvatar
userId={user.id}
firstName={user.firstName}
lastName={user.lastName}
middleName={user.middleName}
sizeClass="w-5 h-5"
className="shrink-0"
/>
{!collapsed && (
<span className="truncate text-muted-foreground">
{user.firstName} {user.middleName}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end" className="w-48">
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-1">
{user.firstName} {user.middleName} {user.lastName}
</div>
<Link href="/users">
<DropdownMenuItem className="text-xs" data-testid="item-users">
<Users className="w-3.5 h-3.5 mr-2" />
Пользователи
</DropdownMenuItem>
</Link>
<Link href="/forms">
<DropdownMenuItem className="text-xs" data-testid="item-forms">
<FileText className="w-3.5 h-3.5 mr-2" />
Формы
</DropdownMenuItem>
</Link>
<Link href="/directories">
<DropdownMenuItem className="text-xs" data-testid="item-directories">
<Table2 className="w-3.5 h-3.5 mr-2" />
Справочники
</DropdownMenuItem>
</Link>
<Link href="/field-templates">
<DropdownMenuItem className="text-xs" data-testid="item-field-templates">
<Layers className="w-3.5 h-3.5 mr-2" />
Шаблоны полей
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
{isAdmin(user) && (
<>
<Link href="/finance">
<DropdownMenuItem className="text-xs" data-testid="item-finance">
<PiggyBank className="w-3.5 h-3.5 mr-2" />
Финансы
</DropdownMenuItem>
</Link>
<Link href="/documents">
<DropdownMenuItem className="text-xs" data-testid="item-documents">
<FileSignature className="w-3.5 h-3.5 mr-2" />
Документы
</DropdownMenuItem>
</Link>
<Link href="/settings">
<DropdownMenuItem className="text-xs" data-testid="item-settings">
<Settings className="w-3.5 h-3.5 mr-2" />
Настройки приложения
</DropdownMenuItem>
</Link>
<Link href="/billing">
<DropdownMenuItem className="text-xs" data-testid="item-billing">
<Wallet className="w-3.5 h-3.5 mr-2" />
Подписка и биллинг
</DropdownMenuItem>
</Link>
</>
)}
<Link href="/profile">
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
<User className="w-3.5 h-3.5 mr-2" />
Настройки профиля
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-xs text-destructive" data-testid="item-logout">
<LogOut className="w-3.5 h-3.5 mr-2" />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
{!isOnline && (
<div className="flex items-center gap-2 px-4 py-1.5 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800 shrink-0 z-10">
<WifiOff className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-xs text-amber-700 dark:text-amber-300">
{pendingCount > 0
? `Офлайн-режим. ${pendingCount} изм. ожидают синхронизации.`
: 'Офлайн-режим: данные могут быть устаревшими.'}
</span>
</div>
)}
<main className="flex-1 flex flex-col overflow-hidden bg-background">
{children}
</main>
</div>
</div>
<OfflineSyncBanner />
</SidebarContext.Provider>
);
}
export default Sidebar;