Files
iistwin/client/src/hooks/useTaskWithCache.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

112 lines
3.7 KiB
TypeScript

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,
};
}