fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,54 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme') as Theme;
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
const toggleTheme = () => {
setThemeState(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,191 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

218
client/src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,218 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
import { authService } from '@/services/auth.service';
import type { User } from '@/types/auth.types';
import { AUTH_FAILURE_EVENT } from '@/lib/authEvents';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
isOfflineSession: boolean;
isExpiredOffline: boolean;
login: (email: string, password: string, organizationSlug?: string, remember?: boolean) => Promise<boolean>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function useAuthProvider() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isOfflineSession, setIsOfflineSession] = useState(false);
const [isExpiredOffline, setIsExpiredOffline] = useState(false);
useEffect(() => {
const AUTH_CHECK_TIMEOUT_MS = 5000;
const enterOfflineSession = (cachedUser: User) => {
setUser(cachedUser);
setIsOfflineSession(true);
setIsExpiredOffline(false);
};
const checkAuth = async () => {
const cachedUser = authService.getCachedUser();
if (!cachedUser) {
setIsLoading(false);
return;
}
// Show the cached user immediately so the UI does not block on cold PWA start.
setUser(cachedUser);
setIsLoading(false);
try {
const result = await Promise.race([
authService.getSession(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('AUTH_CHECK_TIMEOUT')), AUTH_CHECK_TIMEOUT_MS)
),
]) as Awaited<ReturnType<typeof authService.getSession>>;
if (result.success && result.user) {
setUser(result.user);
authService.setCachedUser(result.user);
setIsOfflineSession(false);
setIsExpiredOffline(false);
return;
}
} catch (error) {
// Network failure: keep offline session alive if we have a cached user.
if (
error instanceof TypeError ||
(error instanceof DOMException && error.name === 'AbortError') ||
!navigator.onLine ||
(error instanceof Error && error.message === 'AUTH_CHECK_TIMEOUT')
) {
enterOfflineSession(cachedUser);
return;
}
console.error('Session check failed:', error);
}
// Server rejected the session (e.g. 401 that could not be refreshed).
setUser(null);
authService.clearCachedUser();
setIsExpiredOffline(true);
};
checkAuth();
}, []);
useEffect(() => {
const handleOnline = async () => {
if (isOfflineSession || isExpiredOffline) {
try {
const result = await authService.getSession();
if (result.success && result.user) {
setUser(result.user);
authService.setCachedUser(result.user);
setIsOfflineSession(false);
setIsExpiredOffline(false);
} else {
setUser(null);
authService.clearCachedUser();
setIsOfflineSession(false);
setIsExpiredOffline(false);
}
} catch (error) {
console.error('Background session check failed:', error);
}
}
};
window.addEventListener('online', handleOnline);
return () => window.removeEventListener('online', handleOnline);
}, [isOfflineSession, isExpiredOffline]);
const login = async (email: string, password: string, organizationSlug?: string, remember?: boolean): Promise<boolean> => {
try {
const result = await authService.login({ email, password, organizationSlug, remember });
if (result.success && result.user) {
setUser(result.user);
setIsOfflineSession(false);
setIsExpiredOffline(false);
return true;
}
return false;
} catch (error) {
console.error('Login failed:', error);
return false;
}
};
const logout = useCallback(async (): Promise<void> => {
await authService.logout();
setUser(null);
setIsOfflineSession(false);
setIsExpiredOffline(false);
}, []);
useEffect(() => {
const handleAuthFailure = () => {
logout().finally(() => {
const currentPath = window.location.pathname + window.location.search;
const isAuthPage = currentPath.startsWith('/login') || currentPath === '/';
const returnTo = !isAuthPage ? `?returnTo=${encodeURIComponent(currentPath)}` : '';
window.location.replace(`/login${returnTo}`);
});
};
window.addEventListener(AUTH_FAILURE_EVENT, handleAuthFailure);
return () => {
window.removeEventListener(AUTH_FAILURE_EVENT, handleAuthFailure);
};
}, [logout]);
// Keep the session alive in long-lived tabs/PWAs. With a 14-day access token
// this is mostly defensive: it refreshes cookies and prevents the family hard
// cap from expiring while the app is open.
useEffect(() => {
if (!user) return;
const KEEP_ALIVE_MS = 12 * 60 * 60 * 1000; // 12 hours
const interval = setInterval(() => {
authService.refreshTokens().catch(() => {});
}, KEEP_ALIVE_MS);
let lastFocusRefresh = 0;
const FOCUS_REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastFocusRefresh < FOCUS_REFRESH_INTERVAL_MS) return;
lastFocusRefresh = now;
authService.refreshTokens().catch(() => {});
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [user]);
const refreshUser = async (): Promise<void> => {
try {
const result = await authService.getSession();
if (result.success && result.user) {
setUser(result.user);
authService.setCachedUser(result.user);
setIsOfflineSession(false);
setIsExpiredOffline(false);
} else {
setUser(null);
authService.clearCachedUser();
}
} catch (error) {
console.error('User refresh failed:', error);
setUser(null);
authService.clearCachedUser();
}
};
return {
user,
isAuthenticated: !!user,
isLoading,
isOfflineSession,
isExpiredOffline,
login,
logout,
refreshUser
};
}
export { AuthContext };

View File

