1592 lines
59 KiB
TypeScript
1592 lines
59 KiB
TypeScript
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;
|