fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
19
client/src/hooks/use-mobile.tsx
Normal file
19
client/src/hooks/use-mobile.tsx
Normal 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
|
||||
}
|
||||
54
client/src/hooks/use-theme.tsx
Normal file
54
client/src/hooks/use-theme.tsx
Normal 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;
|
||||
}
|
||||
191
client/src/hooks/use-toast.ts
Normal file
191
client/src/hooks/use-toast.ts
Normal 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
218
client/src/hooks/useAuth.ts
Normal 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 };
|
||||
334
client/src/hooks/useBookmarks.ts
Normal file
334
client/src/hooks/useBookmarks.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
67
client/src/hooks/useCalendarTasks.ts
Normal file
67
client/src/hooks/useCalendarTasks.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
170
client/src/hooks/useColumnFilters.ts
Normal file
170
client/src/hooks/useColumnFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
client/src/hooks/useColumnLabels.ts
Normal file
19
client/src/hooks/useColumnLabels.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
51
client/src/hooks/useDashboard.ts
Normal file
51
client/src/hooks/useDashboard.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
12
client/src/hooks/useDebounce.ts
Normal file
12
client/src/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
563
client/src/hooks/useDocuments.ts
Normal file
563
client/src/hooks/useDocuments.ts
Normal 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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
63
client/src/hooks/useEvents.ts
Normal file
63
client/src/hooks/useEvents.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
1001
client/src/hooks/useFinance.ts
Normal file
1001
client/src/hooks/useFinance.ts
Normal file
File diff suppressed because it is too large
Load Diff
127
client/src/hooks/useFolders.ts
Normal file
127
client/src/hooks/useFolders.ts
Normal 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']);
|
||||
196
client/src/hooks/useGanttTasks.ts
Normal file
196
client/src/hooks/useGanttTasks.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
23
client/src/hooks/useInboxCount.ts
Normal file
23
client/src/hooks/useInboxCount.ts
Normal 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;
|
||||
}
|
||||
83
client/src/hooks/useInboxWithCache.ts
Normal file
83
client/src/hooks/useInboxWithCache.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
117
client/src/hooks/useNotifications.ts
Normal file
117
client/src/hooks/useNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
818
client/src/hooks/useOfflineSync.ts
Normal file
818
client/src/hooks/useOfflineSync.ts
Normal 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 };
|
||||
}
|
||||
179
client/src/hooks/usePrefetchOfflineTasks.ts
Normal file
179
client/src/hooks/usePrefetchOfflineTasks.ts
Normal 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]);
|
||||
}
|
||||
73
client/src/hooks/usePwaFontSize.ts
Normal file
73
client/src/hooks/usePwaFontSize.ts
Normal 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 };
|
||||
}
|
||||
68
client/src/hooks/useTableMaps.ts
Normal file
68
client/src/hooks/useTableMaps.ts
Normal 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 };
|
||||
}
|
||||
111
client/src/hooks/useTaskWithCache.ts
Normal file
111
client/src/hooks/useTaskWithCache.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
77
client/src/hooks/useWebNotifications.ts
Normal file
77
client/src/hooks/useWebNotifications.ts
Normal 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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user