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

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

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

282
mobile/App.js Normal file
View File

@@ -0,0 +1,282 @@
import {
StyleSheet,
View,
BackHandler,
Linking,
StatusBar,
PermissionsAndroid,
Platform,
Alert,
} from 'react-native';
import { WebView } from 'react-native-webview';
import { useRef, useEffect, useState, useCallback } from 'react';
import Pushy from 'pushy-react-native';
const APP_URL = 'https://iistwin.ru';
const APP_ORIGIN = new URL(APP_URL).origin;
// Injected before every page load:
// 1. sessionStorage → localStorage so session survives app restarts.
// 2. Intercept setItem('accessToken') → login event for SPA login detection.
// 3. Intercept removeItem('accessToken') → logout event.
const BASE_INJECTED_JS = `
(function() {
try {
Object.defineProperty(window, 'sessionStorage', {
get: function() { return window.localStorage; },
configurable: true
});
} catch(e) {}
try {
var _origSet = localStorage.setItem.bind(localStorage);
var _origRemove = localStorage.removeItem.bind(localStorage);
localStorage.setItem = function(key, value) {
_origSet(key, value);
if (key === 'accessToken' && value && window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'login', token: value })
);
}
};
localStorage.removeItem = function(key) {
if (key === 'accessToken') {
var tok = localStorage.getItem('accessToken');
if (tok && window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'logout', token: tok })
);
}
}
return _origRemove(key);
};
} catch(e) {}
})();
true;
`;
async function requestNotificationPermission() {
if (Platform.OS !== 'android') return;
if (Platform.Version >= 33) {
await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
{
title: 'Разрешение на уведомления',
message: 'iistwin хочет отправлять вам уведомления о задачах и сообщениях.',
buttonPositive: 'Разрешить',
buttonNegative: 'Не сейчас',
}
);
}
}
export default function App() {
const webViewRef = useRef(null);
const [canGoBack, setCanGoBack] = useState(false);
const [pushyToken, setPushyToken] = useState(null);
// true once the WebView fires onLoadEnd at least once
const [webViewReady, setWebViewReady] = useState(false);
// Auth token received via login event before pushyToken was available (race guard)
const [pendingAuthToken, setPendingAuthToken] = useState(null);
// URL from notification tap while app was killed (cold-start)
const [pendingUrl, setPendingUrl] = useState(null);
// ── helpers ────────────────────────────────────────────────────────────────
// Register using an auth token passed explicitly (login event / replay)
const registerDevice = useCallback((deviceToken, authToken) => {
if (!deviceToken || !authToken || !webViewRef.current) return;
const dt = JSON.stringify(deviceToken);
const at = JSON.stringify(authToken);
webViewRef.current.injectJavaScript(`
(function() {
try {
fetch('/api/devices/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
body: JSON.stringify({ token: ${dt}, platform: 'android' })
}).catch(function() {});
} catch(e) {}
})();
true;
`);
}, []);
// Register by reading accessToken from localStorage (existing sessions / page reload)
const registerFromStorage = useCallback((deviceToken) => {
if (!deviceToken || !webViewRef.current) return;
const dt = JSON.stringify(deviceToken);
webViewRef.current.injectJavaScript(`
(function() {
try {
var at = localStorage.getItem('accessToken');
if (at) {
fetch('/api/devices/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + at },
body: JSON.stringify({ token: ${dt}, platform: 'android' })
}).catch(function() {});
}
} catch(e) {}
})();
true;
`);
}, []);
const unregisterDevice = useCallback((deviceToken, authToken) => {
if (!deviceToken || !authToken || !webViewRef.current) return;
const dt = JSON.stringify(deviceToken);
const at = JSON.stringify(authToken);
webViewRef.current.injectJavaScript(`
(function() {
try {
fetch('/api/devices/unregister', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + ${at} },
body: JSON.stringify({ token: ${dt} })
}).catch(function() {});
} catch(e) {}
})();
true;
`);
}, []);
const navigateTo = useCallback((path) => {
if (!path || !webViewRef.current) return;
const url = path.startsWith('http') ? path : APP_URL + path;
webViewRef.current.injectJavaScript(
`window.location.href = ${JSON.stringify(url)}; true;`
);
}, []);
// ── Coordination effect: fires whenever pushyToken or webViewReady changes ──
// Ensures registration always happens once BOTH sides are ready,
// regardless of which arrives first.
useEffect(() => {
if (!pushyToken || !webViewReady) return;
if (pendingAuthToken) {
// Login event arrived before pushyToken – replay it now
registerDevice(pushyToken, pendingAuthToken);
setPendingAuthToken(null);
} else {
// Normal start: read token from existing session in localStorage
registerFromStorage(pushyToken);
}
}, [pushyToken, webViewReady]); // eslint-disable-line react-hooks/exhaustive-deps
// ── One-time effect: Pushy setup ─────────────────────────────────────────
useEffect(() => {
requestNotificationPermission();
Pushy.register()
.then((token) => {
console.log('Pushy device token:', token);
setPushyToken(token);
})
.catch((err) => {
console.error('Pushy registration error:', err.message);
});
Pushy.setNotificationHandler((data, callback) => {
const title = data.title || 'iistwin';
const body = data.body || '';
if (body) {
Alert.alert(title, body, [
{ text: 'Открыть', onPress: () => { if (data.url) navigateTo(data.url); } },
{ text: 'Закрыть', style: 'cancel' },
]);
}
callback();
});
Pushy.setNotificationClickListener((data) => {
if (data.url) {
if (webViewRef.current) {
navigateTo(data.url);
} else {
setPendingUrl(data.url);
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Back button ───────────────────────────────────────────────────────────
useEffect(() => {
const onBackPress = () => {
if (canGoBack && webViewRef.current) {
webViewRef.current.goBack();
return true;
}
return false;
};
const sub = BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => sub.remove();
}, [canGoBack]);
// ── WebView callbacks ─────────────────────────────────────────────────────
const handleLoadEnd = () => {
setWebViewReady(true); // triggers coordination effect above
if (pendingUrl) {
navigateTo(pendingUrl);
setPendingUrl(null);
}
};
const handleMessage = useCallback((event) => {
try {
const msg = JSON.parse(event.nativeEvent.data);
if (msg.type === 'login' && msg.token) {
if (pushyToken) {
// Both pieces ready — register immediately
registerDevice(pushyToken, msg.token);
} else {
// pushyToken not yet available — cache for coordination effect
setPendingAuthToken(msg.token);
}
} else if (msg.type === 'logout' && msg.token && pushyToken) {
unregisterDevice(pushyToken, msg.token);
}
} catch (e) {
// ignore malformed messages
}
}, [pushyToken, registerDevice, unregisterDevice]);
return (
<View style={styles.container}>
<StatusBar backgroundColor="#3b82f6" barStyle="light-content" translucent={false} />
<WebView
ref={webViewRef}
source={{ uri: APP_URL }}
style={styles.webview}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
sharedCookiesEnabled={true}
thirdPartyCookiesEnabled={true}
injectedJavaScriptBeforeContentLoaded={BASE_INJECTED_JS}
onLoadEnd={handleLoadEnd}
onMessage={handleMessage}
onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
onPermissionRequest={(request) => { request.grant(request.resources); }}
onShouldStartLoadWithRequest={(request) => {
try {
if (new URL(request.url).origin === APP_ORIGIN) return true;
} catch {}
Linking.openURL(request.url);
return false;
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#3b82f6' },
webview: { flex: 1 },
});