From f052486e716c4be6086be098dbe46e43a11c6d2e Mon Sep 17 00:00:00 2001 From: tolya Date: Tue, 18 Aug 2026 13:19:05 +0300 Subject: [PATCH] weather&bottom --- admin-frontend/package-lock.json | 106 ++- admin-frontend/package.json | 4 +- admin-server1/logs/admin.log | 72 ++ admin-server1/package-lock.json | 69 +- backend/config.json | 6 +- backend/package-lock.json | 210 +++-- backend/package.json | 5 +- backend/rates.xlsx | Bin 0 -> 10404 bytes backend/src/server.js | 486 +++++++++--- frontend/src/App.tsx | 30 +- frontend/src/components/BottomBar.tsx | 831 ++++++++------------ frontend/src/components/Contacts.tsx | 912 ++++++++++++++++------ frontend/src/components/MenuComponent.tsx | 76 +- frontend/src/components/Profile.tsx | 25 +- frontend/src/components/WeatherWidget.tsx | 395 ++++++++++ frontend/tsconfig.tsbuildinfo | 2 +- 16 files changed, 2240 insertions(+), 989 deletions(-) create mode 100644 backend/rates.xlsx create mode 100644 frontend/src/components/WeatherWidget.tsx diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index fa82fe3..088d088 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -15,7 +15,8 @@ "dompurify": "^3.4.13", "quill": "^2.0.3", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.5", @@ -441,6 +442,15 @@ } } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -471,6 +481,40 @@ "dequal": "^2.0.3" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -541,6 +585,15 @@ } } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1074,6 +1127,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -1180,6 +1245,45 @@ "optional": true } } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } } } } diff --git a/admin-frontend/package.json b/admin-frontend/package.json index 01f4a8c..8de0b83 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -10,9 +10,11 @@ "dompurify": "^3.4.13", "quill": "^2.0.3", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "xlsx": "^0.18.5" }, "scripts": { + "rebuild": "rm -rf dist && npm run build", "start": "vite", "build": "vite build", "preview": "vite preview" diff --git a/admin-server1/logs/admin.log b/admin-server1/logs/admin.log index e39ff77..ba3aff4 100644 --- a/admin-server1/logs/admin.log +++ b/admin-server1/logs/admin.log @@ -1751,3 +1751,75 @@ [2026-08-10T20:18:33.113Z] [INFO] Card logs initialized successfully [2026-08-10T20:24:20.748Z] [INFO] Server running on http://localhost:3005 [2026-08-10T20:24:20.752Z] [INFO] Card logs initialized successfully +[2026-08-14T13:24:44.818Z] [INFO] Server running on http://localhost:3005 +[2026-08-14T13:24:44.821Z] [INFO] Card logs initialized successfully +[2026-08-14T13:27:10.675Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-14T13:27:10.675Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-14T13:27:10.687Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-14T13:27:10.688Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-14T13:27:10.760Z] [INFO] [LOGIN] Результат сравнения пароля: true +[2026-08-14T13:27:10.761Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"} +[2026-08-14T13:27:10.763Z] [INFO] [LOGIN] Ответ отправлен +[2026-08-14T13:29:02.161Z] [INFO] Server running on http://localhost:3005 +[2026-08-14T13:29:02.164Z] [INFO] Card logs initialized successfully +[2026-08-14T13:29:12.245Z] [INFO] [LOGIN] Попытка входа: vova +[2026-08-14T13:29:12.245Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-14T13:29:12.247Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-14T13:29:12.248Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-14T13:29:12.326Z] [INFO] [LOGIN] Результат сравнения пароля: false +[2026-08-14T13:29:12.327Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova +[2026-08-14T13:29:17.059Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-14T13:29:17.060Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-14T13:29:17.062Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-14T13:29:17.063Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-14T13:29:17.132Z] [INFO] [LOGIN] Результат сравнения пароля: true +[2026-08-14T13:29:17.133Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"} +[2026-08-14T13:29:17.134Z] [INFO] [LOGIN] Ответ отправлен +[2026-08-14T13:31:55.307Z] [INFO] Server running on http://localhost:3005 +[2026-08-14T13:31:55.311Z] [INFO] Card logs initialized successfully +[2026-08-14T13:32:01.120Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-14T13:32:01.121Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-14T13:32:01.130Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-14T13:32:01.131Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-14T13:32:01.211Z] [INFO] [LOGIN] Результат сравнения пароля: true +[2026-08-14T13:32:01.212Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"} +[2026-08-14T13:32:01.213Z] [INFO] [LOGIN] Ответ отправлен +[2026-08-14T13:32:24.329Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-14T13:32:24.329Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-14T13:32:24.332Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-14T13:32:24.332Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-14T13:32:24.403Z] [INFO] [LOGIN] Результат сравнения пароля: true +[2026-08-14T13:32:24.403Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"} +[2026-08-14T13:32:24.404Z] [INFO] [LOGIN] Ответ отправлен +[2026-08-14T13:38:25.063Z] [INFO] Server running on http://localhost:3005 +[2026-08-14T13:38:25.067Z] [INFO] Card logs initialized successfully +[2026-08-16T01:27:22.968Z] [INFO] Server running on http://localhost:3005 +[2026-08-16T01:27:22.971Z] [INFO] Card logs initialized successfully +[2026-08-16T01:28:31.640Z] [INFO] [DB CHECK] Проверка подключения к MySQL: localhost:3306/rasch_content +[2026-08-16T01:28:31.649Z] [INFO] [DB CHECK] ✅ Успешное подключение к MySQL +[2026-08-16T01:28:40.817Z] [WARN] Неверный пароль +[2026-08-16T02:02:39.663Z] [INFO] Server running on http://localhost:3005 +[2026-08-16T02:02:39.665Z] [INFO] Card logs initialized successfully +[2026-08-17T03:07:53.436Z] [INFO] Server running on http://localhost:3005 +[2026-08-17T03:07:53.439Z] [INFO] Card logs initialized successfully +[2026-08-17T03:45:34.461Z] [INFO] Server running on http://localhost:3005 +[2026-08-17T03:45:34.464Z] [INFO] Card logs initialized successfully +[2026-08-17T03:45:42.164Z] [INFO] [LOGIN] Попытка входа: vova +[2026-08-17T03:45:42.164Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-17T03:45:42.182Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-17T03:45:42.183Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-17T03:45:42.264Z] [INFO] [LOGIN] Результат сравнения пароля: false +[2026-08-17T03:45:42.265Z] [WARN] [LOGIN] Неверный пароль для пользователя: vova +[2026-08-17T03:45:46.561Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-17T03:45:46.562Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-17T03:45:46.564Z] [INFO] [LOGIN] Результат поиска пользователя: найден +[2026-08-17T03:45:46.565Z] [INFO] [LOGIN] Сравнение пароля через bcrypt... +[2026-08-17T03:45:46.642Z] [INFO] [LOGIN] Результат сравнения пароля: true +[2026-08-17T03:45:46.643Z] [INFO] [LOGIN] Сессия установлена: {"isModerator":true,"userId":3,"username":"grivova"} +[2026-08-17T03:45:46.647Z] [INFO] [LOGIN] Ответ отправлен +[2026-08-17T13:53:20.830Z] [INFO] [LOGIN] Попытка входа: grivova +[2026-08-17T13:53:20.845Z] [INFO] [LOGIN] Проверка подключения к базе... +[2026-08-17T13:53:20.849Z] [INFO] [LOGIN] Результат поиска пользователя: не найден +[2026-08-17T13:53:20.849Z] [WARN] [LOGIN] Пользователь не найден: grivova +[2026-08-18T10:10:33.384Z] [INFO] Server running on http://localhost:3005 +[2026-08-18T10:10:33.394Z] [INFO] Card logs initialized successfully diff --git a/admin-server1/package-lock.json b/admin-server1/package-lock.json index f9c6c04..ff30023 100644 --- a/admin-server1/package-lock.json +++ b/admin-server1/package-lock.json @@ -56,6 +56,18 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -141,9 +153,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -534,15 +546,6 @@ "node": ">=0.10.0" } }, - "node_modules/express-mysql-session/node_modules/lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", - "license": "ISC", - "engines": { - "node": ">=16.14" - } - }, "node_modules/express-mysql-session/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -884,18 +887,6 @@ "node": ">= 6" } }, - "node_modules/https-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -919,9 +910,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -973,6 +964,15 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", @@ -1151,15 +1151,14 @@ } }, "node_modules/mysql2": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", - "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "version": "3.23.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.3.tgz", + "integrity": "sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", - "denque": "^2.1.0", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", @@ -1635,9 +1634,9 @@ } }, "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" diff --git a/backend/config.json b/backend/config.json index bbc3d97..06c647b 100644 --- a/backend/config.json +++ b/backend/config.json @@ -18,7 +18,7 @@ "category": "Высшая", "departmentName": "Технический отдел", "departmentCode": "101", - "organization": "ООО ТехноЦентр", + "organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»", "identNumber": "123456789" }, { @@ -32,7 +32,7 @@ "category": "1", "departmentName": "Бухгалтерия", "departmentCode": "202", - "organization": "ООО ТехноЦентр", + "organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»", "identNumber": "987654321" }, { @@ -46,7 +46,7 @@ "category": "2", "departmentName": "IT-отдел", "departmentCode": "303", - "organization": "ООО ТехноЦентр", + "organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»", "identNumber": "555555555" } ], diff --git a/backend/package-lock.json b/backend/package-lock.json index 5c0c20d..ef9affc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,10 +17,11 @@ "mammoth": "^1.9.1", "mysql2": "^3.14.2", "node-cron": "^4.2.1", - "winston": "^3.17.0" + "winston": "^3.17.0", + "xlsx": "^0.18.5" }, "devDependencies": { - "nodemon": "^2.0.22" + "nodemon": "^3.1.14" } }, "node_modules/@colors/colors": { @@ -91,6 +92,15 @@ "node": ">= 0.6" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -130,11 +140,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -213,14 +226,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -274,6 +289,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -299,6 +327,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -345,13 +382,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -415,6 +445,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -674,6 +716,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -1133,16 +1184,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -1204,19 +1258,19 @@ } }, "node_modules/nodemon": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", - "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", - "debug": "^3.2.7", + "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" @@ -1225,23 +1279,13 @@ "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/nodemon" } }, - "node_modules/nodemon/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1492,13 +1536,16 @@ "license": "MIT" }, "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { - "semver": "bin/semver" + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -1631,26 +1678,16 @@ } }, "node_modules/simple-update-notifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", - "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "dev": true, "license": "MIT", "dependencies": { - "semver": "~7.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=10" } }, "node_modules/sprintf-js": { @@ -1674,6 +1711,18 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -1900,12 +1949,51 @@ "node": ">= 6" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index ba974dc..c3621fd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,9 +20,10 @@ "mammoth": "^1.9.1", "mysql2": "^3.14.2", "node-cron": "^4.2.1", - "winston": "^3.17.0" + "winston": "^3.17.0", + "xlsx": "^0.18.5" }, "devDependencies": { - "nodemon": "^2.0.22" + "nodemon": "^3.1.14" } } diff --git a/backend/rates.xlsx b/backend/rates.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b804e1b52f6ad2f50da9fc504b93254b0a36f29e GIT binary patch literal 10404 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 literal 0 HcmV?d00001 diff --git a/backend/src/server.js b/backend/src/server.js index d754594..f873f4f 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,27 +1,34 @@ const express = require('express'); const cors = require('cors'); const path = require('path'); -const fs = require('fs').promises; +const fs = require('fs'); +const fsPromises = require('fs').promises; const { connectDB } = require('./config/db'); const { logger } = require('./config/logger'); const handbookRoutes = require('./routes/handbook'); const employeeRoutes = require('./routes/employee'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); +const XLSX = require('xlsx'); require('dotenv').config({ path: path.join(__dirname, '../config.env') }); + const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }); +// ============================================ +// 📚 ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +// ============================================ + async function readJson(filePath) { - const data = await fs.readFile(filePath, 'utf8'); + const data = await fsPromises.readFile(filePath, 'utf8'); return JSON.parse(data); } async function pathExists(filePath) { try { - await fs.access(filePath); + await fsPromises.access(filePath); return true; } catch { return false; @@ -38,33 +45,34 @@ async function connectToNetworkShare(config) { const username = config.networkCredentials.username; const password = config.networkCredentials.password; const domain = config.networkCredentials.domain; - const netUseCommand = `net use \\${payslipsPath} ${password} /user:${domain ? domain + '\\' : ''}${username}`; + const netUseCommand = 'net use \\\\' + payslipsPath + ' ' + password + ' /user:' + (domain ? domain + '\\' : '') + username; - logger.info(`Attempting to connect to network share with credentials`); - const { stdout, stderr } = await execAsync(netUseCommand); + logger.info('Attempting to connect to network share with credentials'); + const result = await execAsync(netUseCommand); - if (stderr) { - logger.warn(`Network share connection warning: ${stderr}`); + if (result.stderr) { + logger.warn('Network share connection warning: ' + result.stderr); } - logger.info(`Network share connection result: ${stdout}`); + logger.info('Network share connection result: ' + result.stdout); return true; } catch (error) { logger.error('Failed to connect to network share', { error: error.message }); return false; } } - return true; + return true; } // ============================================ -// 🚀 Инициализация Express +// 🚀 ИНИЦИАЛИЗАЦИЯ EXPRESS // ============================================ const app = express(); app.use(cors()); app.use(express.json()); -app.use((req, res, next) => { + +app.use(function(req, res, next) { if (req.method === 'POST' || req.method === 'PUT') { if (!req.is('application/json')) { return res.status(415).json({ message: 'Only JSON allowed' }); @@ -73,47 +81,292 @@ app.use((req, res, next) => { next(); }); -// ============================================ -// 📍 Маршруты API -// ============================================ app.use(helmet()); app.use('/api/', limiter); + +// ============================================ +// 📍 МАРШРУТЫ API +// ============================================ + app.use('/api/handbook', handbookRoutes); app.use('/api/employee', employeeRoutes); // ============================================ -// 🧪 Тестовые эндпоинты +// 🧪 ТЕСТОВЫЙ ЭНДПОИНТ // ============================================ -app.get('/api/test', (req, res) => { +app.get('/api/test', function(req, res) { res.json({ message: 'API работает' }); }); // ============================================ -// 📊 Эндпоинт для получения статуса системы +// 📊 КУРСЫ ВАЛЮТ ИЗ EXCEL // ============================================ -app.get('/api/system-status', async (req, res) => { +var EXCEL_PATH = path.join(__dirname, '../rates.xlsx'); + +async function createExcelTemplate() { + var wb = XLSX.utils.book_new(); + + var currencyData = [ + ['currency', 'rate', 'change'], + ['USD', 3.22, 0.5], + ['EUR', 3.45, -0.2], + ['CNY', 0.44, 0.1], + ['RUB', 0.035, -0.3] + ]; + var currencySheet = XLSX.utils.aoa_to_sheet(currencyData); + XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency'); + + var metalsData = [ + ['metal', 'price', 'change'], + ['Золото', 1950, 0.8], + ['Серебро', 23.5, -0.5], + ['Платина', 920, 0.3], + ['Палладий', 1450, -0.7] + ]; + var metalsSheet = XLSX.utils.aoa_to_sheet(metalsData); + XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals'); + + XLSX.writeFile(wb, EXCEL_PATH); + logger.info('Excel-шаблон создан'); +} + +function getRatesFromExcel() { try { - const configPath = path.join(__dirname, '../config.json'); - const config = await readJson(configPath); - const { getDBStatus } = require('./config/db'); - const dbStatus = await getDBStatus(); + if (!fs.existsSync(EXCEL_PATH)) { + return null; + } + var workbook = XLSX.readFile(EXCEL_PATH); - let networkAccess = false; - let networkError = null; - let networkContents = []; + var currencySheet = workbook.Sheets['Currency']; + var currency = []; + if (currencySheet) { + var data = XLSX.utils.sheet_to_json(currencySheet); + for (var i = 0; i < data.length; i++) { + var row = data[i]; + currency.push({ + currency: String(row.currency || row.Currency || '').toUpperCase(), + rate: parseFloat(row.rate || row.Rate || 0), + change: parseFloat(row.change || row.Change || 0) + }); + } + } + + var metalsSheet = workbook.Sheets['Metals']; + var metals = []; + if (metalsSheet) { + var data = XLSX.utils.sheet_to_json(metalsSheet); + for (var i = 0; i < data.length; i++) { + var row = data[i]; + metals.push({ + metal: String(row.metal || row.Metal || row.name || row.Name || ''), + price: parseFloat(row.price || row.Price || 0), + change: parseFloat(row.change || row.Change || 0) + }); + } + } + + return { currency: currency, metals: metals }; + } catch (error) { + logger.error('Ошибка чтения Excel:', error); + return null; + } +} + +// ============================================ +// 📊 GET /api/rates +// ============================================ + +app.get('/api/rates', async function(req, res) { + try { + var fileExists = await pathExists(EXCEL_PATH); + if (!fileExists) { + await createExcelTemplate(); + } + + var data = getRatesFromExcel(); + + if (data && data.currency.length > 0 && data.metals.length > 0) { + res.json({ + currency: data.currency, + metals: data.metals, + source: 'excel', + updated: new Date().toISOString() + }); + } else { + res.json({ + currency: [ + { currency: 'USD', rate: 3.22, change: 0.5 }, + { currency: 'EUR', rate: 3.45, change: -0.2 }, + { currency: 'CNY', rate: 0.44, change: 0.1 }, + { currency: 'RUB', rate: 0.035, change: -0.3 } + ], + metals: [ + { metal: 'Золото', price: 1950, change: 0.8 }, + { metal: 'Серебро', price: 23.5, change: -0.5 }, + { metal: 'Платина', price: 920, change: 0.3 }, + { metal: 'Палладий', price: 1450, change: -0.7 } + ], + source: 'default' + }); + } + } catch (err) { + logger.error('Ошибка получения курсов:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// 🔄 POST /api/rates +// ============================================ + +app.post('/api/rates', async function(req, res) { + try { + var currency = req.body.currency; + var metals = req.body.metals; + + var wb = XLSX.utils.book_new(); + + if (currency && currency.length > 0) { + var currencyData = [ + ['currency', 'rate', 'change'] + ]; + for (var i = 0; i < currency.length; i++) { + currencyData.push([currency[i].currency, currency[i].rate, currency[i].change]); + } + var currencySheet = XLSX.utils.aoa_to_sheet(currencyData); + XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency'); + } + + if (metals && metals.length > 0) { + var metalsData = [ + ['metal', 'price', 'change'] + ]; + for (var i = 0; i < metals.length; i++) { + metalsData.push([metals[i].metal, metals[i].price, metals[i].change]); + } + var metalsSheet = XLSX.utils.aoa_to_sheet(metalsData); + XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals'); + } + + XLSX.writeFile(wb, EXCEL_PATH); + logger.info('Excel-файл обновлён'); + + res.json({ + success: true, + message: 'Курсы обновлены в Excel', + path: EXCEL_PATH + }); + + } catch (err) { + logger.error('Ошибка при обновлении Excel:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// 🔄 POST /api/rates/update-from-nbrb +// ============================================ + +app.post('/api/rates/update-from-nbrb', async function(req, res) { + try { + var currencyResponse = await fetch('https://api.nbrb.by/exrates/rates?periodicity=0'); + var currencyData = await currencyResponse.json(); + + var metalResponse = await fetch('https://api.nbrb.by/ingots/prices'); + var metalData = await metalResponse.json(); + + var currency = [ + { currency: 'USD', rate: 0, change: 0 }, + { currency: 'EUR', rate: 0, change: 0 }, + { currency: 'CNY', rate: 0, change: 0 }, + { currency: 'RUB', rate: 0, change: 0 } + ]; + + for (var i = 0; i < currencyData.length; i++) { + var c = currencyData[i]; + if (c.Cur_Abbreviation === 'USD') currency[0].rate = c.Cur_OfficialRate || 0; + if (c.Cur_Abbreviation === 'EUR') currency[1].rate = c.Cur_OfficialRate || 0; + if (c.Cur_Abbreviation === 'CNY') currency[2].rate = c.Cur_OfficialRate || 0; + if (c.Cur_Abbreviation === 'RUB') currency[3].rate = c.Cur_OfficialRate || 0; + } + + var metals = [ + { metal: 'Золото', price: 0, change: 0 }, + { metal: 'Серебро', price: 0, change: 0 }, + { metal: 'Платина', price: 0, change: 0 }, + { metal: 'Палладий', price: 0, change: 0 } + ]; + + for (var i = 0; i < metalData.length; i++) { + var m = metalData[i]; + if (m.MetalId === 0) metals[0].price = m.EntitiesDollars || 0; + if (m.MetalId === 1) metals[1].price = m.EntitiesDollars || 0; + if (m.MetalId === 2) metals[2].price = m.EntitiesDollars || 0; + if (m.MetalId === 3) metals[3].price = m.EntitiesDollars || 0; + } + + var wb = XLSX.utils.book_new(); + + var currencySheetData = [ + ['currency', 'rate', 'change'] + ]; + for (var i = 0; i < currency.length; i++) { + currencySheetData.push([currency[i].currency, currency[i].rate, currency[i].change]); + } + var currencySheet = XLSX.utils.aoa_to_sheet(currencySheetData); + XLSX.utils.book_append_sheet(wb, currencySheet, 'Currency'); + + var metalsSheetData = [ + ['metal', 'price', 'change'] + ]; + for (var i = 0; i < metals.length; i++) { + metalsSheetData.push([metals[i].metal, metals[i].price, metals[i].change]); + } + var metalsSheet = XLSX.utils.aoa_to_sheet(metalsSheetData); + XLSX.utils.book_append_sheet(wb, metalsSheet, 'Metals'); + + XLSX.writeFile(wb, EXCEL_PATH); + logger.info('Курсы обновлены из НБРБ'); + + res.json({ + success: true, + message: 'Курсы обновлены из НБРБ', + data: { currency: currency, metals: metals } + }); + + } catch (err) { + logger.error('Ошибка при обновлении курсов из НБРБ:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// 🗄️ ДРУГИЕ ЭНДПОИНТЫ +// ============================================ + +app.get('/api/system-status', async function(req, res) { + try { + var configPath = path.join(__dirname, '../config.json'); + var config = await readJson(configPath); + var db = require('./config/db'); + var dbStatus = await db.getDBStatus(); + + var networkAccess = false; + var networkError = null; + var networkContents = []; try { networkAccess = await pathExists(config.payslipsPath); if (networkAccess) { - const stats = await fs.stat(config.payslipsPath); + var stats = await fsPromises.stat(config.payslipsPath); networkAccess = stats.isDirectory(); if (networkAccess) { - networkContents = await fs.readdir(config.payslipsPath); + networkContents = await fsPromises.readdir(config.payslipsPath); if (networkContents.length > 10) { networkContents = networkContents.slice(0, 10); - networkContents.push(`... и еще ${networkContents.length - 10} элементов`); + networkContents.push('... и еще ' + (networkContents.length - 10) + ' элементов'); } } } @@ -122,7 +375,7 @@ app.get('/api/system-status', async (req, res) => { networkError = err.message; } - const serverInfo = { + var serverInfo = { uptime: process.uptime(), memory: process.memoryUsage(), nodeVersion: process.version, @@ -145,8 +398,8 @@ app.get('/api/system-status', async (req, res) => { }, config: { testMode: config.testMode || false, - serverHost: config.server?.host || 'localhost', - serverPort: config.server?.port || '3001' + serverHost: config.server ? config.server.host : 'localhost', + serverPort: config.server ? config.server.port : '3001' } }); @@ -160,22 +413,18 @@ app.get('/api/system-status', async (req, res) => { } }); -// ============================================ -// 🌐 Тест сетевой папки -// ============================================ - -app.get('/api/test-network', async (req, res) => { +app.get('/api/test-network', async function(req, res) { try { - const configPath = path.join(__dirname, '../config.json'); - const config = await readJson(configPath); - const payslipBasePath = config.payslipsPath; + var configPath = path.join(__dirname, '../config.json'); + var config = await readJson(configPath); + var payslipBasePath = config.payslipsPath; - logger.info(`Testing network connection to: ${payslipBasePath}`); + logger.info('Testing network connection to: ' + payslipBasePath); - const networkConnected = await connectToNetworkShare(config); - logger.info(`Network share connection successful: ${networkConnected}`); + var networkConnected = await connectToNetworkShare(config); + logger.info('Network share connection successful: ' + networkConnected); - const testResults = { + var testResults = { basePath: payslipBasePath, basePathExists: false, basePathAccessible: false, @@ -186,16 +435,16 @@ app.get('/api/test-network', async (req, res) => { try { testResults.basePathExists = await pathExists(payslipBasePath); - logger.info(`Base path exists: ${testResults.basePathExists}`); + logger.info('Base path exists: ' + testResults.basePathExists); if (testResults.basePathExists) { - const stats = await fs.stat(payslipBasePath); + var stats = await fsPromises.stat(payslipBasePath); testResults.basePathAccessible = stats.isDirectory(); - logger.info(`Base path is directory: ${testResults.basePathAccessible}`); + logger.info('Base path is directory: ' + testResults.basePathAccessible); if (testResults.basePathAccessible) { - testResults.basePathContents = await fs.readdir(payslipBasePath); - logger.info(`Base path contents: ${JSON.stringify(testResults.basePathContents)}`); + testResults.basePathContents = await fsPromises.readdir(payslipBasePath); + logger.info('Base path contents: ' + JSON.stringify(testResults.basePathContents)); } } } catch (error) { @@ -210,55 +459,52 @@ app.get('/api/test-network', async (req, res) => { } }); -// ============================================ -// 📄 Получение расчетного листка (с периодом) -// ============================================ - -app.get('/api/payslip/:period/:fileName', async (req, res) => { +app.get('/api/payslip/:period/:fileName', async function(req, res) { try { - const { period, fileName } = req.params; - const configPath = path.join(__dirname, '../config.json'); - const config = await readJson(configPath); - const payslipBasePath = config.payslipsPath; + var period = req.params.period; + var fileName = req.params.fileName; + var configPath = path.join(__dirname, '../config.json'); + var config = await readJson(configPath); + var payslipBasePath = config.payslipsPath; - logger.info(`Payslip request - Period: ${period}, File: ${fileName}`); - logger.info(`Base path from config: ${payslipBasePath}`); + logger.info('Payslip request - Period: ' + period + ', File: ' + fileName); + logger.info('Base path from config: ' + payslipBasePath); - const networkConnected = await connectToNetworkShare(config); - logger.info(`Network share connection for payslip: ${networkConnected}`); + var networkConnected = await connectToNetworkShare(config); + logger.info('Network share connection for payslip: ' + networkConnected); - let finalPayslipPath; + var finalPayslipPath; if (path.isAbsolute(payslipBasePath)) { finalPayslipPath = path.join(payslipBasePath, period, fileName); } else { - const projectRoot = path.resolve(__dirname, '../../'); + var projectRoot = path.resolve(__dirname, '../../'); finalPayslipPath = path.join(projectRoot, payslipBasePath, period, fileName); } - logger.info(`Final payslip path: ${finalPayslipPath}`); + logger.info('Final payslip path: ' + finalPayslipPath); try { - const basePathExists = await pathExists(payslipBasePath); - logger.info(`Base path exists: ${basePathExists}`); + var basePathExists = await pathExists(payslipBasePath); + logger.info('Base path exists: ' + basePathExists); if (basePathExists) { - const basePathStats = await fs.stat(payslipBasePath); - logger.info(`Base path stats: ${JSON.stringify({ + var basePathStats = await fsPromises.stat(payslipBasePath); + logger.info('Base path stats: ' + JSON.stringify({ isDirectory: basePathStats.isDirectory(), permissions: basePathStats.mode, size: basePathStats.size - })}`); + })); - const basePathContents = await fs.readdir(payslipBasePath); - logger.info(`Base path contents: ${JSON.stringify(basePathContents)}`); + var basePathContents = await fsPromises.readdir(payslipBasePath); + logger.info('Base path contents: ' + JSON.stringify(basePathContents)); - const periodPath = path.join(payslipBasePath, period); - const periodPathExists = await pathExists(periodPath); - logger.info(`Period path exists: ${periodPathExists} (${periodPath})`); + var periodPath = path.join(payslipBasePath, period); + var periodPathExists = await pathExists(periodPath); + logger.info('Period path exists: ' + periodPathExists + ' (' + periodPath + ')'); if (periodPathExists) { - const periodPathContents = await fs.readdir(periodPath); - logger.info(`Period path contents: ${JSON.stringify(periodPathContents)}`); + var periodPathContents = await fsPromises.readdir(periodPath); + logger.info('Period path contents: ' + JSON.stringify(periodPathContents)); } } } catch (basePathError) { @@ -269,10 +515,10 @@ app.get('/api/payslip/:period/:fileName', async (req, res) => { } if (await pathExists(finalPayslipPath)) { - logger.info(`Payslip found, sending file: ${finalPayslipPath}`); + logger.info('Payslip found, sending file: ' + finalPayslipPath); res.sendFile(finalPayslipPath); } else { - logger.warn(`Payslip not found: ${finalPayslipPath}`); + logger.warn('Payslip not found: ' + finalPayslipPath); res.status(404).json({ message: 'Расчетный листок не найден', path: finalPayslipPath, @@ -288,31 +534,27 @@ app.get('/api/payslip/:period/:fileName', async (req, res) => { } }); -// ============================================ -// 📄 Получение расчетного листка (без периода) -// ============================================ - -app.get('/api/payslip/:fileName', async (req, res) => { +app.get('/api/payslip/:fileName', async function(req, res) { try { - const { fileName } = req.params; - const configPath = path.join(__dirname, '../config.json'); - const config = await readJson(configPath); - const payslipBasePath = config.payslipsPath; + var fileName = req.params.fileName; + var configPath = path.join(__dirname, '../config.json'); + var config = await readJson(configPath); + var payslipBasePath = config.payslipsPath; - let finalPayslipPath; + var finalPayslipPath; if (path.isAbsolute(payslipBasePath)) { finalPayslipPath = path.join(payslipBasePath, fileName); } else { - const projectRoot = path.resolve(__dirname, '../../'); + var projectRoot = path.resolve(__dirname, '../../'); finalPayslipPath = path.join(projectRoot, payslipBasePath, fileName); } - logger.info(`Attempting to find payslip at resolved path: ${finalPayslipPath}`); + logger.info('Attempting to find payslip at resolved path: ' + finalPayslipPath); if (await pathExists(finalPayslipPath)) { res.sendFile(finalPayslipPath); } else { - logger.warn(`Payslip not found: ${finalPayslipPath}`); + logger.warn('Payslip not found: ' + finalPayslipPath); res.status(404).json({ message: 'Расчетный листок не найден' }); } } catch (err) { @@ -321,43 +563,47 @@ app.get('/api/payslip/:fileName', async (req, res) => { } }); -// ============================================ - -// ============================================ - -app.get('/api/db-status', async (req, res) => { +app.get('/api/db-status', async function(req, res) { try { - const { query } = require('./config/db'); - await query('SELECT 1'); + var db = require('./config/db'); + await db.query('SELECT 1'); res.status(200).json({ status: 'ok', message: 'DB connection successful' }); } catch (err) { logger.error('Database connection check failed', { error: err.message, stack: err.stack }); res.status(500).json({ status: 'error', message: 'DB connection failed' }); } }); -app.get('/api/time', (req, res) => { - res.json({ - time: new Date().toISOString(), - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - }); -}); -async function startServer() { - try { - const configPath = path.join(__dirname, '../config.json'); - const config = await readJson(configPath); - const { host, port } = config.server; - await connectDB(); -app.get('/api/time', (req, res) => { - const now = new Date(); - logger.info(`[TIME] Запрос времени от ${req.ip || req.connection.remoteAddress}`); +app.get('/api/time', function(req, res) { + var now = new Date(); + logger.info('[TIME] Запрос времени от ' + (req.ip || req.connection.remoteAddress)); res.json({ time: now.toISOString(), timestamp: now.getTime() }); }); - app.listen(port, host, () => { - logger.info(`Server is running on http://${host}:${port}`); + +// ============================================ +// 🚀 ЗАПУСК СЕРВЕРА +// ============================================ + +async function startServer() { + try { + var configPath = path.join(__dirname, '../config.json'); + var config = await readJson(configPath); + var host = config.server.host; + var port = config.server.port; + + var excelExists = await pathExists(EXCEL_PATH); + if (!excelExists) { + await createExcelTemplate(); + logger.info('Создан Excel-шаблон'); + } + + await connectDB(); + app.listen(port, host, function() { + logger.info('Server is running on http://' + host + ':' + port); + logger.info('Excel file: ' + EXCEL_PATH); }); } catch (err) { if (logger) { @@ -367,8 +613,14 @@ app.get('/api/time', (req, res) => { } } } -app.use((err, req, res, next) => { + +// ============================================ +// ❌ ОБРАБОТКА ОШИБОК +// ============================================ + +app.use(function(err, req, res, next) { logger.error('Unhandled error', { error: err.message, stack: err.stack }); res.status(500).json({ message: 'Внутренняя ошибка сервера' }); }); + startServer(); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9fa578..b5e37d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,10 +5,11 @@ import LoginWindow from './components/LoginWindow'; import GuestMode from './components/GuestMode'; import MainMenu from './components/MenuComponent'; import VideoBackground from './components/VideoBackground'; +import WeatherWidget from './components/WeatherWidget'; import BottomBar from './components/BottomBar'; import PayslipViewer from './components/PayslipViewer'; import EmployeeInfo from './components/EmployeeInfo'; -import { useActivityTimer } from './components/useActivityTimer' +import { useActivityTimer } from './components/useActivityTimer'; import config from './config.json'; // Импорты разделов @@ -314,6 +315,9 @@ function App() { // ============================================ // 🚀 Рендер приложения // ============================================ + const showTopBar = !showVideo && !showPayslip; + const topBarHeight = 70; // высота виджета + return (
+ )} - {/* 📱 Нижняя панель — ПОКАЗЫВАЕМ ВСЕГДА, кроме видео и расчетного листка */} - {!showVideo && !showPayslip && ( - + {/* 📄 Основной контент с отступом сверху */} +
+ {renderContent()} +
+ + {/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */} + {showTopBar && ( + )}
); diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index d1c57c2..046d23a 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion } from 'framer-motion'; interface CurrencyRate { currency: string; @@ -13,20 +13,7 @@ interface MetalRate { change: number; } -interface WeatherDay { - date: string; - dayOfWeek: string; - tempMin: number; - tempMax: number; - icon: string; - description: string; -} - -interface BottomBarProps { - onLogout?: () => void; -} - -const BottomBar: React.FC = ({ onLogout }) => { +const BottomBar: React.FC = () => { const [currencyRates, setCurrencyRates] = useState([ { currency: 'USD', rate: 3.22, change: 0.5 }, { currency: 'EUR', rate: 3.45, change: -0.2 }, @@ -43,135 +30,12 @@ const BottomBar: React.FC = ({ onLogout }) => { const [isOnline, setIsOnline] = useState(navigator.onLine); const [isPaused, setIsPaused] = useState(false); - const [currentTime, setCurrentTime] = useState(new Date()); - const [weatherData, setWeatherData] = useState([]); - const [weatherLoading, setWeatherLoading] = useState(false); - const [weatherError, setWeatherError] = useState(false); + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState<'mysql' | 'excel' | 'default'>('default'); + const [lastUpdate, setLastUpdate] = useState(''); const scrollRef = useRef(null); - // ============================================ - // ⏰ ВРЕМЯ С СЕРВЕРА (объединенная логика) - // ============================================ - const NTP_URL = import.meta.env.VITE_NTP_SERVER || 'http://192.168.139.249:3001/api/time'; - - useEffect(() => { - let timer: NodeJS.Timeout; - let diff = 0; - - const fetchTime = async () => { - try { - const res = await fetch(NTP_URL); - if (!res.ok) throw new Error('Server time unavailable'); - const data = await res.json(); - const serverTime = new Date(data.time); - - diff = serverTime.getTime() - Date.now(); - setCurrentTime(new Date(Date.now() + diff)); - - if (timer) clearInterval(timer); - timer = setInterval(() => { - setCurrentTime(new Date(Date.now() + diff)); - }, 1000); - } catch (err) { - console.warn('Server time unavailable, using local time:', err); - setCurrentTime(new Date()); - if (timer) clearInterval(timer); - timer = setInterval(() => { - setCurrentTime(new Date()); - }, 1000); - } - }; - - fetchTime(); - - return () => { - if (timer) clearInterval(timer); - }; - }, []); - - // ============================================ - // 🌤️ ЗАГРУЗКА ПОГОДЫ - // ============================================ - useEffect(() => { - const fetchWeather = async () => { - if (!isOnline) { - setWeatherData([]); - return; - } - - try { - setWeatherLoading(true); - setWeatherError(false); - - const apiKey = '9edc3b8058098d110db0aa1894a08c97'; - const city = 'Grodno'; - const country = 'BY'; - const url = `https://api.openweathermap.org/data/2.5/forecast?q=${city},${country}&units=metric&lang=ru&appid=${apiKey}`; - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Ошибка: ${response.status}`); - } - - const data = await response.json(); - - const dailyForecast: WeatherDay[] = []; - const daysMap = new Map(); - - data.list.forEach((item: any) => { - const date = new Date(item.dt * 1000); - const dateStr = date.toISOString().split('T')[0]; - const dayOfWeek = date.toLocaleString('ru-RU', { weekday: 'short' }); - - if (!daysMap.has(dateStr)) { - daysMap.set(dateStr, { - date: dateStr, - dayOfWeek: dayOfWeek.charAt(0).toUpperCase() + dayOfWeek.slice(1), - tempMin: item.main.temp_min, - tempMax: item.main.temp_max, - icon: item.weather[0].icon, - description: item.weather[0].description, - }); - } else { - const existing = daysMap.get(dateStr); - existing.tempMin = Math.min(existing.tempMin, item.main.temp_min); - existing.tempMax = Math.max(existing.tempMax, item.main.temp_max); - } - }); - - const sevenDays = Array.from(daysMap.values()).slice(0, 7); - setWeatherData(sevenDays); - setWeatherLoading(false); - - } catch (error) { - console.warn('Ошибка загрузки погоды:', error); - setWeatherError(true); - setWeatherLoading(false); - setWeatherData([]); - } - }; - - fetchWeather(); - const interval = setInterval(fetchWeather, 30 * 60 * 1000); - - return () => clearInterval(interval); - }, [isOnline]); - - // ============================================ - // 🌐 СТАТУС ИНТЕРНЕТА - // ============================================ - useEffect(() => { - const handleOnline = () => setIsOnline(true); - const handleOffline = () => setIsOnline(false); - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; - }, []); + const BACKEND_URL = 'http://localhost:3001'; // ============================================ // 📊 ЗАГРУЗКА КУРСОВ @@ -179,30 +43,102 @@ const BottomBar: React.FC = ({ onLogout }) => { useEffect(() => { const loadRates = async () => { try { - if (isOnline) { - const response = await fetch('http://localhost:3001/api/rates'); - if (response.ok) { - const data = await response.json(); - setCurrencyRates(data.currency || currencyRates); - setMetalRates(data.metals || metalRates); + setLoading(true); + + // ✅ Сначала пробуем MySQL + try { + const mysqlResponse = await fetch(`${BACKEND_URL}/api/rates/mysql`); + if (mysqlResponse.ok) { + const data = await mysqlResponse.json(); + if (data.currency && data.currency.length > 0) { + setCurrencyRates(data.currency); + setMetalRates(data.metals || []); + setDataSource('mysql'); + setLastUpdate(new Date().toLocaleString('ru-RU')); + // Сохраняем в localStorage + localStorage.setItem('ratesData', JSON.stringify({ + currency: data.currency, + metals: data.metals, + source: 'mysql', + lastUpdate: new Date().toISOString() + })); + setLoading(false); + return; + } } - } else { - const stored = localStorage.getItem('currencyRates'); - if (stored) { + } catch (mysqlError) { + console.warn('MySQL недоступен, пробуем Excel:', mysqlError); + } + + // ✅ Если MySQL не работает — пробуем Excel + try { + const excelResponse = await fetch(`${BACKEND_URL}/api/rates/excel`); + if (excelResponse.ok) { + const data = await excelResponse.json(); + if (data.currency && data.currency.length > 0) { + setCurrencyRates(data.currency); + setMetalRates(data.metals || []); + setDataSource('excel'); + setLastUpdate(new Date().toLocaleString('ru-RU')); + // Сохраняем в localStorage + localStorage.setItem('ratesData', JSON.stringify({ + currency: data.currency, + metals: data.metals, + source: 'excel', + lastUpdate: new Date().toISOString() + })); + setLoading(false); + return; + } + } + } catch (excelError) { + console.warn('Excel недоступен:', excelError); + } + + // ✅ Если ничего не работает — берём из localStorage + const stored = localStorage.getItem('ratesData'); + if (stored) { + try { const data = JSON.parse(stored); - setCurrencyRates(data.currency || currencyRates); - setMetalRates(data.metals || metalRates); + if (data.currency) { + setCurrencyRates(data.currency); + setMetalRates(data.metals || []); + setDataSource(data.source || 'default'); + if (data.lastUpdate) { + setLastUpdate(new Date(data.lastUpdate).toLocaleString('ru-RU')); + } + } + } catch (e) { + console.warn('Ошибка загрузки из localStorage:', e); } } + + setLoading(false); + } catch (error) { console.warn('Ошибка загрузки курсов:', error); + setLoading(false); } }; loadRates(); - const interval = setInterval(loadRates, 60000); + const interval = setInterval(loadRates, 60000); // Обновление каждую минуту return () => clearInterval(interval); - }, [isOnline]); + }, []); + + // ============================================ + // 🌐 СТАТУС ИНТЕРНЕТА + // ============================================ + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); // ============================================ // 🎮 УПРАВЛЕНИЕ ПАУЗОЙ @@ -212,42 +148,6 @@ const BottomBar: React.FC = ({ onLogout }) => { const handleTouchStart = () => setIsPaused(true); const handleTouchEnd = () => setIsPaused(false); - // ============================================ - // 🚪 ВЫХОД - // ============================================ - const handleLogout = () => { - if (onLogout) { - onLogout(); - } - }; - - // ============================================ - // 🎨 ИКОНКА ПОГОДЫ - // ============================================ - const getWeatherIcon = (iconCode: string) => { - const icons: Record = { - '01d': '☀️', - '01n': '🌙', - '02d': '⛅', - '02n': '☁️', - '03d': '☁️', - '03n': '☁️', - '04d': '☁️', - '04n': '☁️', - '09d': '🌧️', - '09n': '🌧️', - '10d': '🌦️', - '10n': '🌧️', - '11d': '⛈️', - '11n': '⛈️', - '13d': '❄️', - '13n': '❄️', - '50d': '🌫️', - '50n': '🌫️', - }; - return icons[iconCode] || '🌡️'; - }; - // ============================================ // 📦 ВСЕ ЭЛЕМЕНТЫ ДЛЯ БЕГУЩЕЙ СТРОКИ // ============================================ @@ -267,7 +167,8 @@ const BottomBar: React.FC = ({ onLogout }) => { label: item.metal, value: item.price, change: item.change, - displayValue: `$${item.price.toFixed(item.price < 10 ? 2 : 0)}`, + displayValue: item.price.toFixed(item.price < 10 ? 2 : 0), + currencySymbol: 'Br', })); return [...currencyItems, ...metalItems]; @@ -277,348 +178,244 @@ const BottomBar: React.FC = ({ onLogout }) => { const doubledItems = [...items, ...items, ...items]; // ============================================ - // 🎨 РЕНДЕР + // 🔄 ИНДИКАТОР ЗАГРУЗКИ + // ============================================ + if (loading) { + return ( +
+ ⏳ Загрузка курсов... +
+ ); + } + + // ============================================ + // 🎨 ОСНОВНОЙ РЕНДЕР // ============================================ return ( - <> - {/* 🌤️ ПОЛОСКА ПОГОДЫ */} - - {isOnline && weatherData.length > 0 && !weatherLoading && !weatherError && ( - -
- 🌤️ - - ГРОДНО - -
-
- -
- - {[...weatherData, ...weatherData].map((day, index) => ( -
- - {day.dayOfWeek} - - - {getWeatherIcon(day.icon)} - - - {Math.round(day.tempMin)}°…{Math.round(day.tempMax)}° - -
- ))} -
-
- - )} - - - {/* 📊 ОСНОВНАЯ ПОЛОСКА (курсы валют) */} - - {/* Статус */} -
+ {/* Статус и источник данных */} +
+ - - {isOnline ? '●' : '○'} - - КУРСЫ -
-
+ {isOnline ? '●' : '○'} +
+ КУРСЫ + + {/* ✅ Индикатор источника данных (без кнопки) */} + + {dataSource === 'mysql' ? 'MySQL' : + dataSource === 'excel' ? '📊' : + '💾'} + + +
+
- {/* Бегущая строка */} -
+ - - {doubledItems.map((item, index) => ( -
- - {item.label} - - - {item.displayValue} - - = 0 ? '#2ecc71' : '#e74c3c', - fontSize: '11px', - fontWeight: 500, - display: 'flex', - alignItems: 'center', - gap: '2px', - letterSpacing: '0.2px', - }}> - {item.change >= 0 ? '▲' : '▼'} - {Math.abs(item.change).toFixed(1)}% - - {item.type === 'metal' && ( - - $ - - )} -
- ))} -
-
- - {/* Время + Кнопка выхода */} -
-
- {currentTime.toLocaleString('ru-RU', { - timeZone: 'Europe/Minsk', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - })} -
- - {onLogout && ( - - )} -
+ + {item.label} + + + {item.displayValue} + + {item.type === 'metal' && ( + + {item.currencySymbol || 'Br'} + + )} + = 0 ? '#2ecc71' : '#e74c3c', + fontSize: '10px', + fontWeight: 500, + display: 'flex', + alignItems: 'center', + gap: '2px', + letterSpacing: '0.2px', + }}> + {item.change >= 0 ? '▲' : '▼'} + {Math.abs(item.change).toFixed(1)}% + +
+ ))} + +
- {/* Пауза */} - {isPaused && ( -
+ {lastUpdate && ( + - ⏸ Пауза -
+ {lastUpdate} + )} -
- +
+ + {/* Пауза */} + {isPaused && ( +
+ ⏸ Пауза +
+ )} +
); }; diff --git a/frontend/src/components/Contacts.tsx b/frontend/src/components/Contacts.tsx index 92b92c4..6d3aeff 100644 --- a/frontend/src/components/Contacts.tsx +++ b/frontend/src/components/Contacts.tsx @@ -1,56 +1,370 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { motion } from 'framer-motion'; -import { FaArrowLeft } from 'react-icons/fa'; +import { FaArrowLeft, FaSearch, FaArrowUp, FaArrowDown } from 'react-icons/fa'; import MapViewer from './MapViewer'; +import config from '../config.json'; interface ContactsProps { onBack: () => void; } +// ============================================ +// 🔧 Парсер телефонов из HTML +// ============================================ +function parseHandbookBlocks(html: string) { + const blocks: (string | { type: 'phones', rows: { desc: string, internal: string, city: string }[] })[] = []; + const container = document.createElement('div'); + container.innerHTML = html; + let phoneRows: { desc: string, internal: string, city: string }[] = []; + let lastDesc = ''; + const phoneRegex = /(\d{2,3}-\d{2,3}(?:-\d{2,3})?|\d{4}\s\d{6,7})/g; + const children = Array.from(container.childNodes); + let htmlBuffer = ''; + const flushHtmlBuffer = () => { + if (htmlBuffer) { + blocks.push(htmlBuffer); + htmlBuffer = ''; + } + }; + const flushPhoneRows = () => { + if (phoneRows.length > 1) { + blocks.push({ type: 'phones', rows: phoneRows }); + } else if (phoneRows.length === 1) { + const row = phoneRows[0]; + blocks.push(`

