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

66
mobile/README.md Normal file
View File

@@ -0,0 +1,66 @@
# iistwin — Android APK (Expo EAS)
Мобильное приложение для Android, собранное через Expo EAS облачный сервис.
Это WebView-обёртка над веб-версией — обновления функционала происходят автоматически при деплое.
**Production URL:** `https://iistwin.ru`
**Expo проект:** https://expo.dev/accounts/iisultanov/projects/iistwin
## Настройка EXPO_TOKEN
1. Открой https://expo.dev/settings/access-tokens
2. Нажми **Create token**, скопируй значение (~40 символов латиницы)
3. В Replit: левая панель → 🔒 **Secrets** → добавь `EXPO_TOKEN` = твой токен
## Сборка APK (одна команда)
```bash
cd mobile && npm run build
```
Команда автоматически:
1. Генерирует keystore и `credentials.json` если их нет
2. Запускает облачную сборку на серверах Expo (10-20 мин)
APK доступен после сборки: https://expo.dev/accounts/iisultanov/projects/iistwin/builds
## Установка APK на Android
1. Скачай `.apk` с expo.dev → проект → Builds
2. На Android: **Настройки → Безопасность → Установка из неизвестных источников**
3. Установи APK
## Настройка паролей подписи (опционально)
По умолчанию keystore генерируется с паролями по умолчанию.
Для кастомных паролей добавь секреты в Replit:
- `ANDROID_KEYSTORE_PASSWORD` — пароль keystore
- `ANDROID_KEY_PASSWORD` — пароль ключа
- `ANDROID_KEY_ALIAS` — алиас ключа (по умолчанию: iistwin)
## Обновление приложения
- **Изменения UI, форм, функционала** — автоматически при деплое, APK переустанавливать не нужно
- **Изменения нативного кода** (иконка, разрешения) — нужна новая сборка EAS
## Безопасность
- `mobile/credentials/` и `mobile/credentials.json` в `.gitignore` — не попадают в VCS
- Файлы создаются динамически через `scripts/setup-android-creds.sh`
- WebView доступен только для origin: `https://iistwin.ru`
- Внешние ссылки открываются в браузере устройства
## Структура
```
mobile/
├── App.js # WebView — только production origin, кнопка "назад"
├── app.json # projectId=18e993b2, owner=iisultanov
├── eas.json # preview → buildType: apk
├── credentials.json.example # Шаблон (не коммитится)
├── package.json # npm run build = setup + EAS
└── assets/icon.png
scripts/
├── setup-android-creds.sh # Генерирует keystore и credentials.json
└── eas-build.exp # Expect-скрипт для EAS CLI
```

35
mobile/app.json Normal file
View File

@@ -0,0 +1,35 @@
{
"expo": {
"name": "iistwin",
"slug": "iistwin",
"version": "1.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
"backgroundColor": "#3b82f6"
},
"android": {
"package": "com.iistwin.app",
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#3b82f6"
},
"permissions": [
"android.permission.POST_NOTIFICATIONS",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.WAKE_LOCK",
"android.permission.INTERNET"
]
},
"owner": "iisultanov",
"plugins": [
"./plugins/withPushy"
],
"extra": {
"eas": {
"projectId": "18e993b2-1a80-47b2-b101-1ce02636d705"
}
}
}
}

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

6
mobile/babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

View File

@@ -0,0 +1,10 @@
{
"android": {
"keystore": {
"keystorePath": "credentials/keystore.p12",
"keystorePassword": "YOUR_KEYSTORE_PASSWORD",
"keyAlias": "workflow",
"keyPassword": "YOUR_KEY_PASSWORD"
}
}
}

16
mobile/eas.json Normal file
View File

@@ -0,0 +1,16 @@
{
"cli": {
"version": ">= 3.0.0",
"appVersionSource": "local"
},
"build": {
"preview": {
"android": {
"buildType": "apk",
"credentialsSource": "local",
"image": "ubuntu-22.04-jdk-17-ndk-r21e"
},
"distribution": "internal"
}
}
}

23
mobile/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "iistwin-mobile",
"version": "1.1.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"setup-creds": "bash ../scripts/setup-android-creds.sh",
"build": "bash ../scripts/setup-android-creds.sh && EAS_NO_VCS=1 expect ../scripts/eas-build.exp"
},
"dependencies": {
"expo": "~51.0.0",
"expo-status-bar": "~1.12.1",
"pushy-react-native": "1.0.61",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-webview": "13.8.6"
},
"devDependencies": {
"@babel/core": "^7.20.0"
},
"private": true
}

View File

@@ -0,0 +1,78 @@
let withMainApplication, withAndroidManifest;
try {
const cp = require('@expo/config-plugins');
withMainApplication = cp.withMainApplication;
withAndroidManifest = cp.withAndroidManifest;
} catch {
// @expo/config-plugins not available locally (no mobile/node_modules).
// EAS cloud servers install it; just pass through the config unchanged here.
module.exports = function withPushy(config) { return config; };
return;
}
function withPushyMainApplication(config) {
return withMainApplication(config, (config) => {
const contents = config.modResults.contents;
const filePath = config.modResults.path || '';
const isKotlin = filePath.endsWith('.kt');
const importStatement = isKotlin
? 'import me.pushy.sdk.Pushy'
: 'import me.pushy.sdk.Pushy;';
let updated = contents;
// --- Import injection -----------------------------------------------
if (!updated.includes('me.pushy.sdk.Pushy')) {
// Match 'import android.app.Application' with or without semicolon
const importRegex = /(import android\.app\.Application[;]?)/;
if (importRegex.test(updated)) {
updated = updated.replace(importRegex, `$1\n${importStatement}`);
} else {
console.warn('[withPushy] Could not find import anchor in MainApplication; Pushy import not injected.');
}
}
// --- Pushy.listen() injection ----------------------------------------
if (!updated.includes('Pushy.listen(this)')) {
// Match 'super.onCreate()' or 'super.onCreate();' with leading whitespace.
// Use [ \t]* (not \s*) to avoid consuming the newline after the call,
// which would merge the next statement onto the same line and break Kotlin syntax.
const onCreateRegex = /([ \t]*super\.onCreate\(\)[ \t]*;?)/;
if (onCreateRegex.test(updated)) {
const callStatement = isKotlin
? '\n Pushy.listen(this)'
: '\n Pushy.listen(this);';
// Replace only the first occurrence (the one inside onCreate)
updated = updated.replace(onCreateRegex, `$1${callStatement}`);
} else {
console.warn('[withPushy] Could not find super.onCreate() in MainApplication; Pushy.listen not injected.');
}
}
config.modResults.contents = updated;
return config;
});
}
function withPushyManifest(config) {
return withAndroidManifest(config, (config) => {
const manifest = config.modResults.manifest;
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const perms = manifest['uses-permission'];
const wakeLock = 'android.permission.WAKE_LOCK';
if (!perms.some((p) => p.$?.['android:name'] === wakeLock)) {
perms.push({ $: { 'android:name': wakeLock } });
}
return config;
});
}
module.exports = function withPushy(config) {
config = withPushyMainApplication(config);
config = withPushyManifest(config);
return config;
};