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 0000000..b804e1b Binary files /dev/null and b/backend/rates.xlsx differ 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