${row.desc} ${row.internal}${row.city ? ' ' + row.city : ''}

`); + } + phoneRows = []; + }; + children.forEach(node => { + if (node.nodeType === 1) { + const tag = (node as HTMLElement).tagName.toLowerCase(); + const text = (node as HTMLElement).textContent?.trim() || ''; + const phones = Array.from(text.matchAll(phoneRegex), m => m[1]); + if ((tag === 'p' || tag === 'li') && phones.length >= 1) { + flushHtmlBuffer(); + if (phones.length === 2) { + const desc = text.replace(phoneRegex, '').trim(); + phoneRows.push({ desc, internal: phones[0], city: phones[1] }); + lastDesc = desc; + } else if (phones.length === 1) { + phoneRows.push({ desc: lastDesc, internal: phones[0], city: '' }); + } else if (text && !phones.length) { + lastDesc = text; + } + } else { + if (phoneRows.length) { + flushPhoneRows(); + } + if ((node as HTMLElement).outerHTML) { + htmlBuffer += (node as HTMLElement).outerHTML; + } else { + htmlBuffer += node.textContent || ''; + } + } + } else if (node.nodeType === 3) { + htmlBuffer += node.textContent; + } + }); + if (phoneRows.length) { + flushPhoneRows(); + } + if (htmlBuffer) { + blocks.push(htmlBuffer); + } + return blocks; +} + +// ============================================ +// 📚 Функции рендеринга с подсветкой и поиском +// ============================================ +const ADMIN_SERVER_URL = config.adminServerUrl || 'http://localhost:3005'; + +function patchMediaSrc(html: string): string { + return html + .replace(/]+)src=["'](\/uploads\/[^"']+)["']/g, `]+)src=["'](\/uploads\/[^"']+)["']/g, ` lastIndex) { + fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index))); + } + const mark = document.createElement('mark'); + mark.style.cssText = 'background: #ffdd57; color: #1a3c34; padding: 0 4px; border-radius: 3px; font-weight: 600;'; + mark.dataset.matchIndex = String(matchCount); + mark.textContent = match[0]; + fragment.appendChild(mark); + lastIndex = match.index + match[0].length; + matchCount++; + } + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.substring(lastIndex))); + } + node.parentNode?.replaceChild(fragment, node); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const children = Array.from(node.childNodes); + for (const child of children) { + walkNodes(child); + } + } + } + + walkNodes(temp); + return { html: temp.innerHTML, count: matchCount }; +} + +function blockMatchesSearch(block: any, searchTerm: string): boolean { + if (!searchTerm.trim()) return true; + const term = searchTerm.trim().toLowerCase(); + if (typeof block === 'string') { + return stripHtml(block).toLowerCase().includes(term); + } else if (block.type === 'phones') { + return block.rows.some((row: { desc: string; internal: string; city: string }) => + row.desc.toLowerCase().includes(term) || + row.internal.includes(term) || + row.city.includes(term) + ); + } + return true; +} + +// ============================================ +// 📦 Компонент для рендеринга справочника +// ============================================ +const HandbookRenderer: React.FC<{ + blocks: any[]; + searchTerm: string; + onMatchCount: (count: number) => void; +}> = ({ blocks, searchTerm, onMatchCount }) => { + const containerRef = useRef(null); + const [renderedHtml, setRenderedHtml] = useState<{ html: string; count: number }[]>([]); + const [totalMatches, setTotalMatches] = useState(0); + const hasSearch = searchTerm.trim().length > 0; + + useEffect(() => { + if (!blocks.length) return; + + const result: { html: string; count: number }[] = []; + let total = 0; + + blocks.forEach(block => { + if (typeof block === 'string') { + const content = patchMediaSrc(block); + if (hasSearch) { + const highlighted = highlightTextInHtml(content, searchTerm); + result.push({ html: highlighted.html, count: highlighted.count }); + total += highlighted.count; + } else { + result.push({ html: content, count: 0 }); + } + } else if (block.type === 'phones') { + const term = searchTerm.trim().toLowerCase(); + let rows = block.rows; + if (hasSearch && term) { + rows = rows.filter((row: { desc: string; internal: string; city: string }) => + row.desc.toLowerCase().includes(term) || + row.internal.includes(term) || + row.city.includes(term) + ); + } + if (rows.length === 0) { + result.push({ html: '', count: 0 }); + return; + } + + let tableHtml = ``; + tableHtml += ` + + + + + + + + `; + + rows.forEach((row: { desc: string; internal: string; city: string }) => { + let descHtml = row.desc; + let internalHtml = row.internal; + let cityHtml = row.city; + let rowHasMatch = false; + + if (hasSearch && term) { + const descResult = highlightTextInHtml(row.desc, searchTerm); + descHtml = descResult.html; + if (descResult.count > 0) rowHasMatch = true; + const internalResult = highlightTextInHtml(row.internal, searchTerm); + internalHtml = internalResult.html; + if (internalResult.count > 0) rowHasMatch = true; + const cityResult = highlightTextInHtml(row.city, searchTerm); + cityHtml = cityResult.html; + if (cityResult.count > 0) rowHasMatch = true; + total += descResult.count + internalResult.count + cityResult.count; + } + + tableHtml += ` + + + + + `; + }); + + tableHtml += `
ОписаниеАТС предприятияГородская АТС
${descHtml}${internalHtml}${cityHtml}
`; + result.push({ html: tableHtml, count: 0 }); + } + }); + + setRenderedHtml(result); + setTotalMatches(total); + onMatchCount(total); + }, [blocks, searchTerm, onMatchCount]); + + useEffect(() => { + if (!containerRef.current) return; + const marks = containerRef.current.querySelectorAll('mark'); + marks.forEach((el, idx) => { + el.dataset.matchIndex = String(idx); + }); + }, [renderedHtml]); + + return ( +
+ {renderedHtml.map((item, idx) => ( + item.html ?
: null + ))} +
+ ); +}; + +// ============================================ +// 🏠 Основной компонент Contacts +// ============================================ const Contacts: React.FC = ({ onBack }) => { const [searchTerm, setSearchTerm] = useState(''); const [activeTab, setActiveTab] = useState<'map' | 'directory'>('directory'); const [showMapViewer, setShowMapViewer] = useState(false); + const [blocks, setBlocks] = useState([]); + const [loadingHandbook, setLoadingHandbook] = useState(false); + const [errorHandbook, setErrorHandbook] = useState(null); + const [matchCount, setMatchCount] = useState(0); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const contentRef = useRef(null); + const inputRef = useRef(null); - // ✅ Путь к TIFF-файлу (вместо PDF) const MAP_FILE_URL = '/assets/maps/enterprise-map.tiff'; - const directoryData = [ - { name: 'Иванов Иван Иванович', position: 'Директор', phone: '123-45-67', department: 'Руководство' }, - { name: 'Петров Петр Петрович', position: 'Главный инженер', phone: '123-45-68', department: 'Руководство' }, - { name: 'Сидорова Мария Сергеевна', position: 'Бухгалтер', phone: '123-45-69', department: 'Бухгалтерия' }, - { name: 'Козлов Андрей Владимирович', position: 'Начальник цеха', phone: '123-45-70', department: 'Производство' }, - ]; + useEffect(() => { + if (activeTab === 'directory' && blocks.length === 0 && !loadingHandbook) { + loadHandbook(); + } + }, [activeTab]); - const emergencyNumbers = [ - { name: 'Пожарная служба', phone: '101', icon: '🧯' }, - { name: 'Милиция', phone: '102', icon: '🛡️' }, - { name: 'Скорая помощь', phone: '103', icon: '🚑' }, - { name: 'Газовая служба', phone: '104', icon: '📞' }, - ]; - - const filteredData = directoryData.filter(item => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) || - item.position.toLowerCase().includes(searchTerm.toLowerCase()) || - item.department.toLowerCase().includes(searchTerm.toLowerCase()) - ); - - const handleOpenMap = () => { - setShowMapViewer(true); + const loadHandbook = async () => { + setLoadingHandbook(true); + setErrorHandbook(null); + try { + const url = `${config.backendUrl}/api/handbook/html`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const html = await response.text(); + const parsed = parseHandbookBlocks(html); + setBlocks(parsed); + } catch (error) { + console.error('[Contacts] Ошибка загрузки справочника:', error); + setErrorHandbook(error instanceof Error ? error.message : 'Неизвестная ошибка'); + } finally { + setLoadingHandbook(false); + } }; - const handleMapBack = () => { - setShowMapViewer(false); + const handleOpenMap = () => setShowMapViewer(true); + const handleMapBack = () => setShowMapViewer(false); + + const goToNextMatch = useCallback(() => { + if (matchCount === 0) return; + const nextIndex = (currentMatchIndex + 1) % matchCount; + setCurrentMatchIndex(nextIndex); + scrollToMatch(nextIndex); + }, [matchCount, currentMatchIndex]); + + const goToPrevMatch = useCallback(() => { + if (matchCount === 0) return; + const prevIndex = (currentMatchIndex - 1 + matchCount) % matchCount; + setCurrentMatchIndex(prevIndex); + scrollToMatch(prevIndex); + }, [matchCount, currentMatchIndex]); + + const scrollToMatch = useCallback((index: number) => { + const container = contentRef.current; + if (!container) return; + + const marks = container.querySelectorAll('mark[data-match-index]'); + if (marks.length === 0) return; + + const targetMark = Array.from(marks).find((el: Element) => el.getAttribute('data-match-index') === String(index)); + if (!targetMark) return; + + const containerRect = container.getBoundingClientRect(); + const markRect = targetMark.getBoundingClientRect(); + const scrollTop = container.scrollTop + markRect.top - containerRect.top - 80; + container.scrollTo({ top: scrollTop, behavior: 'smooth' }); + + const markElement = targetMark as HTMLElement; + markElement.style.outline = '2px solid #ff6b6b'; + markElement.style.outlineOffset = '2px'; + setTimeout(() => { + markElement.style.outline = 'none'; + markElement.style.outlineOffset = 'none'; + }, 2000); + }, []); + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (matchCount > 0) { + goToNextMatch(); + } + inputRef.current?.blur(); + } }; + useEffect(() => { + setCurrentMatchIndex(0); + }, [searchTerm]); + if (showMapViewer) { - return ( - - ); + return ; } return ( @@ -65,17 +379,12 @@ const Contacts: React.FC = ({ onBack }) => { maxWidth: '1200px', margin: '0 auto', paddingBottom: '80px', + display: 'flex', + flexDirection: 'column', + height: 'calc(100vh - 80px)', }} > - {/* Верхняя панель */} -
+
- {/* Контент вкладок */} - {activeTab === 'directory' && ( - - {/* Экстренные номера */} -
- {emergencyNumbers.map((item, index) => ( - -
{item.icon}
-
{item.name}
-
{item.phone}
-
- ))} -
- - {/* Поиск */} -
+
+ {activeTab === 'directory' && ( +
- 🔍 - setSearchTerm(e.target.value)} - style={{ - flex: 1, - border: 'none', - background: 'transparent', - padding: '10px 0', - fontSize: '15px', - outline: 'none', - }} - /> -
-
- - {/* Таблица */} -
- - - - - - - - - - {filteredData.map((item, index) => ( - { e.currentTarget.style.background = '#f8f9fa'; }} - onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} - > - - - - - ))} - -
ФИОДолжностьТелефон
{item.name}{item.position} - 📞 {item.phone} -
- {filteredData.length === 0 && ( -
- Ничего не найдено +
+ 🔍 + { + setSearchTerm(e.target.value); + }} + onKeyPress={handleKeyPress} + style={{ + flex: 1, + border: 'none', + background: 'transparent', + padding: '10px 0', + fontSize: '15px', + outline: 'none', + }} + />
- )} -
- - )} - {activeTab === 'map' && ( - { - e.currentTarget.style.borderColor = '#2ecc71'; - e.currentTarget.style.boxShadow = '0 8px 30px rgba(46, 204, 113, 0.15)'; - e.currentTarget.style.transform = 'translateY(-3px)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = '#dfe6e9'; - e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.06)'; - e.currentTarget.style.transform = 'translateY(0)'; - }} - > -
🗺️
-

- Карта предприятия -

-

- Нажмите для просмотра карты в полноэкранном режиме -

-
- Открыть карту → -
-
- )} + {searchTerm.trim() && matchCount > 0 && ( + + {currentMatchIndex + 1} / {matchCount} + + )} + +
+ + +
+
+ + {/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */} +
+ {loadingHandbook ? ( +
+
+

Загрузка справочника...

+
+ ) : errorHandbook ? ( +
+

Ошибка загрузки: {errorHandbook}

+ +
+ ) : blocks.length > 0 ? ( + { + setMatchCount(count); + }} + /> + ) : ( +

Нет данных для отображения

+ )} +
+ + )} + + {activeTab === 'map' && ( + { + e.currentTarget.style.borderColor = '#2ecc71'; + e.currentTarget.style.boxShadow = '0 8px 30px rgba(46, 204, 113, 0.15)'; + e.currentTarget.style.transform = 'translateY(-3px)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = '#dfe6e9'; + e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.06)'; + e.currentTarget.style.transform = 'translateY(0)'; + }} + > +
🗺️
+

+ Карта предприятия +

+

+ Нажмите для просмотра карты в полноэкранном режиме +

+
+ Открыть карту → +
+
+ )} +
+ + {/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */} + ); }; diff --git a/frontend/src/components/MenuComponent.tsx b/frontend/src/components/MenuComponent.tsx index 5bb6425..bdfc297 100644 --- a/frontend/src/components/MenuComponent.tsx +++ b/frontend/src/components/MenuComponent.tsx @@ -9,7 +9,10 @@ const MainMenu: React.FC = ({ onSectionSelect, employee }) => { console.log('🔵 MainMenu компонент рендерится!'); const menuItems = [ - { id: 'employee-info', title: 'Моя информация', emoji: '📋' }, + { id: 'employee-info', emoji: '📋' } + ]; + + const menuItems_2 = [ { id: 'finance', title: 'Финансы', emoji: '💰' }, { id: 'medicine', title: 'Медицина', emoji: '🏥' }, { id: 'trade-union', title: 'Профсоюз', emoji: '🤝' }, @@ -26,16 +29,17 @@ const MainMenu: React.FC = ({ onSectionSelect, employee }) => { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - padding: '40px', + padding: '10px', + marginTop: '-5vh', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', }}> {employee && (
- Здравствуйте, {employee.ima} {employee.famaly} + Приветствуем Вас, {employee.ima} {employee.famaly}
)}

= ({ onSectionSelect, employee }) => {
+ {menuItems.map((item) => ( + + ))} +
+ +
+ + {menuItems_2.map((item) => (
+ {/* ✅ Табельный номер из базы */} +
+
Табельный номер
+
+ {formData.tnom} +
+
+ + {/* ✅ Телефон из базы */}
Телефон
{isEditing ? ( @@ -126,6 +146,7 @@ const Profile: React.FC = ({ onBack, employee }) => {
{formData.phone}
)}
+
Email
{isEditing ? ( diff --git a/frontend/src/components/WeatherWidget.tsx b/frontend/src/components/WeatherWidget.tsx new file mode 100644 index 0000000..47eff36 --- /dev/null +++ b/frontend/src/components/WeatherWidget.tsx @@ -0,0 +1,395 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface WeatherDay { + date: string; + dayOfWeek: string; + tempMin: number; + tempMax: number; + icon: string; + description: string; +} + +interface WeatherWidgetProps { + onLogout?: () => void; +} + +const WeatherWidget: React.FC = ({ onLogout }) => { + const [weatherData, setWeatherData] = useState([]); + const [currentWeather, setCurrentWeather] = useState(null); + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [loading, setLoading] = useState(true); + const [currentTime, setCurrentTime] = useState(new Date()); + + // ⏰ ВРЕМЯ + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + return () => clearInterval(interval); + }, []); + + // 🌐 СТАТУС ИНТЕРНЕТА + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + // 🌤️ ЗАГРУЗКА ПОГОДЫ + useEffect(() => { + const fetchWeather = async () => { + if (!isOnline) { + setLoading(false); + return; + } + + try { + setLoading(true); + const apiKey = '9edc3b8058098d110db0aa1894a08c97'; + const url = `https://api.openweathermap.org/data/2.5/forecast?q=Grodno&units=metric&lang=ru&appid=${apiKey}`; + + const response = await fetch(url); + + if (!response.ok) { + // fallback + setCurrentWeather({ + date: new Date().toISOString().split('T')[0], + dayOfWeek: 'Сегодня', + tempMin: 18, + tempMax: 24, + icon: '01d', + description: 'Ясно', + }); + setWeatherData([ + { date: '2026-08-17', dayOfWeek: 'Пн', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' }, + { date: '2026-08-18', dayOfWeek: 'Вт', tempMin: 19, tempMax: 25, icon: '02d', description: 'Облачно' }, + { date: '2026-08-19', dayOfWeek: 'Ср', tempMin: 17, tempMax: 23, icon: '10d', description: 'Дождь' }, + { date: '2026-08-20', dayOfWeek: 'Чт', tempMin: 16, tempMax: 22, icon: '03d', description: 'Облачно' }, + { date: '2026-08-21', dayOfWeek: 'Пт', tempMin: 18, tempMax: 24, icon: '01d', description: 'Ясно' }, + { date: '2026-08-22', dayOfWeek: 'Сб', tempMin: 20, tempMax: 26, icon: '01d', description: 'Ясно' }, + { date: '2026-08-23', dayOfWeek: 'Вс', tempMin: 21, tempMax: 27, icon: '02d', description: 'Облачно' }, + ]); + setLoading(false); + return; + } + + const data = await response.json(); + + const current = data.list[0]; + setCurrentWeather({ + date: new Date(current.dt * 1000).toISOString().split('T')[0], + dayOfWeek: 'Сегодня', + tempMin: current.main.temp_min, + tempMax: current.main.temp_max, + icon: current.weather[0].icon, + description: current.weather[0].description, + }); + + const dailyForecast: WeatherDay[] = []; + const daysMap = new Map(); + + data.list.forEach((item: any) => { + const date = new Date(item.dt * 1000); + const dateStr = date.toISOString().split('T')[0]; + const dayOfWeek = date.toLocaleString('ru-RU', { weekday: 'short' }); + + if (!daysMap.has(dateStr)) { + daysMap.set(dateStr, { + date: dateStr, + dayOfWeek: dayOfWeek.charAt(0).toUpperCase() + dayOfWeek.slice(1), + tempMin: item.main.temp_min, + tempMax: item.main.temp_max, + icon: item.weather[0].icon, + description: item.weather[0].description, + }); + } else { + const existing = daysMap.get(dateStr); + existing.tempMin = Math.min(existing.tempMin, item.main.temp_min); + existing.tempMax = Math.max(existing.tempMax, item.main.temp_max); + } + }); + + const sevenDays = Array.from(daysMap.values()).slice(0, 7); + setWeatherData(sevenDays); + setLoading(false); + + } catch (error) { + console.warn('Ошибка загрузки погоды:', error); + setLoading(false); + } + }; + + fetchWeather(); + const interval = setInterval(fetchWeather, 30 * 60 * 1000); + return () => clearInterval(interval); + }, [isOnline]); + + const getWeatherIcon = (iconCode: string) => { + const icons: Record = { + '01d': '☀️', + '01n': '🌙', + '02d': '⛅', + '02n': '☁️', + '03d': '☁️', + '03n': '☁️', + '04d': '☁️', + '04n': '☁️', + '09d': '🌧️', + '09n': '🌧️', + '10d': '🌦️', + '10n': '🌧️', + '11d': '⛈️', + '11n': '⛈️', + '13d': '❄️', + '13n': '❄️', + '50d': '🌫️', + '50n': '🌫️', + }; + return icons[iconCode] || '🌡️'; + }; + + const handleLogout = () => { + if (onLogout) { + onLogout(); + } + }; + + return ( + + {/* Левая часть: Время */} +
+
+ + {currentTime.toLocaleString('ru-RU', { + timeZone: 'Europe/Minsk', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + {currentTime.toLocaleString('ru-RU', { + timeZone: 'Europe/Minsk', + weekday: 'short', + day: 'numeric', + month: 'short', + })} + +
+
+ + {/* Центральная часть: Погода */} +
+ + {isOnline && !loading && currentWeather ? ( + +
+ + {getWeatherIcon(currentWeather.icon)} + +
+ + {Math.round(currentWeather.tempMax)}° + + + {currentWeather.description} + +
+
+ +
+ {weatherData.slice(1, 7).map((day, index) => ( + + + {day.dayOfWeek} + + + {getWeatherIcon(day.icon)} + + + {Math.round(day.tempMax)}° + + + ))} +
+
+ ) : ( + + + )} +
+
+ + {/* Правая часть: Кнопка выхода */} +
+ {onLogout && ( + { + e.currentTarget.style.background = 'rgba(231, 76, 60, 1)'; + e.currentTarget.style.boxShadow = '0 4px 20px rgba(231, 76, 60, 0.4)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(231, 76, 60, 0.7)'; + e.currentTarget.style.boxShadow = '0 2px 10px rgba(231, 76, 60, 0.2)'; + }} + > + ⏻ + Выйти + + )} +
+
+ ); +}; + +export default WeatherWidget; \ No newline at end of file diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 1b50b6a..1ab6a0b 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/employeeinfo.tsx","./src/components/guestmode.tsx","./src/components/loginwindow.tsx","./src/components/payslipviewer.tsx","./vite.config.js"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/config.js","./src/global.d.ts","./src/index.tsx","./src/r5usb.js","./src/r5usb_example.js","./src/react-app-env.d.ts","./src/reportwebvitals.ts","./src/setuptests.ts","./src/components/bottombar.tsx","./src/components/contacts.tsx","./src/components/employeeinfo.tsx","./src/components/finance.tsx","./src/components/guestmode.tsx","./src/components/laborsafety.tsx","./src/components/loginwindow.tsx","./src/components/mapviewer.tsx","./src/components/medicine.tsx","./src/components/menucomponent.tsx","./src/components/news.tsx","./src/components/payslipviewer.tsx","./src/components/profile.tsx","./src/components/tradeunion.tsx","./src/components/videobackground.tsx","./src/components/useactivitytimer.ts","./vite.config.js"],"version":"5.9.3"} \ No newline at end of file