@@ -0,0 +1,334 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest, BadRequestError } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import type { BookmarkFolder, Bookmark } from '@shared/schema';
// Bookmark Folders hooks
export function useBookmarkFolders() {
return useQuery<{ success: boolean; folders: BookmarkFolder[] }>({
queryKey: ['/api/bookmark-folders']
});
}
export function useBookmarkFoldersByParent(parentId: number | null) {
return useQuery<{ success: boolean; folders: BookmarkFolder[] }>({
queryKey: ['/api/bookmark-folders/by-parent', parentId]
});
}
export function useCreateBookmarkFolder() {
const { toast } = useToast();
return useMutation({
mutationFn: async (data: { name: string; parentId?: number; position?: number }) => {
const response = await apiRequest('POST', '/api/bookmark-folders', data);
return await response.json();
},
onSuccess: (result: any) => {
if (result.success) {
// Мягкое обновление кэша - избегаем агрессивной инвалидации
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
toast({
title: 'Ошибка',
description: 'Не удалось создать папку',
variant: 'destructive',
});
}
});
}
export function useUpdateBookmarkFolder() {
const { toast } = useToast();
return useMutation({
mutationFn: async ({ id, ...data }: { id: number; name?: string; parentId?: number; position?: number; isExpanded?: boolean }) => {
const response = await apiRequest('PUT', `/api/bookmark-folders/${id}`, data);
return await response.json();
},
onSuccess: (result: any) => {
if (result.success) {
// Мягкое обновление кэша - избегаем агрессивной инвалидации
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
toast({
title: 'Ошибка',
description: 'Не удалось обновить папку',
variant: 'destructive',
});
}
});
}
export function useDeleteBookmarkFolder() {
const { toast } = useToast();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('DELETE', `/api/bookmark-folders/${id}`);
return await response.json();
},
onSuccess: (result: any) => {
if (result.success) {
// Мягкое обновление кэша - избегаем агрессивной инвалидации
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
toast({
title: 'Ошибка',
description: 'Не удалось удалить папку',
variant: 'destructive',
});
}
});
}
// Bookmarks hooks
export function useBookmarks() {
return useQuery<{ success: boolean; bookmarks: Bookmark[] }>({
queryKey: ['/api/bookmarks']
});
}
export function useBookmarksByFolder(folderId: number | null) {
return useQuery<{ success: boolean; bookmarks: Bookmark[] }>({
queryKey: ['/api/bookmarks/by-folder', folderId ?? 'null']
});
}
export function useCreateBookmark() {
const { toast } = useToast();
return useMutation({
mutationFn: async (data: {
name: string;
type: 'form' | 'task' | 'custom_url';
folderId?: number;
targetId?: number;
formId?: number;
url?: string;
icon?: string;
position?: number;
viewConfig?: Record<string, any>;
}) => {
const response = await apiRequest('POST', '/api/bookmarks', data);
return await response.json();
},
onSuccess: (result: any, variables) => {
if (result.success) {
// Мягкое обновление кэша - избегаем агрессивной инвалидации
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks/by-folder'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
if (error instanceof BadRequestError) return;
toast({
title: 'Ошибка',
description: 'Не удалось создать закладку',
variant: 'destructive',
});
}
});
}
export function useUpdateBookmark() {
const { toast } = useToast();
return useMutation({
mutationFn: async ({ id, ...data }: {
id: number;
name?: string;
type?: 'form' | 'task' | 'custom_url';
folderId?: number;
targetId?: number;
formId?: number;
url?: string;
icon?: string;
position?: number;
viewConfig?: Record<string, any>;
}) => {
const response = await apiRequest('PUT', `/api/bookmarks/${id}`, data);
return await response.json();
},
onSuccess: (result: any, variables) => {
if (result.success) {
// Мягкое обновление кэша - избегаем агрессивной инвалидации
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks/by-folder'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
if (error instanceof BadRequestError) return;
toast({
title: 'Ошибка',
description: 'Не удалось обновить закладку',
variant: 'destructive',
});
}
});
}
export function useReorderBookmarks() {
return useMutation({
mutationFn: async (items: { id: number; position: number }[]) => {
const response = await apiRequest('POST', '/api/bookmarks/reorder', { items });
return await response.json();
},
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['/api/bookmarks'], type: 'active' });
queryClient.refetchQueries({ queryKey: ['/api/bookmarks/by-folder'], type: 'active' });
},
onError: () => {
// Rollback optimistic order by re-fetching server state
queryClient.refetchQueries({ queryKey: ['/api/bookmarks'], type: 'active' });
queryClient.refetchQueries({ queryKey: ['/api/bookmarks/by-folder'], type: 'active' });
},
});
}
export function useReorderBookmarkFolders() {
return useMutation({
mutationFn: async (items: { id: number; position: number }[]) => {
const response = await apiRequest('POST', '/api/bookmark-folders/reorder', { items });
return await response.json();
},
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['/api/bookmark-folders'], type: 'active' });
},
onError: () => {
// Rollback optimistic order by re-fetching server state
queryClient.refetchQueries({ queryKey: ['/api/bookmark-folders'], type: 'active' });
queryClient.refetchQueries({ queryKey: ['/api/bookmarks/by-folder'], type: 'active' });
},
});
}
export function useDeleteBookmark() {
const { toast } = useToast();
return useMutation({
mutationFn: async (id: number) => {
const response = await apiRequest('DELETE', `/api/bookmarks/${id}`);
return await response.json();
},
onSuccess: (result: any) => {
if (result.success) {
// Более мягкая инвалидация кэша - только обновляем данные, не заставляем перезагружать
queryClient.refetchQueries({
queryKey: ['/api/bookmark-folders'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks'],
type: 'active'
});
queryClient.refetchQueries({
queryKey: ['/api/bookmarks/by-folder'],
type: 'active'
});
toast({
title: 'Успех',
description: result.message,
});
} else {
toast({
title: 'Ошибка',
description: result.error,
variant: 'destructive',
});
}
},
onError: (error: any) => {
toast({
title: 'Ошибка',
description: 'Не удалось удалить закладку',
variant: 'destructive',
});
}
});
}

View File

@@ -0,0 +1,67 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
export interface CalendarTask {
id: number;
title: string;
dueDate: string;
formId: number;
currentStatusId: number;
statusColor: string | null;
statusName: string | null;
isCompleted: boolean;
isAllDay: boolean;
}
interface CalendarTasksResponse {
success: boolean;
tasks: CalendarTask[];
}
function buildCalendarQueryKey(from: Date, to: Date, formIds: number[]) {
return ['/api/calendar/tasks', from.toISOString(), to.toISOString(), formIds.join(',')];
}
export function useCalendarTasks(from: Date, to: Date, formIds: number[]) {
return useQuery<CalendarTasksResponse>({
queryKey: buildCalendarQueryKey(from, to, formIds),
queryFn: async ({ queryKey }) => {
const url = new URL(queryKey[0] as string, window.location.origin);
url.searchParams.set('from', queryKey[1] as string);
url.searchParams.set('to', queryKey[2] as string);
if (formIds.length > 0) {
url.searchParams.set('formIds', formIds.join(','));
}
const res = await apiRequest('GET', url.pathname + url.search);
return res.json();
},
enabled: formIds.length > 0 && !isNaN(from.getTime()) && !isNaN(to.getTime()),
placeholderData: { success: true, tasks: [] },
});
}
export function useUpdateCalendarTaskDueDate() {
const { toast } = useToast();
return useMutation({
mutationFn: async ({ taskId, dueDate }: { taskId: number; dueDate: Date }) => {
const response = await apiRequest('PUT', `/api/calendar/tasks/${taskId}/due-date`, {
dueDate: dueDate.toISOString(),
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/calendar/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox'] });
queryClient.invalidateQueries({ queryKey: ['home'] });
},
onError: (error: any) => {
toast({
title: 'Ошибка',
description: error?.message || 'Не удалось изменить срок задачи',
variant: 'destructive',
});
},
});
}

View File

@@ -0,0 +1,170 @@
import { useState, useCallback, useEffect, useRef } from 'react';
export type SortDirection = 'asc' | 'desc' | null;
export interface ColumnSort {
column: string;
direction: 'asc' | 'desc';
}
export interface UseColumnFiltersReturn {
filters: Record<string, string>;
sort: ColumnSort | null;
searches: string[];
setFilter: (column: string, value: string) => void;
clearFilter: (column: string) => void;
clearAll: () => void;
setSort: (column: string) => void;
clearSort: () => void;
addSearch: (value: string) => void;
removeSearch: (index: number) => void;
clearSearches: () => void;
hasActiveFilters: boolean;
}
function isPlainStringRecord(v: unknown): v is Record<string, string> {
if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
return Object.values(v).every(val => typeof val === 'string');
}
function isColumnSort(v: unknown): v is ColumnSort {
if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
const obj = v as Record<string, unknown>;
return typeof obj.column === 'string' && (obj.direction === 'asc' || obj.direction === 'desc');
}
function readFromUrl(): { filters: Record<string, string>; sort: ColumnSort | null; searches: string[] } {
try {
const params = new URLSearchParams(window.location.search);
const filtersRaw = params.get('filters');
const sortRaw = params.get('sort');
const searchValues = params.getAll('search');
const parsedFilters = filtersRaw ? JSON.parse(filtersRaw) : {};
const filters = isPlainStringRecord(parsedFilters) ? parsedFilters : {};
const parsedSort = sortRaw ? JSON.parse(sortRaw) : null;
const sort = parsedSort !== null && isColumnSort(parsedSort) ? parsedSort : null;
const searches = searchValues.filter((s): s is string => typeof s === 'string' && s.trim() !== '');
return { filters, sort, searches };
} catch {
return { filters: {}, sort: null, searches: [] };
}
}
function writeToUrl(filters: Record<string, string>, sort: ColumnSort | null, searches: string[]) {
const params = new URLSearchParams(window.location.search);
if (Object.keys(filters).length > 0) {
params.set('filters', JSON.stringify(filters));
} else {
params.delete('filters');
}
if (sort) {
params.set('sort', JSON.stringify(sort));
} else {
params.delete('sort');
}
params.delete('search');
for (const s of searches) {
params.append('search', s);
}
const searchStr = params.toString();
const newUrl = window.location.pathname + (searchStr ? '?' + searchStr : '');
window.history.replaceState(null, '', newUrl);
}
export function useColumnFilters(): UseColumnFiltersReturn {
const initial = useRef(readFromUrl());
const [filters, setFilters] = useState<Record<string, string>>(initial.current.filters);
const [sort, setSort] = useState<ColumnSort | null>(initial.current.sort);
const [searches, setSearches] = useState<string[]>(initial.current.searches);
const isMounted = useRef(false);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
writeToUrl(filters, sort, searches);
}, [filters, sort, searches]);
const setFilter = useCallback((column: string, value: string) => {
setFilters(prev => {
if (!value) {
const { [column]: _, ...rest } = prev;
return rest;
}
return { ...prev, [column]: value };
});
}, []);
const clearFilter = useCallback((column: string) => {
setFilters(prev => {
const { [column]: _, ...rest } = prev;
return rest;
});
}, []);
const clearAll = useCallback(() => {
setFilters({});
setSort(null);
setSearches([]);
}, []);
const handleSetSort = useCallback((column: string) => {
setSort(prev => {
if (!prev || prev.column !== column) {
return { column, direction: 'desc' };
}
if (prev.direction === 'desc') {
return { column, direction: 'asc' };
}
return null;
});
}, []);
const clearSort = useCallback(() => {
setSort(null);
}, []);
const addSearch = useCallback((value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setSearches(prev => {
if (prev.includes(trimmed)) return prev;
return [...prev, trimmed];
});
}, []);
const removeSearch = useCallback((index: number) => {
setSearches(prev => prev.filter((_, i) => i !== index));
}, []);
const clearSearches = useCallback(() => {
setSearches([]);
}, []);
const hasActiveFilters = Object.keys(filters).length > 0 || sort !== null || searches.length > 0;
return {
filters,
sort,
searches,
setFilter,
clearFilter,
clearAll,
setSort: handleSetSort,
clearSort,
addSearch,
removeSearch,
clearSearches,
hasActiveFilters,
};
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
export function useColumnLabels() {
return useQuery<{ success: boolean; config: { columnLabels: Record<string, string> } }>({
queryKey: ["/api/table-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/table-config");
return res.json();
},
staleTime: 5 * 60 * 1000,
});
}
export function useGetLabel(columnLabels?: Record<string, string>) {
return (key: string, fallback?: string): string => {
return columnLabels?.[key] ?? fallback ?? key;
};
}

View File

@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
export interface DashboardForm {
id: number;
name: string;
}
export interface StatusCount {
statusId: number;
name: string;
color: string;
count: number;
}
export interface DashboardTask {
id: number;
title: string;
formId: number;
formName: string;
statusName: string;
statusColor: string;
dueDate: string | null;
assigneeName: string | null;
}
export interface AssigneeDistributionItem {
userId: number | null;
userName: string;
count: number;
}
export interface DashboardData {
forms: DashboardForm[];
statusCounts: Record<number, StatusCount[]>;
overdueTasks: DashboardTask[];
myTasks: DashboardTask[];
assigneeDistribution: Record<number, AssigneeDistributionItem[]>;
}
export function useDashboard(formIds: number[]) {
return useQuery<{ success: boolean; data: DashboardData }>({
queryKey: ['/api/dashboard', formIds],
queryFn: async () => {
const ids = formIds.join(',');
const res = await apiRequest('GET', `/api/dashboard?formIds=${ids}`);
return res.json();
},
staleTime: 30 * 1000,
});
}

View File

@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,563 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type {
DocumentTemplate,
TemplateVariable,
DocumentTemplateCategory,
OrganizationAsset,
DocumentGeneration,
} from "@shared/schema";
export type OutputFormat = "pdf" | "docx" | "both";
// ── Types ────────────────────────────────────────────────────────────────────
export interface CreateTemplateInput {
name: string;
description?: string;
content: string;
categoryId?: number | null;
formId?: number | null;
pageOrientation?: string;
pageSize?: string;
margins?: { top: number; right: number; bottom: number; left: number };
assetPlacements?: Array<{
assetId: number;
position: string;
page?: string;
customX?: number;
customY?: number;
width?: number;
height?: number;
opacity?: number;
}>;
variables?: Array<Partial<TemplateVariable>>;
}
export interface UpdateTemplateInput extends Partial<CreateTemplateInput> {}
export interface CreateCategoryInput {
name: string;
icon?: string;
color?: string;
sortOrder?: number;
}
export interface CreateAssetInput {
type: "stamp" | "signature" | "logo" | "image";
label: string;
file?: File;
userId?: number;
position?: string;
settings?: { width?: number; height?: number; opacity?: number };
formId?: number;
fieldCode?: string;
fieldValue?: string;
}
export interface GenerateDocumentInput {
templateId: number;
taskId: number;
format: OutputFormat;
}
export interface PreviewDocumentInput {
templateId: number;
taskId: number;
}
export interface DocumentPackageWithItems {
id: number;
name: string;
description?: string | null;
category?: string | null;
isActive?: boolean | null;
organizationId: number;
createdAt?: Date | string | null;
items: Array<{
id: number;
packageId: number;
templateId: number;
sortOrder: number;
customName?: string | null;
condition?: any;
}>;
}
export interface CreatePackageInput {
name: string;
description?: string;
category?: string;
items: Array<{ templateId: number; sortOrder?: number; customName?: string }>;
}
export interface GeneratePackageInput {
packageId: number;
taskId: number;
}
// ── Signatures ───────────────────────────────────────────────────────────────
export interface DocumentSignature {
id: number;
documentGenerationId: number;
type: string;
status: string;
signedBy?: number | null;
signedAt?: Date | string | null;
comment?: string | null;
isCurrent?: boolean | null;
createdAt?: Date | string | null;
}
// ── Templates ────────────────────────────────────────────────────────────────
export function useDocumentTemplates(filters?: {
status?: string;
categoryId?: number;
formId?: number;
}) {
const queryParams = new URLSearchParams();
if (filters?.status) queryParams.set("status", filters.status);
if (filters?.categoryId) queryParams.set("categoryId", String(filters.categoryId));
if (filters?.formId) queryParams.set("formId", String(filters.formId));
return useQuery({
queryKey: ["/api/documents/templates", filters],
queryFn: async () => {
const res = await apiRequest("GET", `/api/documents/templates?${queryParams.toString()}`);
const data = await res.json();
return data.templates as DocumentTemplate[];
},
});
}
export function useDocumentTemplate(id: number | null) {
return useQuery({
queryKey: ["/api/documents/templates", id],
queryFn: async () => {
const res = await apiRequest("GET", `/api/documents/templates/${id}`);
const data = await res.json();
return data.template as DocumentTemplate & {
variables: TemplateVariable[];
category?: DocumentTemplateCategory;
};
},
enabled: !!id,
});
}
export function useCreateDocumentTemplate() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (input: CreateTemplateInput) => {
const res = await apiRequest("POST", "/api/documents/templates", input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка создания шаблона");
return data.template as DocumentTemplate;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] });
toast({ title: "Шаблон создан" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useUpdateDocumentTemplate() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ id, input }: { id: number; input: UpdateTemplateInput }) => {
const res = await apiRequest("PUT", `/api/documents/templates/${id}`, input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка обновления шаблона");
return data.template as DocumentTemplate;
},
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] });
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates", vars.id] });
toast({ title: "Шаблон обновлён" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useDeleteDocumentTemplate() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/documents/templates/${id}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Ошибка удаления шаблона");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] });
toast({ title: "Шаблон удалён" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
// ── Categories ───────────────────────────────────────────────────────────────
export function useDocumentTemplateCategories() {
return useQuery({
queryKey: ["/api/documents/templates/categories"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/documents/templates/categories");
const data = await res.json();
return data.categories as DocumentTemplateCategory[];
},
});
}
export function useCreateDocumentTemplateCategory() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (input: CreateCategoryInput) => {
const res = await apiRequest("POST", "/api/documents/templates/categories", input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка создания категории");
return data.category as DocumentTemplateCategory;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates/categories"] });
toast({ title: "Категория создана" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
// ── Assets ───────────────────────────────────────────────────────────────────
export function useOrganizationAssets(filters?: { type?: string; isActive?: boolean }) {
const queryParams = new URLSearchParams();
if (filters?.type) queryParams.set("type", filters.type);
if (filters?.isActive !== undefined) queryParams.set("isActive", String(filters.isActive));
return useQuery({
queryKey: ["/api/documents/assets", filters],
queryFn: async () => {
const res = await apiRequest("GET", `/api/documents/assets?${queryParams.toString()}`);
const data = await res.json();
return data.assets as OrganizationAsset[];
},
});
}
export function useCreateOrganizationAsset() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (input: CreateAssetInput) => {
const formData = new FormData();
formData.append("type", input.type);
formData.append("label", input.label);
if (input.userId) formData.append("userId", String(input.userId));
if (input.position) formData.append("position", input.position);
if (input.settings) formData.append("settings", JSON.stringify(input.settings));
if (input.file) formData.append("file", input.file);
if (input.formId) formData.append("formId", String(input.formId));
if (input.fieldCode) formData.append("fieldCode", input.fieldCode);
if (input.fieldValue) formData.append("fieldValue", input.fieldValue);
const res = await fetch("/api/documents/assets", {
method: "POST",
body: formData,
credentials: "include",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка загрузки актива");
return data.asset as OrganizationAsset;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/assets"] });
toast({ title: "Актив загружен" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useDeleteOrganizationAsset() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/documents/assets/${id}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Ошибка удаления актива");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/assets"] });
toast({ title: "Актив удалён" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
// ── Generations ──────────────────────────────────────────────────────────────
export function useDocumentGenerations(filters?: {
templateId?: number;
taskId?: number;
status?: string;
limit?: number;
offset?: number;
}) {
const queryParams = new URLSearchParams();
if (filters?.templateId) queryParams.set("templateId", String(filters.templateId));
if (filters?.taskId) queryParams.set("taskId", String(filters.taskId));
if (filters?.status) queryParams.set("status", filters.status);
if (filters?.limit) queryParams.set("limit", String(filters.limit));
if (filters?.offset !== undefined) queryParams.set("offset", String(filters.offset));
return useQuery({
queryKey: ["/api/documents/generations", filters],
queryFn: async () => {
const res = await apiRequest("GET", `/api/documents/generations?${queryParams.toString()}`);
const data = await res.json();
return {
generations: data.generations as DocumentGeneration[],
total: data.total as number,
};
},
});
}
export function useGenerateDocument() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (input: GenerateDocumentInput) => {
const res = await apiRequest("POST", "/api/documents/generations", input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка генерации документа");
return data as {
generationId: number;
pdfUrl?: string;
docxUrl?: string;
unresolvedVariables: string[];
status: string;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/generations"] });
toast({ title: "Документ сгенерирован" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function usePreviewDocument() {
return useMutation({
mutationFn: async (input: PreviewDocumentInput) => {
const res = await apiRequest("POST", "/api/documents/generations/preview", input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка предпросмотра");
return data as { html: string; unresolvedVariables: string[] };
},
});
}
// ── Packages ─────────────────────────────────────────────────────────────────
export function useDocumentPackages() {
return useQuery({
queryKey: ["/api/documents/packages"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/documents/packages");
const data = await res.json();
return data.packages as DocumentPackageWithItems[];
},
});
}
export function useCreateDocumentPackage() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (input: CreatePackageInput) => {
const res = await apiRequest("POST", "/api/documents/packages", input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка создания пакета");
return data.package as DocumentPackageWithItems;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/packages"] });
toast({ title: "Пакет создан" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useDeleteDocumentPackage() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async (id: number) => {
const res = await apiRequest("DELETE", `/api/documents/packages/${id}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Ошибка удаления пакета");
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/packages"] });
toast({ title: "Пакет удалён" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useGenerateDocumentPackage() {
const { toast } = useToast();
return useMutation({
mutationFn: async (input: GeneratePackageInput) => {
const res = await apiRequest("POST", `/api/documents/packages/${input.packageId}/generate`, input);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка генерации пакета");
return data as { packageId: number; zipUrl: string; generatedFiles: number; skippedItems: number };
},
onSuccess: (data) => {
toast({ title: "Пакет сгенерирован", description: `Файлов: ${data.generatedFiles}` });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
// ── Signatures ───────────────────────────────────────────────────────────────
export function useDocumentSignatures(generationId: number | null) {
return useQuery({
queryKey: ["/api/documents/signatures", generationId],
queryFn: async () => {
const res = await apiRequest("GET", `/api/documents/signatures?generationId=${generationId}`);
const data = await res.json();
return data.signatures as DocumentSignature[];
},
enabled: !!generationId,
});
}
export function useSignDocument() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ generationId, comment }: { generationId: number; comment?: string }) => {
const res = await apiRequest("POST", `/api/documents/signatures/${generationId}/sign`, { comment });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка подписания");
return data.signature as DocumentSignature;
},
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/signatures", vars.generationId] });
queryClient.invalidateQueries({ queryKey: ["/api/documents/generations"] });
toast({ title: "Документ подписан" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
export function useRejectDocument() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ generationId, comment }: { generationId: number; comment?: string }) => {
const res = await apiRequest("POST", `/api/documents/signatures/${generationId}/reject`, { comment });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка отклонения");
return data.signature as DocumentSignature;
},
onSuccess: (_, vars) => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/signatures", vars.generationId] });
queryClient.invalidateQueries({ queryKey: ["/api/documents/generations"] });
toast({ title: "Подпись отклонена" });
},
onError: (error: Error) => {
toast({ title: "Ошибка", description: error.message, variant: "destructive" });
},
});
}
// ── DOCX Import ──────────────────────────────────────────────────────────────
export interface ImportDocxInput {
file: File;
name?: string;
}
export interface ImportDocxResponse {
template: DocumentTemplate;
variables: TemplateVariable[];
warnings: string[];
}
export function useImportDocxTemplate() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ file, name }: ImportDocxInput) => {
const formData = new FormData();
formData.append("file", file);
if (name) formData.append("name", name);
const res = await apiRequest("POST", "/api/documents/templates/import-docx", formData);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Ошибка импорта DOCX");
return data as ImportDocxResponse;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/documents/templates"] });
toast({
title: "Шаблон импортирован",
description: `Создан черновик «${data.template.name}» с ${data.variables.length} переменными.`,
});
},
onError: (error: Error) => {
toast({ title: "Ошибка импорта", description: error.message, variant: "destructive" });
},
});
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { useAuth } from './useAuth';
import { sseManager } from '@/lib/sseManager';
interface UseEventsOptions {
onMessageRead?: (data: { messageId: number; taskId: number; readerId: number; readerName: string; authorId: number }) => void;
onMessageCreated?: (data: { taskId: number; message: any }) => void;
onTaskUpdated?: (data: { taskId: number; formId: number; task: any }) => void;
onNotification?: (data: { type: string; taskId?: number; messageId?: number }) => void;
onConvMessageCreated?: (data: { conversationId: number; message: any }) => void;
onConvMessageUpdated?: (data: { conversationId: number; message: any }) => void;
onConvMessageDeleted?: (data: { conversationId: number; messageId: number }) => void;
onConvRead?: (data: { conversationId: number }) => void;
onConvCreated?: (data: any) => void;
onReactionUpdated?: (data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; reactions: any[] }) => void;
onPollUpdated?: (data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; poll: any }) => void;
reconnectInterval?: number;
}
export const useEvents = (options: UseEventsOptions = {}) => {
const { user } = useAuth();
const [isConnected, setIsConnected] = useState(false);
const [connectionId, setConnectionId] = useState<string | null>(null);
// Привязываем singleton к текущему пользователю
useEffect(() => {
sseManager.setUser(user ? (user as any).id ?? null : null);
}, [user]);
// Подписываемся на состояние подключения
useEffect(() => {
return sseManager.onConnState((connected, connId) => {
setIsConnected(connected);
setConnectionId(connId);
});
}, []);
// Подписываемся на события — каждый useEvents добавляет свои callbacks к одному EventSource
// Fixed: empty dependency array so we subscribe once on mount instead of
// unsubscribing/resubscribing on every render (which dropped SSE events).
useEffect(() => {
const unsubs: Array<() => void> = [];
if (options.onMessageRead) unsubs.push(sseManager.on('message_read', options.onMessageRead));
if (options.onMessageCreated) unsubs.push(sseManager.on('message_created', options.onMessageCreated));
if (options.onTaskUpdated) unsubs.push(sseManager.on('task_updated', options.onTaskUpdated));
if (options.onNotification) unsubs.push(sseManager.on('notification', options.onNotification));
if (options.onConvMessageCreated) unsubs.push(sseManager.on('conv_message_created', options.onConvMessageCreated));
if (options.onConvMessageUpdated) unsubs.push(sseManager.on('conv_message_updated', options.onConvMessageUpdated));
if (options.onConvMessageDeleted) unsubs.push(sseManager.on('conv_message_deleted', options.onConvMessageDeleted));
if (options.onConvRead) unsubs.push(sseManager.on('conv_read', options.onConvRead));
if (options.onConvCreated) unsubs.push(sseManager.on('conv_created', options.onConvCreated));
if (options.onReactionUpdated) unsubs.push(sseManager.on('reaction_updated', options.onReactionUpdated));
if (options.onPollUpdated) unsubs.push(sseManager.on('poll_updated', options.onPollUpdated));
return () => { for (const u of unsubs) u(); };
}, []);
return {
isConnected,
connectionId,
connect: () => sseManager.reconnect(),
disconnect: () => sseManager.setUser(null),
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiRequest, queryClient, getQueryFn } from '@/lib/queryClient';
interface MutationCallbacks<TData, TError, TVariables, TContext = unknown> {
onSuccess?: (data: TData, variables: TVariables, context: TContext) => void | Promise<unknown>;
onError?: (error: TError, variables: TVariables, context: TContext) => void | Promise<unknown>;
}
export interface Folder {
id: number;
name: string;
parentId: number | null;
position: number;
organizationId: number;
createdAt: string;
updatedAt: string;
}
export interface FolderReorderUpdate {
id: number;
parentId: number | null;
position: number;
}
interface CreateFolderData {
name: string;
parentId?: number | null;
position?: number;
}
interface UpdateFolderData {
id: number;
name?: string;
parentId?: number | null;
position?: number;
}
interface MoveItemData {
itemId: number;
folderId: number | null;
}
function createFolderHooks(basePath: string, listQueryKey: string[]) {
return {
useFolders: () =>
useQuery<{ success: boolean; folders: Folder[] }>({
queryKey: listQueryKey,
queryFn: getQueryFn({ on401: 'throw' }),
}),
useCreateFolder: (options?: MutationCallbacks<Response, Error, CreateFolderData>) =>
useMutation({
mutationFn: (data: CreateFolderData) => apiRequest('POST', basePath, data),
onSuccess: (data, variables, context) => {
const createdFolder = (data as any).folder as Folder | undefined;
if (createdFolder) {
queryClient.setQueryData<{ success: boolean; folders: Folder[] }>(listQueryKey, (old) => {
if (!old || !Array.isArray(old.folders)) return old;
return { ...old, folders: [...old.folders, createdFolder] };
});
}
queryClient.invalidateQueries({ queryKey: listQueryKey });
queryClient.refetchQueries({ queryKey: listQueryKey });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
}),
useUpdateFolder: (options?: MutationCallbacks<Response, Error, UpdateFolderData>) =>
useMutation({
mutationFn: (data: UpdateFolderData) => apiRequest('PUT', `${basePath}/${data.id}`, data),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: listQueryKey });
queryClient.refetchQueries({ queryKey: listQueryKey });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
}),
useDeleteFolder: (options?: MutationCallbacks<Response, Error, number>) =>
useMutation({
mutationFn: (id: number) => apiRequest('DELETE', `${basePath}/${id}`),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: listQueryKey });
queryClient.refetchQueries({ queryKey: listQueryKey });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
}),
useReorderFolders: (options?: MutationCallbacks<Response, Error, FolderReorderUpdate[]>) =>
useMutation({
mutationFn: (updates: FolderReorderUpdate[]) => apiRequest('PATCH', `${basePath}/reorder`, { updates }),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: listQueryKey });
queryClient.refetchQueries({ queryKey: listQueryKey });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
}),
useMoveItemToFolder: (itemQueryKey: string[], options?: MutationCallbacks<Response, Error, MoveItemData>) =>
useMutation({
mutationFn: (data: MoveItemData) =>
apiRequest('PATCH', `${basePath.replace('/folders', '')}/${data.itemId}/folder`, { folderId: data.folderId }),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: listQueryKey });
queryClient.invalidateQueries({ queryKey: itemQueryKey });
options?.onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context);
},
}),
};
}
export const formFolderHooks = createFolderHooks('/api/forms/folders', ['/api/forms/folders']);
export const directoryFolderHooks = createFolderHooks('/api/directories/folders', ['/api/directories/folders']);

View File

@@ -0,0 +1,196 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useMemo } from 'react';
import type { Task as GanttLibTask } from 'gantt-task-react';
export interface GanttTaskAssignee {
userId: number;
user: { firstName: string | null; lastName: string | null; email: string };
}
export interface GanttTask {
id: number;
title: string;
description?: string | null;
formId: number;
currentStatusId: number;
statusName: string | null;
statusColor: string | null;
statusIsFinal: boolean;
assignedTo?: number | null;
assignees?: GanttTaskAssignee[];
createdBy: number;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
dueDate?: string | null;
isCompleted: boolean;
parentTaskId?: number | null;
fieldValues?: Record<number, unknown>;
start: string | null;
end: string | null;
milestones: Array<{ fieldId: number; date: string }>;
}
export interface GanttFormMeta {
formId: number;
startFieldId?: number;
endFieldId?: number;
milestoneFieldIds: number[];
}
export interface GanttRelation {
parentTaskId: number;
childTaskId: number;
}
export interface GanttTasksResponse {
success: boolean;
tasks: GanttTask[];
meta: GanttFormMeta[];
relatedTaskTitles: Record<number, string>;
}
export interface GanttRelationsResponse {
success: boolean;
relations: GanttRelation[];
}
export interface GanttDateUpdate {
taskId: number;
start?: string | null;
end?: string | null;
startFieldId?: number;
endFieldId?: number;
}
function parseIsoOrNull(value: string | null | undefined): Date | null {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
function taskBarId(taskId: number | string): string {
return `task-${taskId}`;
}
function milestoneId(taskId: number, fieldId: number): string {
return `milestone-${taskId}-${fieldId}`;
}
export function useGanttTasks(formIds: number[]) {
const queryKey = ['/api/gantt/tasks', { formIds: formIds.sort().join(',') }];
const tasksQuery = useQuery<GanttTasksResponse>({
queryKey,
queryFn: async () => {
if (formIds.length === 0) {
return { success: true, tasks: [], meta: [], relatedTaskTitles: {} };
}
const res = await apiRequest('GET', `/api/gantt/tasks?formIds=${formIds.join(',')}`);
return res.json();
},
enabled: formIds.length > 0,
});
const taskIds = tasksQuery.data?.tasks.map((t) => t.id) || [];
const relationsQuery = useQuery<GanttRelationsResponse>({
queryKey: ['/api/gantt/relations', { taskIds: taskIds.sort().join(',') }],
queryFn: async () => {
if (taskIds.length === 0) {
return { success: true, relations: [] };
}
const res = await apiRequest('GET', `/api/gantt/relations?taskIds=${taskIds.join(',')}`);
return res.json();
},
enabled: taskIds.length > 0,
});
const isLoading = tasksQuery.isLoading || relationsQuery.isLoading;
const isFetching = tasksQuery.isFetching || relationsQuery.isFetching;
const ganttTasks: GanttLibTask[] = useMemo(() => {
const tasks = tasksQuery.data?.tasks || [];
const relations = relationsQuery.data?.relations || [];
const result: GanttLibTask[] = [];
// Build dependency map: child -> parents
const childToParents = new Map<number, number[]>();
for (const r of relations) {
const arr = childToParents.get(r.childTaskId) || [];
arr.push(r.parentTaskId);
childToParents.set(r.childTaskId, arr);
}
for (const task of tasks) {
const start = parseIsoOrNull(task.start);
const end = parseIsoOrNull(task.end);
if (start && end) {
const deps = (childToParents.get(task.id) || []).map((parentId) => taskBarId(parentId));
const styles: GanttLibTask['styles'] | undefined = task.statusColor
? {
backgroundColor: task.statusColor,
backgroundSelectedColor: task.statusColor,
progressColor: task.statusColor,
progressSelectedColor: task.statusColor,
}
: undefined;
result.push({
start,
end,
name: task.title,
id: taskBarId(task.id),
type: 'task',
progress: task.isCompleted ? 100 : 0,
isDisabled: false,
styles,
dependencies: deps.length > 0 ? deps : undefined,
});
}
}
return result;
}, [tasksQuery.data, relationsQuery.data]);
return {
tasks: tasksQuery.data?.tasks || [],
meta: tasksQuery.data?.meta || [],
relatedTaskTitles: tasksQuery.data?.relatedTaskTitles || {},
ganttTasks,
isLoading,
isFetching,
error: tasksQuery.error || relationsQuery.error,
refetch: async () => {
await tasksQuery.refetch();
await relationsQuery.refetch();
},
};
}
export function useUpdateGanttDates() {
const queryClient = useQueryClient();
const { toast } = useToast();
return useMutation({
mutationFn: async ({ taskId, start, end, startFieldId, endFieldId }: GanttDateUpdate) => {
const res = await apiRequest('PUT', `/api/gantt/tasks/${taskId}/dates`, { start, end, startFieldId, endFieldId });
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/gantt/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/gantt/relations'] });
},
onError: (error: any) => {
toast({
title: 'Ошибка обновления дат',
description: error?.message || 'Не удалось сохранить изменения',
variant: 'destructive',
});
},
});
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useEvents } from './useEvents';
import { queryClient } from '@/lib/queryClient';
export function useInboxCount() {
const { data } = useQuery<{ count: number }>({
queryKey: ['/api/home/inbox/count'],
refetchInterval: 30000,
staleTime: 10000,
});
useEvents({
onTaskUpdated: useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
}, []),
onNotification: useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] });
}, []),
});
return data?.count ?? 0;
}

View File

@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getInbox, saveInbox } from '@/lib/tasksCache';
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
import { useAuth } from '@/hooks/useAuth';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
export interface UseInboxWithCacheResult<T extends InboxTaskSnapshot> {
tasks: T[];
total: number;
isLoading: boolean;
isFromCache: boolean;
cacheTimestamp: number | null;
}
/**
* Cache-first inbox wrapper:
* 1. Always loads IDB snapshot immediately on mount (instant startup from cache).
* 2. Background-refreshes from network when online; persists the result back to IDB.
* 3. Live data replaces the cached snapshot once it arrives.
* 4. When offline and no live data, continues to show IDB snapshot.
* 5. Exposes isFromCache / cacheTimestamp for banner display.
*
* T must extend InboxTaskSnapshot (requires at least { id: number }).
*/
export function useInboxWithCache<T extends InboxTaskSnapshot>(
queryKey: readonly unknown[],
queryFn: () => Promise<{ tasks: T[]; total: number }>,
): UseInboxWithCacheResult<T> {
const { user } = useAuth();
const { isOnline } = useOfflineSyncContext();
const [cachedTasks, setCachedTasks] = useState<T[] | null>(null);
const [cachedTotal, setCachedTotal] = useState(0);
const [cacheTimestamp, setCacheTimestamp] = useState<number | null>(null);
// True once the IDB lookup has completed (prevents network query racing ahead)
const [idbLoaded, setIdbLoaded] = useState(false);
// Always load from IDB on mount — cache-first regardless of online status
useEffect(() => {
if (!user?.id) return;
getInbox(user.id)
.then(cached => {
if (cached) {
setCachedTasks(cached.tasks as T[]);
setCachedTotal(cached.total);
setCacheTimestamp(cached.cachedAt);
}
})
.catch(() => {})
.finally(() => setIdbLoaded(true));
}, [user?.id]);
// Background network refresh — enabled once IDB is loaded and we are online
const query = useQuery<{ tasks: T[]; total: number }>({
queryKey,
queryFn,
enabled: isOnline && idbLoaded,
staleTime: isOnline ? 0 : Infinity,
});
// When live data arrives: persist to IDB and let it take over from cache
useEffect(() => {
if (query.data && user?.id) {
saveInbox(user.id, query.data.tasks as InboxTaskSnapshot[], query.data.total).catch(() => {});
// Clear cached state — live data now renders instead
setCachedTasks(null);
setCacheTimestamp(null);
}
}, [query.data, user?.id]);
// isFromCache: we have IDB data and the live query hasn't delivered yet
const isFromCache = cachedTasks !== null && !query.data;
return {
tasks: isFromCache ? cachedTasks! : (query.data?.tasks ?? []),
total: isFromCache ? cachedTotal : (query.data?.total ?? 0),
// Loading while IDB hasn't finished OR while live fetch is in progress (no data yet)
isLoading: !idbLoaded || (!isFromCache && query.isLoading && !query.data),
isFromCache,
cacheTimestamp: isFromCache ? cacheTimestamp : null,
};
}

View File

@@ -0,0 +1,117 @@
import { useCallback, useRef, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
import { useEvents } from '@/hooks/useEvents';
// Структура сообщения для чат-уведомлений
export interface ChatMessagePayload {
id: number;
message: string;
author: {
firstName: string;
lastName: string;
middleName?: string;
};
}
// Базовый тип уведомления (переименован чтобы избежать конфликта с Web Notifications API)
export interface AppNotification {
id: number;
taskId: number;
messageId?: number;
type: string;
title?: string;
isRead: boolean;
createdAt: string;
task?: {
id: number;
title: string;
formId: number;
};
// message - текстовое поле из таблицы (для системных уведомлений)
message?: string;
// taskMessage - объект relation с чат-сообщением (для чат-уведомлений)
taskMessage?: ChatMessagePayload;
}
// Type guard для проверки что message - это объект чата
export function isChatMessage(message: string | ChatMessagePayload | undefined): message is ChatMessagePayload {
return typeof message === 'object' && message !== null && 'author' in message;
}
export const CHAT_EVENT_TYPES = [
'task.mentioned',
'task.comment.created',
'task.comment.replied',
'task_mention',
'task_reply',
];
export const SYSTEM_EVENT_TYPES = [
'task.created',
'task.status.changed',
'task.assigned',
'task.field.changed',
'task.subtask.created',
'form.structure.changed',
'system.announcement',
'task.reminder',
'task_assigned',
'task_status',
];
export function useNotifications() {
const invalidationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleNotificationEvent = useCallback(() => {
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
invalidationTimeoutRef.current = setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['/api/notifications'] });
invalidationTimeoutRef.current = null;
}, 300);
}, []);
useEffect(() => {
return () => {
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
};
}, []);
const { isConnected: sseConnected } = useEvents({
onNotification: handleNotificationEvent,
});
const { data: notificationsData, isLoading } = useQuery({
queryKey: ['/api/notifications'],
refetchInterval: sseConnected ? false : 15000,
refetchIntervalInBackground: false,
});
const allNotifications: AppNotification[] = (notificationsData as any)?.notifications || [];
const { chatNotifications, systemNotifications, unreadChatCount, unreadSystemCount } = useMemo(() => {
const chat = allNotifications.filter(n => CHAT_EVENT_TYPES.includes(n.type));
const system = allNotifications.filter(n => !CHAT_EVENT_TYPES.includes(n.type));
return {
chatNotifications: chat,
systemNotifications: system,
unreadChatCount: chat.filter(n => !n.isRead).length,
unreadSystemCount: system.filter(n => !n.isRead).length,
};
}, [allNotifications]);
return {
allNotifications,
chatNotifications,
systemNotifications,
unreadChatCount,
unreadSystemCount,
isLoading,
sseConnected,
};
}

View File

@@ -0,0 +1,818 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getAll, remove, count, markAsConflict, clearConflictStatus, updateBody } from '@/lib/offlineQueue';
import { countAttachments } from '@/lib/attachmentQueue';
import type { OfflineQueueItem } from '@/lib/offlineQueue';
import {
getAllAttachments,
getAttachmentById,
removeAttachment,
updateAttachment,
markAttachmentError,
type AttachmentQueueItem,
} from '@/lib/attachmentQueue';
import {
performDeltaSync,
performInitialSync,
checkServerReachable,
} from '@/lib/syncEngine';
import { queryClient } from '@/lib/queryClient';
import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache';
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
import { sseManager } from '@/lib/sseManager';
export type SyncStatus = 'idle' | 'syncing' | 'done' | 'error' | 'conflict';
interface UseOfflineSyncReturn {
isOnline: boolean;
isVisible: boolean;
pendingCount: number;
pendingChatCount: number;
pendingAttachmentCount: number;
pendingUrls: Set<string>;
pendingItems: OfflineQueueItem[];
pendingAttachments: AttachmentQueueItem[];
isPending: (url: string) => boolean;
getPendingBody: (url: string) => Record<string, unknown> | null;
status: SyncStatus;
conflicts: OfflineQueueItem[];
attachmentStatus: SyncStatus;
attachmentConflicts: AttachmentQueueItem[];
sync: () => Promise<void>;
resolveConflict: (id: string, resolution: 'force' | 'discard') => Promise<void>;
retryAttachment: (id: string) => Promise<void>;
discardAttachment: (id: string) => Promise<void>;
triggerInitialSync: (formIds?: number[]) => Promise<void>;
}
function isChatMessage(item: OfflineQueueItem): boolean {
return item.method === 'POST' && /\/api\/tasks\/[^/]+\/messages$/.test(item.url);
}
/**
* After a successful sync, refresh and persist the inbox snapshot for offline use.
* Uses the authenticated userId from app state (passed in).
* Authentication is handled automatically via HttpOnly cookies (credentials: 'include').
*/
async function refreshInboxSnapshot(userId: number): Promise<void> {
try {
const res = await fetch('/api/home/inbox?limit=200&offset=0', {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) return;
const body: unknown = await res.json();
const tasks = ((body as { tasks?: InboxTaskSnapshot[] })?.tasks ?? []) as InboxTaskSnapshot[];
const taskIds = tasks.map(t => t.id);
await saveInbox(userId, tasks, (body as { total?: number })?.total ?? tasks.length);
await pruneStaleInboxTasks(userId, taskIds);
} catch {
// Non-fatal
}
}
interface NavigatorBadge {
setAppBadge?: (count?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
}
function updateAppBadge(n: number) {
const nav = navigator as unknown as NavigatorBadge;
if (n > 0) {
nav.setAppBadge?.(n).catch(() => {});
} else {
nav.clearAppBadge?.().catch(() => {});
}
}
async function replayRequest(item: OfflineQueueItem): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (item.enqueuedAt) {
headers['X-Offline-Enqueued-At'] = String(item.enqueuedAt);
}
return fetch(item.url, {
method: item.method,
headers,
body: item.body ?? undefined,
credentials: 'include',
});
}
function isSafeToDrop(item: OfflineQueueItem): boolean {
return item.method === 'POST' && /\/api\/messages\/\d+\/read$/.test(item.url);
}
// Module-level lock so only one sync runs at a time across all hook instances.
let _syncLock: Promise<void> | null = null;
/**
* On 409 Conflict: check if the conflict is "real" (another user changed the
* same fields) or "phantom" (only updatedAt changed, our fields are still at
* the old server value). Uses the stored snapshot to decide: if the server's
* current value equals the snapshot (nobody else changed it), we can safely
* retry without a conflict UI. If the server differs from the snapshot,
* another user/bot modified the field → real conflict.
*
* If no snapshot is present we fall back to the legacy behaviour (compare
* server value with the value the user wanted to write).
*/
async function tryAutoResolve409(item: OfflineQueueItem): Promise<'resolved' | 'conflict'> {
let userBody: Record<string, unknown> = {};
try {
if (item.body) userBody = JSON.parse(item.body) as Record<string, unknown>;
} catch { return 'conflict'; }
if (!('updatedAt' in userBody)) return 'conflict';
try {
const url = item.url.replace(/\?.*$/, '');
const serverRes = await fetch(url, { credentials: 'include' });
if (!serverRes.ok) return 'conflict';
const serverRaw = await serverRes.json() as unknown;
let serverData: Record<string, unknown> | null = null;
if (serverRaw && typeof serverRaw === 'object' && !Array.isArray(serverRaw)) {
const obj = serverRaw as Record<string, unknown>;
if ('success' in obj) {
for (const key of Object.keys(obj)) {
if (key === 'success') continue;
const val = obj[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
serverData = val as Record<string, unknown>;
break;
}
}
} else {
serverData = obj;
}
}
if (!serverData) return 'conflict';
const userChanges = Object.fromEntries(
Object.entries(userBody).filter(([k]) => k !== 'updatedAt' && k !== 'id')
);
const snapshot = item.snapshot;
const hasSnapshot = snapshot && typeof snapshot === 'object';
let hasRealConflict = false;
for (const [field, userValue] of Object.entries(userChanges)) {
const serverValue = serverData[field];
const serverStr = serverValue === null || serverValue === undefined ? '' : String(serverValue);
if (hasSnapshot && field in snapshot) {
const snapshotValue = snapshot[field];
const snapshotStr = snapshotValue === null || snapshotValue === undefined ? '' : String(snapshotValue);
// Real conflict only if the server value changed since we started editing
if (serverStr !== '' && serverStr !== snapshotStr) {
hasRealConflict = true;
break;
}
} else {
// Legacy fallback when no snapshot is available
const userStr = userValue === null || userValue === undefined ? '' : String(userValue);
if (serverStr !== '' && serverStr !== userStr) {
hasRealConflict = true;
break;
}
}
}
if (hasRealConflict) return 'conflict';
const bodyWithoutUpdatedAt = { ...userBody };
delete bodyWithoutUpdatedAt.updatedAt;
const retryHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (item.enqueuedAt) retryHeaders['X-Offline-Enqueued-At'] = String(item.enqueuedAt);
const retryRes = await fetch(item.url, {
method: item.method,
headers: retryHeaders,
body: JSON.stringify(bodyWithoutUpdatedAt),
credentials: 'include',
});
return retryRes.ok ? 'resolved' : 'conflict';
} catch {
return 'conflict';
}
}
async function tryUploadFile(
file: { name: string; type: string; blob: Blob },
allowRefresh: boolean,
): Promise<{ url: string; name: string; size: number; mimeType?: string }> {
const formData = new FormData();
formData.append('file', file.blob, file.name);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
// Try a single session refresh; if it fails the user must re-authenticate.
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
credentials: 'include',
});
if (refreshRes.ok) {
return tryUploadFile(file, false);
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Ошибка загрузки: ${file.name}`);
}
const data = await res.json();
return {
url: data.url,
name: data.name,
size: data.size,
mimeType: file.type || undefined,
};
}
async function uploadAttachmentFile(file: { name: string; type: string; blob: Blob }): Promise<{ url: string; name: string; size: number; mimeType?: string }> {
return tryUploadFile(file, true);
}
async function trySendAttachmentMessage(
item: AttachmentQueueItem,
attachments: { url: string; name: string; size: number; mimeType?: string }[],
allowRefresh: boolean,
): Promise<void> {
const url = item.type === 'messenger'
? `/api/messenger/conversations/${item.targetId}/messages`
: `/api/tasks/${item.targetId}/messages`;
const payload: Record<string, unknown> = {
message: item.message || '',
attachments,
};
if (item.type === 'messenger' && (item.replyToId || item.replyToMessageId)) {
payload.replyToId = item.replyToId || item.replyToMessageId;
}
if (item.type === 'task' && (item.replyToMessageId || item.replyToId)) {
payload.replyToMessageId = item.replyToMessageId || item.replyToId;
}
if (item.mentionedUserIds && item.mentionedUserIds.length > 0) {
payload.mentionedUserIds = item.mentionedUserIds;
}
if (item.mentionedBotIds && item.mentionedBotIds.length > 0) {
payload.mentionedBotIds = item.mentionedBotIds;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include',
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
credentials: 'include',
});
if (refreshRes.ok) {
return trySendAttachmentMessage(item, attachments, false);
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Ошибка отправки сообщения');
}
}
async function sendAttachmentMessage(item: AttachmentQueueItem, attachments: { url: string; name: string; size: number; mimeType?: string }[]): Promise<void> {
return trySendAttachmentMessage(item, attachments, true);
}
export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
const [isVisible, setIsVisible] = useState<boolean>(() => document.visibilityState === 'visible');
const [pendingCount, setPendingCount] = useState<number>(0);
const [pendingChatCount, setPendingChatCount] = useState<number>(0);
const [pendingAttachmentCount, setPendingAttachmentCount] = useState<number>(0);
const [pendingUrls, setPendingUrls] = useState<Set<string>>(new Set());
const [pendingItems, setPendingItems] = useState<OfflineQueueItem[]>([]);
const [pendingAttachments, setPendingAttachments] = useState<AttachmentQueueItem[]>([]);
const [status, setStatus] = useState<SyncStatus>('idle');
const [attachmentStatus, setAttachmentStatus] = useState<SyncStatus>('idle');
const [conflicts, setConflicts] = useState<OfflineQueueItem[]>([]);
const [attachmentConflicts, setAttachmentConflicts] = useState<AttachmentQueueItem[]>([]);
const userIdRef = useRef(userId);
useEffect(() => { userIdRef.current = userId; }, [userId]);
const refreshCount = useCallback(async () => {
try {
const items = await getAll();
const attachments = await getAllAttachments();
setPendingCount(items.length + attachments.length);
setPendingChatCount(items.filter(isChatMessage).length);
setPendingAttachmentCount(attachments.length);
setPendingUrls(new Set(items.map(item => item.url)));
setPendingItems(items);
setPendingAttachments(attachments);
setConflicts(items.filter(i => i.conflictStatus === 'conflict'));
setAttachmentConflicts(attachments.filter(a => a.status === 'error'));
updateAppBadge(items.length + attachments.length);
} catch {
// IndexedDB not available (e.g. private mode on some browsers)
}
}, []);
// On mount: read pending count, then auto-sync if already online with pending items
useEffect(() => {
refreshCount().then(async () => {
try {
const [n, attachmentN] = await Promise.all([count(), countAttachments()]);
if ((n > 0 || attachmentN > 0) && navigator.onLine) {
sync();
}
} catch {
// IndexedDB not available
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sync = useCallback(async () => {
if (_syncLock) {
await _syncLock;
return;
}
_syncLock = (async () => {
setStatus('syncing');
try {
// 1. Pull server-side changes (delta sync). Runs only when online.
// Pending mutations are replayed below; the legacy /api/sync/push endpoint
// is a no-op and is kept only for backward compatibility.
if (navigator.onLine) {
await performDeltaSync().catch((err) => {
console.warn('[offlineSync] Delta sync failed:', err);
});
}
const [items, attachments] = await Promise.all([getAll(), getAllAttachments()]);
if (items.length === 0 && attachments.length === 0) {
setStatus('idle');
setAttachmentStatus('idle');
setPendingCount(0);
setPendingChatCount(0);
setPendingAttachmentCount(0);
setPendingUrls(new Set());
setPendingItems([]);
setPendingAttachments([]);
setConflicts([]);
setAttachmentConflicts([]);
updateAppBadge(0);
return;
}
// Collapse sequential edits: merge multiple PUT/PATCH to the same URL (last-write-wins)
const conflictItems = items.filter(i => i.conflictStatus === 'conflict');
const nonConflictItems = items.filter(i => !i.conflictStatus);
type CollapseGroup = { winner: OfflineQueueItem; losers: OfflineQueueItem[] };
const collapsedMap = new Map<string, CollapseGroup>();
const collapsedOrder: string[] = [];
for (const item of nonConflictItems) {
if (item.method !== 'PUT' && item.method !== 'PATCH') {
const key = item.id;
collapsedMap.set(key, { winner: item, losers: [] });
collapsedOrder.push(key);
continue;
}
const groupKey = `${item.method}:${item.url}`;
if (collapsedMap.has(groupKey)) {
const group = collapsedMap.get(groupKey)!;
let prevBody: Record<string, unknown> = {};
let newBody: Record<string, unknown> = {};
try { prevBody = JSON.parse(group.winner.body ?? '{}'); } catch { /* ignore */ }
try { newBody = JSON.parse(item.body ?? '{}'); } catch { /* ignore */ }
group.losers.push(group.winner);
group.winner = { ...item, body: JSON.stringify({ ...prevBody, ...newBody }) };
} else {
collapsedMap.set(groupKey, { winner: item, losers: [] });
collapsedOrder.push(groupKey);
}
}
// Remove loser items from IDB and persist merged body for winners
for (const [, group] of collapsedMap) {
for (const loser of group.losers) {
await remove(loser.id);
}
if (group.losers.length > 0 && group.winner.body) {
await updateBody(group.winner.id, group.winner.body);
}
}
const itemsToReplay = [
...collapsedOrder.map(k => collapsedMap.get(k)!.winner),
...conflictItems,
];
let hadTransientError = false;
const newConflicts: OfflineQueueItem[] = [];
for (const item of itemsToReplay) {
// Items already marked as conflicted need manual resolution — skip during auto-sync
if (item.conflictStatus === 'conflict') {
newConflicts.push(item);
continue;
}
try {
const res = await replayRequest(item);
if (res.ok) {
// 2xx: success — remove from queue
await remove(item.id);
// Notify listeners (e.g. TaskChat) that this queue item is gone
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else if (res.status === 429 || res.status >= 500) {
// Transient server-side error (rate limited or 5xx) — stop and retry later
hadTransientError = true;
break;
} else if (res.status === 409) {
// Optimistic concurrency conflict: check if our fields actually conflict
// with the server state, or if only updatedAt changed (phantom conflict).
const resolution = await tryAutoResolve409(item);
if (resolution === 'resolved') {
await remove(item.id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else {
await markAsConflict(item.id);
newConflicts.push({ ...item, conflictStatus: 'conflict' });
}
} else {
// Other 4xx: the request failed for a semantic reason (validation, etc.)
// Safe (non-critical) requests are silently dropped; everything else
// is shown in the conflict UI so the user can decide.
if (isSafeToDrop(item)) {
await remove(item.id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else {
await markAsConflict(item.id);
newConflicts.push({ ...item, conflictStatus: 'conflict' });
}
}
} catch {
// Network error during replay — stop and retry later
hadTransientError = true;
break;
}
}
// ── Replay attachment queue (messages with files) ──────────────────────────
let attachmentTransientError = false;
if (!hadTransientError) {
const attachments = await getAllAttachments();
for (const item of attachments) {
if (item.status === 'error') continue;
try {
const uploaded = [];
for (const file of item.files) {
uploaded.push(await uploadAttachmentFile(file));
}
await sendAttachmentMessage(item, uploaded);
await removeAttachment(item.id);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
} catch (err: any) {
const msg = err?.message || '';
// Auth errors / validation errors — mark as error, don't retry blindly
if (/\b401\b/.test(msg) || /\b403\b/.test(msg) || /\b400\b/.test(msg)) {
await markAttachmentError(item.id, msg);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
continue;
}
// Transient errors — stop and retry later
attachmentTransientError = true;
break;
}
}
}
const remainingItems = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remainingItems.length + remainingAttachments.length);
setPendingChatCount(remainingItems.filter(isChatMessage).length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingUrls(new Set(remainingItems.map(i => i.url)));
setPendingItems(remainingItems);
setPendingAttachments(remainingAttachments);
setConflicts(newConflicts);
updateAppBadge(remainingItems.length + remainingAttachments.length);
const finalHadError = hadTransientError || attachmentTransientError;
if (newConflicts.length > 0) {
setStatus('conflict');
} else if (finalHadError) {
setStatus('error');
} else {
setStatus('done');
setAttachmentStatus('done');
// Invalidate all queries so UI refreshes with server data
await queryClient.invalidateQueries();
// Refresh inbox snapshot for offline cache using authenticated userId from app state
const uid = userIdRef.current;
if (uid) {
refreshInboxSnapshot(uid).catch(() => {});
}
setTimeout(() => {
setStatus('idle');
setAttachmentStatus('idle');
}, 3000);
}
} catch {
setStatus('error');
setAttachmentStatus('error');
} finally {
_syncLock = null;
}
})();
await _syncLock;
}, []);
const resolveConflict = useCallback(async (id: string, resolution: 'force' | 'discard') => {
try {
const items = await getAll();
const item = items.find(i => i.id === id);
if (!item) return;
setAttachmentStatus('idle');
if (resolution === 'discard') {
// User explicitly chooses to throw away their pending change
await remove(id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
} else {
// Force: replay the request and only remove from queue on 2xx success.
// On any failure keep the item in the conflict queue so the user can retry.
await clearConflictStatus(id);
let success = false;
try {
const res = await replayRequest(item);
success = res.ok;
} catch {
// Network error — force replay failed
}
if (success) {
await remove(id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
} else {
// Re-mark as conflict so it stays visible for another attempt
await markAsConflict(id);
}
}
const remaining = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remaining.length + remainingAttachments.length);
setPendingChatCount(remaining.filter(isChatMessage).length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingUrls(new Set(remaining.map(i => i.url)));
setPendingItems(remaining);
setPendingAttachments(remainingAttachments);
const remainingConflicts = remaining.filter(i => i.conflictStatus === 'conflict');
setConflicts(remainingConflicts);
setAttachmentConflicts(remainingAttachments.filter(a => a.status === 'error'));
updateAppBadge(remaining.length + remainingAttachments.length);
if (remainingConflicts.length === 0) {
await queryClient.invalidateQueries();
const allDone = remaining.length === 0 && remainingAttachments.length === 0;
if (allDone) {
setStatus('done');
setAttachmentStatus('done');
setTimeout(() => {
setStatus('idle');
setAttachmentStatus('idle');
}, 3000);
} else {
setStatus('idle');
setAttachmentStatus('idle');
}
}
} catch {
// IndexedDB error
}
}, []);
const discardAttachment = useCallback(async (id: string) => {
try {
await removeAttachment(id);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
const remaining = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remaining.length + remainingAttachments.length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingItems(remaining);
setPendingAttachments(remainingAttachments);
setAttachmentConflicts(remainingAttachments.filter(a => a.status === 'error'));
updateAppBadge(remaining.length + remainingAttachments.length);
} catch {
// IndexedDB error
}
}, []);
const retryAttachment = useCallback(async (id: string) => {
try {
const item = await getAttachmentById(id);
if (!item) return;
const updated: AttachmentQueueItem = { ...item, status: 'pending', error: null };
await updateAttachment(updated);
setAttachmentStatus('syncing');
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
sync();
} catch {
// IndexedDB error
}
}, [sync]);
// Track page visibility so we don't run connectivity checks or show offline UI
// while the PWA/tab is in the background.
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
// Listen for online/offline events
useEffect(() => {
const handleOnline = () => {
// When hidden, only update the flag; actual sync runs when the user returns
setIsOnline(true);
if (!isVisible) return;
(navigator.serviceWorker?.ready as Promise<any>)
.then(r => r.sync?.register('offline-sync'))
.catch(() => {});
sync();
};
const handleOffline = () => {
// Don't mark offline while hidden to avoid false alerts on throttled timers/network
if (!isVisible) return;
setIsOnline(false);
setStatus('idle');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [sync, isVisible]);
// Heartbeat: navigator.onLine can lie (captive portals, flaky Wi-Fi).
// Periodically hit the server to verify real connectivity.
useEffect(() => {
let mounted = true;
const HEARTBEAT_INTERVAL_MS = 120_000; // 2 minutes — enough to detect real connectivity changes without constant polling
const tick = async () => {
if (!mounted || !isVisible) return;
const reachable = await checkServerReachable();
if (reachable && !isOnline) {
setIsOnline(true);
sync();
} else if (!reachable && isOnline) {
setIsOnline(false);
setStatus('idle');
}
};
const interval = setInterval(tick, HEARTBEAT_INTERVAL_MS);
return () => {
mounted = false;
clearInterval(interval);
};
}, [isOnline, isVisible, sync]);
// When the tab/PWA becomes visible, verify connectivity immediately
// so the user sees the actual state right away.
const wasVisibleRef = useRef(false);
useEffect(() => {
if (!isVisible) {
wasVisibleRef.current = false;
return;
}
if (wasVisibleRef.current) return;
wasVisibleRef.current = true;
checkServerReachable().then(reachable => {
if (reachable && !isOnline) {
setIsOnline(true);
sync();
} else if (!reachable && isOnline) {
setIsOnline(false);
setStatus('idle');
}
});
}, [isVisible, isOnline, sync]);
const triggerInitialSync = useCallback(async (formIds?: number[]) => {
setStatus('syncing');
try {
await performInitialSync(formIds);
setStatus('done');
setTimeout(() => setStatus('idle'), 3000);
} catch (err) {
console.error('[offlineSync] Initial sync failed:', err);
setStatus('error');
}
}, []);
// Listen for Background Sync trigger relayed by pwa.ts as a DOM event.
useEffect(() => {
const handleSwSync = () => sync();
window.addEventListener('offline-sw-sync', handleSwSync);
return () => window.removeEventListener('offline-sw-sync', handleSwSync);
}, [sync]);
// SSE: when a task is updated on the server, refresh the offline cache
// so users with offline sync enabled see the latest data immediately.
useEffect(() => {
const unsubscribe = sseManager.on('task_updated', () => {
if (navigator.onLine) {
sync().catch(() => {});
}
});
return unsubscribe;
}, [sync]);
// Custom DOM event: fired by apiRequest when a new item is enqueued.
useEffect(() => {
const handleQueueUpdated = () => refreshCount();
window.addEventListener('offline-queue-updated', handleQueueUpdated);
return () => window.removeEventListener('offline-queue-updated', handleQueueUpdated);
}, [refreshCount]);
// Attachment queue events
useEffect(() => {
const handleAttachmentQueueUpdated = () => {
refreshCount();
if (navigator.onLine) {
sync();
}
};
window.addEventListener('attachment-queue-updated', handleAttachmentQueueUpdated);
return () => window.removeEventListener('attachment-queue-updated', handleAttachmentQueueUpdated);
}, [refreshCount, sync]);
useEffect(() => {
const handleAttachmentSwSync = () => sync();
window.addEventListener('attachment-sw-sync', handleAttachmentSwSync);
return () => window.removeEventListener('attachment-sw-sync', handleAttachmentSwSync);
}, [sync]);
const isPending = useCallback((url: string) => pendingUrls.has(url), [pendingUrls]);
const getPendingBody = useCallback((url: string): Record<string, unknown> | null => {
const matching = pendingItems.filter(i => i.url === url && !i.conflictStatus);
if (matching.length === 0) return null;
const latest = matching.reduce((a, b) => a.enqueuedAt > b.enqueuedAt ? a : b);
if (!latest?.body) return null;
try { return JSON.parse(latest.body); } catch { return null; }
}, [pendingItems]);
return { isOnline, isVisible, pendingCount, pendingChatCount, pendingAttachmentCount, pendingUrls, pendingItems, pendingAttachments, isPending, getPendingBody, status, conflicts, attachmentStatus, attachmentConflicts, sync, resolveConflict, retryAttachment, discardAttachment, triggerInitialSync };
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { saveInbox, saveTask, saveFormData, saveDirectory, patchTaskCache, pruneOldTaskCache, getInbox, type InboxTaskSnapshot } from '@/lib/tasksCache';
const THROTTLE_MS = 5 * 60 * 1000; // 5 minutes between prefetch runs
const BATCH_SIZE = 5;
interface UserRecord {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
fullName?: string;
}
interface InboxApiResponse {
tasks: InboxTaskSnapshot[];
total?: number;
}
interface UsersApiResponse {
users?: UserRecord[];
}
interface FormApiResponse {
fields?: unknown[];
statuses?: unknown[];
transitions?: unknown[];
tabs?: unknown[];
}
interface FormFieldRecord {
type?: string;
options?: { tableId?: number };
}
function isFormField(v: unknown): v is FormFieldRecord {
return typeof v === 'object' && v !== null;
}
async function safeFetch(url: string): Promise<unknown> {
try {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
function isInboxResponse(v: unknown): v is InboxApiResponse {
return typeof v === 'object' && v !== null && Array.isArray((v as InboxApiResponse).tasks);
}
function isUsersResponse(v: unknown): v is UsersApiResponse {
return typeof v === 'object' && v !== null;
}
function isFormResponse(v: unknown): v is FormApiResponse {
return typeof v === 'object' && v !== null;
}
function hasFormId(t: unknown): t is InboxTaskSnapshot & { formId: number } {
return typeof t === 'object' && t !== null && typeof (t as Record<string, unknown>).formId === 'number';
}
export function usePrefetchOfflineTasks() {
const { user } = useAuth();
const runningRef = useRef(false);
useEffect(() => {
if (!user?.id) return;
const run = async () => {
if (runningRef.current) return;
if (!navigator.onLine) return;
// Persisted throttle: skip if inbox was cached recently (survives page reloads)
const existingInbox = await getInbox(user.id);
if (existingInbox && Date.now() - existingInbox.cachedAt < THROTTLE_MS) return;
runningRef.current = true;
try {
// 1. Fetch inbox
const inboxRaw = await safeFetch('/api/home/inbox?limit=100&offset=0');
if (!isInboxResponse(inboxRaw)) return;
const tasks = inboxRaw.tasks;
await saveInbox(user.id, tasks, inboxRaw.total ?? tasks.length);
// 2. Fetch users once (shared across forms)
const usersRaw = await safeFetch('/api/users/list');
const users: UserRecord[] = isUsersResponse(usersRaw) && Array.isArray(usersRaw.users)
? usersRaw.users
: [];
// 3. Fetch each task + form data + related tasks in batches
const formIdsFetched = new Set<number>();
for (let i = 0; i < tasks.length; i += BATCH_SIZE) {
if (!navigator.onLine) break;
const batch = tasks.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (task) => {
// Task detail
const detail = await safeFetch(`/api/tasks/${task.id}/detail`);
if (detail !== null) {
await saveTask(task.id, detail);
}
// Related tasks — stored as __related__ patch in task-cache for offline access
const related = await safeFetch(`/api/tasks/${task.id}/related`);
if (related !== null) {
await patchTaskCache(task.id, { __related__: related });
}
// Form data (fetch once per unique formId)
if (hasFormId(task) && !formIdsFetched.has(task.formId)) {
formIdsFetched.add(task.formId);
const [fieldsRaw, statusesRaw, transitionsRaw, tabsRaw] = await Promise.all([
safeFetch(`/api/forms/${task.formId}/fields`),
safeFetch(`/api/forms/${task.formId}/statuses`),
safeFetch(`/api/forms/${task.formId}/transitions`),
safeFetch(`/api/forms/${task.formId}/tabs`),
]);
const fr = isFormResponse(fieldsRaw) ? fieldsRaw : {};
const sr = isFormResponse(statusesRaw) ? statusesRaw : {};
const tr = isFormResponse(transitionsRaw) ? transitionsRaw : {};
const tabr = isFormResponse(tabsRaw) ? tabsRaw : {};
await saveFormData(task.formId, {
fields: (fr.fields ?? []) as unknown[],
statuses: (sr.statuses ?? []) as unknown[],
transitions: (tr.transitions ?? []) as unknown[],
tabs: (tabr.tabs ?? []) as unknown[],
users,
});
// Prefetch directory (справочник) data for table-type fields
const tableIdsToFetch = new Set<number>();
for (const f of (fr.fields ?? [])) {
if (isFormField(f) && f.options?.tableId) {
tableIdsToFetch.add(f.options.tableId);
}
}
await Promise.all(
[...tableIdsToFetch].map(async (tid) => {
const dirData = await safeFetch(`/api/directories/${tid}`);
if (dirData !== null) await saveDirectory(tid, dirData);
})
);
}
})
);
}
} catch {
// Prefetch errors are non-fatal
} finally {
runningRef.current = false;
}
};
// Prune task cache entries older than 7 days (safe, TTL-based, no cross-user effects)
if (navigator.onLine) {
pruneOldTaskCache().catch(() => {});
}
// Run on mount if online
run();
// Also run when coming back online
const handleOnline = () => run();
window.addEventListener('online', handleOnline);
return () => window.removeEventListener('online', handleOnline);
}, [user?.id]);
}

View File

@@ -0,0 +1,73 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
const BASE_SIZE = 16;
const MIN_SIZE = 12;
const MAX_SIZE = 24;
const STEP = 2;
const STORAGE_KEY_PREFIX = 'pwa-font-size-';
function getIsPwa(): boolean {
if (typeof window === 'undefined') return false;
const standalone = (window.navigator as any).standalone;
return window.matchMedia('(display-mode: standalone)').matches || standalone === true;
}
function storageKey(userId: number | string | undefined | null) {
if (!userId) return `${STORAGE_KEY_PREFIX}anonymous`;
return `${STORAGE_KEY_PREFIX}${userId}`;
}
export function usePwaFontSize(userId?: number | string | null) {
const isPwa = useMemo(() => getIsPwa(), []);
const [fontSize, setFontSize] = useState<number>(() => {
if (!isPwa || typeof window === 'undefined') return BASE_SIZE;
try {
const stored = localStorage.getItem(storageKey(userId));
if (stored) {
const n = parseInt(stored, 10);
if (!isNaN(n)) return Math.max(MIN_SIZE, Math.min(MAX_SIZE, n));
}
} catch {
/* ignore */
}
return BASE_SIZE;
});
useEffect(() => {
if (!isPwa || typeof document === 'undefined') return;
document.documentElement.style.fontSize = `${fontSize}px`;
}, [isPwa, fontSize]);
useEffect(() => {
if (!isPwa || typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(storageKey(userId));
if (stored) {
const n = parseInt(stored, 10);
if (!isNaN(n)) setFontSize(Math.max(MIN_SIZE, Math.min(MAX_SIZE, n)));
}
} catch {
/* ignore */
}
}, [isPwa, userId]);
useEffect(() => {
if (!isPwa || typeof window === 'undefined') return;
try {
localStorage.setItem(storageKey(userId), String(fontSize));
} catch {
/* ignore */
}
}, [isPwa, fontSize, userId]);
const increase = useCallback(() => {
setFontSize((prev) => Math.min(MAX_SIZE, prev + STEP));
}, []);
const decrease = useCallback(() => {
setFontSize((prev) => Math.max(MIN_SIZE, prev - STEP));
}, []);
return { isPwa, fontSize, increase, decrease };
}

View File

@@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
interface TableFieldInfo {
id: number;
type?: string;
options?: { tableId?: number; displayColumn?: string } | null;
}
/**
* Загружает справочники (data tables) для полей типа 'table' и возвращает
* map: tableId -> rowId -> displayValue.
* Использует staleTime: Infinity — справочники редко меняются.
*/
export function useTableMaps(fields: TableFieldInfo[]) {
const tableFields = useMemo(() => {
return fields.filter((f) => f.type === 'table' && f.options?.tableId);
}, [fields]);
const queries = useQueries({
queries: tableFields.map((field) => {
const tableId = field.options!.tableId!;
return {
queryKey: ['/api/directories', tableId],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}`);
return res.json() as Promise<{
table?: {
columns?: Array<{ name: string }>;
rows?: Array<{ id: number; values: string[] }>;
};
}>;
},
enabled: !!tableId,
staleTime: Infinity,
};
}),
});
const tableMap = useMemo(() => {
const map = new Map<number, Map<number, string>>();
for (let i = 0; i < tableFields.length; i++) {
const field = tableFields[i];
const tableId = field.options!.tableId!;
const data = queries[i]?.data;
if (!data?.table) continue;
const columns = data.table.columns || [];
const rows = data.table.rows || [];
const displayColumn = field.options?.displayColumn || columns[0]?.name;
const colIndex = columns.findIndex((c) => c.name === displayColumn);
const rowMap = new Map<number, string>();
for (const row of rows) {
const displayValue =
colIndex >= 0 ? row.values[colIndex] : row.values[0];
rowMap.set(row.id, displayValue || `#${row.id}`);
}
map.set(tableId, rowMap);
}
return map;
}, [tableFields, queries]);
const isLoading = queries.some((q) => q.isLoading);
return { tableMap, isLoading };
}

View File

@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getTask, saveTask, getFormData } from '@/lib/tasksCache';
import type { CachedFormData } from '@/lib/tasksCache';
import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext';
/**
* Minimum shape the generic T must satisfy so we can supplement
* task detail data from the form-cache without unsafe any-casts.
*/
export interface CachedTaskDetail {
form?: { id: number };
fields?: unknown[];
statuses?: unknown[];
transitions?: unknown[];
tabs?: unknown[];
users?: unknown[];
}
export interface UseTaskWithCacheResult<T> {
data: T | null;
isLoading: boolean;
isFromCache: boolean;
cacheTimestamp: number | null;
error: Error | null;
}
/**
* Thin wrapper around useQuery for task detail that:
* 1. Persists fetched data to IDB on every successful load.
* 2. Falls back to IDB (task-cache first, then form-cache) when offline.
* 3. Exposes isFromCache / cacheTimestamp for banner display.
*
* Internally owns the single useQuery for ['/api/tasks', taskId, 'detail'].
* TaskDetail.tsx must NOT create a separate useQuery on the same key.
*/
export function useTaskWithCache<T extends CachedTaskDetail>(
taskId: number | undefined,
): UseTaskWithCacheResult<T> {
const { isOnline } = useOfflineSyncContext();
const [cachedData, setCachedData] = useState<T | null>(null);
const [cacheTimestamp, setCacheTimestamp] = useState<number | null>(null);
const query = useQuery<T>({
queryKey: ['/api/tasks', taskId, 'detail'],
enabled: !!taskId,
staleTime: 30_000,
});
// Persist to IDB when live data loads.
// Merges __related__ from existing entry so background-prefetched related-task
// data is not overwritten by a regular live detail fetch.
useEffect(() => {
if (query.data && taskId) {
getTask(taskId)
.then(existing => {
const prevRelated = (existing?.data as Record<string, unknown> | undefined)?.__related__;
const merged = prevRelated
? { ...(query.data as Record<string, unknown>), __related__: prevRelated }
: query.data;
saveTask(taskId, merged).catch(() => {});
})
.catch(() => {
saveTask(taskId, query.data).catch(() => {});
});
}
}, [query.data, taskId]);
// Load from IDB when offline
useEffect(() => {
if (!isOnline && !query.data && taskId) {
getTask(taskId).then(async cached => {
if (cached) {
let data = cached.data as T;
// Supplement with form-cache data if fields/statuses/transitions are missing
const formId = data.form?.id;
if (formId) {
const formCached: CachedFormData | null = await getFormData(formId);
if (formCached) {
data = {
...data,
fields: data.fields?.length ? data.fields : formCached.fields,
statuses: data.statuses?.length ? data.statuses : formCached.statuses,
transitions: data.transitions?.length ? data.transitions : formCached.transitions,
tabs: data.tabs?.length ? data.tabs : formCached.tabs,
users: data.users?.length ? data.users : formCached.users,
};
}
}
setCachedData(data);
setCacheTimestamp(cached.cachedAt);
}
});
}
if (isOnline) {
setCachedData(null);
setCacheTimestamp(null);
}
}, [isOnline, query.data, taskId]);
const isFromCache = !isOnline && !query.data && cachedData !== null;
return {
data: isFromCache ? cachedData : (query.data ?? null),
isLoading: !isFromCache && query.isLoading,
isFromCache,
cacheTimestamp,
error: query.error as Error | null,
};
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react';
interface WebNotificationOptions {
title: string;
body: string;
icon?: string;
tag?: string;
}
export const useWebNotifications = () => {
const permissionRef = useRef<NotificationPermission>('default');
const isEnabledRef = useRef(false);
useEffect(() => {
// Проверяем поддержку браузера
if ('Notification' in window) {
permissionRef.current = Notification.permission;
isEnabledRef.current = Notification.permission === 'granted';
}
}, []);
const requestPermission = async (): Promise<boolean> => {
if (!('Notification' in window)) {
console.warn('Браузер не поддерживает Web Notifications API');
return false;
}
if (permissionRef.current === 'granted') {
return true;
}
try {
const permission = await Notification.requestPermission();
permissionRef.current = permission;
isEnabledRef.current = permission === 'granted';
return permission === 'granted';
} catch (error) {
console.error('Ошибка при запросе разрешения на уведомления:', error);
return false;
}
};
const showNotification = async (options: WebNotificationOptions): Promise<boolean> => {
if (!isEnabledRef.current) {
console.warn('Уведомления отключены или не поддерживаются');
return false;
}
try {
const notification = new Notification(options.title, {
body: options.body,
icon: options.icon || '/favicon.ico',
tag: options.tag,
silent: false,
requireInteraction: false
});
// Автоматически закрываем уведомление через 5 секунд
setTimeout(() => {
notification.close();
}, 5000);
return true;
} catch (error) {
console.error('Ошибка при показе уведомления:', error);
return false;
}
};
return {
isSupported: 'Notification' in window,
permission: permissionRef.current,
isEnabled: isEnabledRef.current,
requestPermission,
showNotification
};
};