weather&bottom

This commit is contained in:
2026-08-18 13:19:05 +03:00
parent c8168ff8d7
commit f052486e71
16 changed files with 2240 additions and 989 deletions
+105 -1
View File
@@ -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"
}
}
}
}
+3 -1
View File
@@ -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"
+72
View File
@@ -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
+34 -35
View File
@@ -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"
+3 -3
View File
@@ -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"
}
],
+149 -61
View File
@@ -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",
+3 -2
View File
@@ -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"
}
}
Binary file not shown.
+369 -117
View File
@@ -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();
+24 -6
View File
@@ -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 (
<div
className="app"
@@ -327,12 +331,26 @@ function App() {
{/* ⚠️ Предупреждение о неактивности */}
{renderWarning()}
{/* 📄 Основной контент */}
{renderContent()}
{/* 🌤️ ВЕРХНИЙ ВИДЖЕТ: ПОГОДА + ВРЕМЯ + ВЫХОД */}
{showTopBar && (
<WeatherWidget onLogout={handleBack} />
)}
{/* 📱 Нижняя панель — ПОКАЗЫВАЕМ ВСЕГДА, кроме видео и расчетного листка */}
{!showVideo && !showPayslip && (
<BottomBar onLogout={handleBack} />
{/* 📄 Основной контент с отступом сверху */}
<div style={{
paddingTop: showTopBar ? topBarHeight : 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}}>
{renderContent()}
</div>
{/* 📊 НИЖНЯЯ ПАНЕЛЬ: КУРСЫ ВАЛЮТ */}
{showTopBar && (
<BottomBar />
)}
</div>
);
+314 -517
View File
@@ -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<BottomBarProps> = ({ onLogout }) => {
const BottomBar: React.FC = () => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>([
{ currency: 'USD', rate: 3.22, change: 0.5 },
{ currency: 'EUR', rate: 3.45, change: -0.2 },
@@ -43,135 +30,12 @@ const BottomBar: React.FC<BottomBarProps> = ({ onLogout }) => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isPaused, setIsPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(new Date());
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
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<string>('');
const scrollRef = useRef<HTMLDivElement>(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<BottomBarProps> = ({ 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<BottomBarProps> = ({ onLogout }) => {
const handleTouchStart = () => setIsPaused(true);
const handleTouchEnd = () => setIsPaused(false);
// ============================================
// 🚪 ВЫХОД
// ============================================
const handleLogout = () => {
if (onLogout) {
onLogout();
}
};
// ============================================
// 🎨 ИКОНКА ПОГОДЫ
// ============================================
const getWeatherIcon = (iconCode: string) => {
const icons: Record<string, string> = {
'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<BottomBarProps> = ({ 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<BottomBarProps> = ({ onLogout }) => {
const doubledItems = [...items, ...items, ...items];
// ============================================
// 🎨 РЕНДЕР
// 🔄 ИНДИКАТОР ЗАГРУЗКИ
// ============================================
if (loading) {
return (
<div style={{
position: 'fixed',
bottom: 5,
left: 0,
right: 0,
background: 'rgba(44, 62, 80, 0.95)',
color: 'rgba(255,255,255,0.5)',
padding: '10px 20px',
textAlign: 'center',
fontSize: '12px',
zIndex: 1000,
borderTop: '1px solid rgba(255,255,255,0.1)',
height: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
⏳ Загрузка курсов...
</div>
);
}
// ============================================
// 🎨 ОСНОВНОЙ РЕНДЕР
// ============================================
return (
<>
{/* 🌤️ ПОЛОСКА ПОГОДЫ */}
<AnimatePresence>
{isOnline && weatherData.length > 0 && !weatherLoading && !weatherError && (
<motion.div
initial={{ y: -30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -30, opacity: 0 }}
transition={{ duration: 0.3 }}
style={{
position: 'fixed',
bottom: '44px',
left: 0,
right: 0,
zIndex: 999,
background: 'rgba(44, 62, 80, 0.85)',
backdropFilter: 'blur(10px)',
color: '#fff',
padding: '4px 20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
overflow: 'hidden',
height: '32px',
borderTop: '1px solid rgba(255,255,255,0.05)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '12px',
fontFamily: 'monospace',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0,
background: 'rgba(44, 62, 80, 0.85)',
paddingRight: '12px',
}}>
<span style={{ fontSize: '16px' }}>🌤️</span>
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>
ГРОДНО
</span>
<div style={{
width: '1px',
height: '20px',
background: 'rgba(255,255,255,0.1)',
marginLeft: '4px',
}} />
</div>
<div
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-50%'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '30px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
paddingRight: '30px',
height: '100%',
alignItems: 'center',
}}
>
{[...weatherData, ...weatherData].map((day, index) => (
<div
key={`${day.date}-${index}`}
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
<span style={{ fontSize: '13px', fontWeight: 500 }}>
{day.dayOfWeek}
</span>
<span style={{ fontSize: '16px' }}>
{getWeatherIcon(day.icon)}
</span>
<span style={{ color: 'rgba(255,255,255,0.7)' }}>
{Math.round(day.tempMin)}°…{Math.round(day.tempMax)}°
</span>
</div>
))}
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 📊 ОСНОВНАЯ ПОЛОСКА (курсы валют) */}
<motion.div
className="bottom-bar"
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(44, 62, 80, 0.95)',
backdropFilter: 'blur(10px)',
color: '#fff',
padding: '8px 20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
overflow: 'hidden',
zIndex: 1000,
height: '44px',
borderTop: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px',
fontFamily: 'monospace',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Статус */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
zIndex: 2,
background: 'rgba(44, 62, 80, 0.95)',
paddingRight: '12px',
<motion.div
className="bottom-bar"
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
bottom: 5,
left: 0,
right: 0,
background: 'rgba(44, 62, 80, 0.95)',
backdropFilter: 'blur(10px)',
color: '#fff',
padding: '0 20px',
display: 'flex',
alignItems: 'center',
gap: '16px',
overflow: 'hidden',
zIndex: 1000,
height: '50px',
borderTop: '1px solid rgba(255,255,255,0.1)',
fontSize: '18px',
fontFamily: 'monospace',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Статус и источник данных */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0,
zIndex: 2,
background: 'rgba(44, 62, 80, 0.95)',
paddingRight: '12px',
height: '100%',
}}>
<span style={{
color: isOnline ? '#2ecc71' : '#e74c3c',
fontSize: '10px',
}}>
<span style={{
color: isOnline ? '#2ecc71' : '#e74c3c',
fontSize: '12px',
}}>
{isOnline ? '●' : '○'}
</span>
<span style={{ fontWeight: 600, opacity: 0.7, fontSize: '11px' }}>КУРСЫ</span>
<div style={{
width: '1px',
height: '24px',
background: 'rgba(255,255,255,0.15)',
marginLeft: '4px',
}} />
</div>
{isOnline ? '●' : '○'}
</span>
<span style={{ fontWeight: 600, opacity: 0.6, fontSize: '10px' }}>КУРСЫ</span>
{/* ✅ Индикатор источника данных (без кнопки) */}
<span style={{
padding: '2px 8px',
background: dataSource === 'mysql' ? 'rgba(46, 204, 113, 0.2)' :
dataSource === 'excel' ? 'rgba(52, 152, 219, 0.2)' :
'rgba(255, 193, 7, 0.2)',
color: dataSource === 'mysql' ? '#2ecc71' :
dataSource === 'excel' ? '#3498db' :
'#f39c12',
border: '1px solid ' + (dataSource === 'mysql' ? 'rgba(46,204,113,0.3)' :
dataSource === 'excel' ? 'rgba(52,152,219,0.3)' :
'rgba(255,193,7,0.3)'),
borderRadius: '4px',
fontSize: '8px',
fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{dataSource === 'mysql' ? 'MySQL' :
dataSource === 'excel' ? '📊' :
'💾'}
</span>
<div style={{
width: '1px',
height: '20px',
background: 'rgba(255,255,255,0.15)',
marginLeft: '4px',
}} />
</div>
{/* Бегущая строка */}
<div
ref={scrollRef}
{/* Бегущая строка */}
<div
ref={scrollRef}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
alignItems: 'center',
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-33.33%'],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
flex: 1,
overflow: 'hidden',
position: 'relative',
height: '100%',
display: 'flex',
gap: '30px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '30px',
height: '100%',
alignItems: 'center',
maskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
WebkitMaskImage: 'linear-gradient(to right, transparent, black 3%, black 97%, transparent)',
}}
>
<motion.div
animate={{
x: isPaused ? 0 : ['0%', '-33.33%'],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: 'linear',
repeatType: 'loop',
}}
style={{
display: 'flex',
gap: '40px',
whiteSpace: 'nowrap',
willChange: 'transform',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'geometricPrecision',
paddingRight: '40px',
height: '100%',
alignItems: 'center',
}}
>
{doubledItems.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap',
flexShrink: 0,
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
textRendering: 'geometricPrecision',
}}
>
<span style={{
fontWeight: 600,
color: 'rgba(255,255,255,0.9)',
fontSize: '13px',
letterSpacing: '0.3px',
}}>
{item.label}
</span>
<span style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '13px',
letterSpacing: '0.2px',
}}>
{item.displayValue}
</span>
<span style={{
color: item.change >= 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)}%
</span>
{item.type === 'metal' && (
<span style={{
fontSize: '9px',
opacity: 0.3,
marginLeft: '-2px',
}}>
$
</span>
)}
</div>
))}
</motion.div>
</div>
{/* Время + Кнопка выхода */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
paddingLeft: '12px',
background: 'rgba(44, 62, 80, 0.95)',
zIndex: 2,
}}>
<div style={{
fontSize: '11px',
opacity: 0.4,
whiteSpace: 'nowrap',
fontSmooth: 'always',
WebkitFontSmoothing: 'antialiased',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})}
</div>
{onLogout && (
<button
onClick={handleLogout}
{doubledItems.map((item, index) => (
<div
key={`${item.id}-${index}`}
style={{
padding: '4px 14px',
background: 'rgba(231, 76, 60, 0.8)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500,
transition: 'all 0.3s ease',
whiteSpace: 'nowrap',
minHeight: '28px',
display: 'flex',
gap: '6px',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 1)';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(231, 76, 60, 0.8)';
e.currentTarget.style.transform = 'scale(1)';
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
<span style={{ fontSize: '14px' }}>⏻</span>
Выйти
</button>
)}
</div>
<span style={{
fontWeight: 600,
color: 'rgba(255,255,255,0.9)',
fontSize: '12px',
letterSpacing: '0.3px',
}}>
{item.label}
</span>
<span style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
letterSpacing: '0.2px',
}}>
{item.displayValue}
</span>
{item.type === 'metal' && (
<span style={{
fontSize: '8px',
opacity: 0.3,
marginLeft: '-2px',
}}>
{item.currencySymbol || 'Br'}
</span>
)}
<span style={{
color: item.change >= 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)}%
</span>
</div>
))}
</motion.div>
</div>
{/* Пауза */}
{isPaused && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '11px',
color: 'rgba(255,255,255,0.3)',
pointerEvents: 'none',
zIndex: 3,
background: 'rgba(0,0,0,0.5)',
padding: '2px 12px',
borderRadius: '4px',
backdropFilter: 'blur(5px)',
{/* Время обновления и источник */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
flexShrink: 0,
paddingLeft: '8px',
}}>
{lastUpdate && (
<span style={{
fontSize: '8px',
opacity: 0.3,
whiteSpace: 'nowrap',
}}>
⏸ Пауза
</div>
{lastUpdate}
</span>
)}
</motion.div>
</>
</div>
{/* Пауза */}
{isPaused && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '10px',
color: 'rgba(255,255,255,0.3)',
pointerEvents: 'none',
zIndex: 3,
background: 'rgba(0,0,0,0.5)',
padding: '2px 12px',
borderRadius: '4px',
backdropFilter: 'blur(5px)',
}}>
⏸ Пауза
</div>
)}
</motion.div>
);
};
+675 -237
View File
@@ -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(`<p>${row.desc} ${row.internal}${row.city ? ' ' + row.city : ''}</p>`);
}
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(/<img([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<img$1src="${ADMIN_SERVER_URL}$2"`)
.replace(/<video([^>]+)src=["'](\/uploads\/[^"']+)["']/g, `<video$1src="${ADMIN_SERVER_URL}$2"`);
}
function stripHtml(html: string): string {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
// ✅ Подсветка текста внутри HTML с сохранением разметки
function highlightTextInHtml(html: string, searchTerm: string): { html: string; count: number } {
if (!searchTerm.trim()) return { html, count: 0 };
const term = searchTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi');
const temp = document.createElement('div');
temp.innerHTML = html;
let matchCount = 0;
function walkNodes(node: Node): void {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
if (text.toLowerCase().includes(term.toLowerCase())) {
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match;
const tempRegex = new RegExp(escapedTerm, 'gi');
while ((match = tempRegex.exec(text)) !== null) {
if (match.index > 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<HTMLDivElement>(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 = `<table class="handbook-table">`;
tableHtml += `
<thead>
<tr>
<th>Описание</th>
<th>АТС предприятия</th>
<th>Городская АТС</th>
</tr>
</thead>
<tbody>`;
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 += `
<tr>
<td>${descHtml}</td>
<td>${internalHtml}</td>
<td>${cityHtml}</td>
</tr>`;
});
tableHtml += `</tbody></table>`;
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 (
<div ref={containerRef} className="handbook-content">
{renderedHtml.map((item, idx) => (
item.html ? <div key={idx} style={{ marginBottom: 16 }} dangerouslySetInnerHTML={{ __html: item.html }} /> : null
))}
</div>
);
};
// ============================================
// 🏠 Основной компонент Contacts
// ============================================
const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'map' | 'directory'>('directory');
const [showMapViewer, setShowMapViewer] = useState(false);
const [blocks, setBlocks] = useState<any[]>([]);
const [loadingHandbook, setLoadingHandbook] = useState(false);
const [errorHandbook, setErrorHandbook] = useState<string | null>(null);
const [matchCount, setMatchCount] = useState(0);
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (matchCount > 0) {
goToNextMatch();
}
inputRef.current?.blur();
}
};
useEffect(() => {
setCurrentMatchIndex(0);
}, [searchTerm]);
if (showMapViewer) {
return (
<MapViewer
fileUrl={MAP_FILE_URL}
onBack={handleMapBack}
title="Карта предприятия"
/>
);
return <MapViewer fileUrl={MAP_FILE_URL} onBack={handleMapBack} title="Карта предприятия" />;
}
return (
@@ -65,17 +379,12 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
maxWidth: '1200px',
margin: '0 auto',
paddingBottom: '80px',
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 80px)',
}}
>
{/* Верхняя панель */}
<div className="top-bar" style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '25px',
flexWrap: 'wrap',
gap: '10px'
}}>
<div className="top-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '25px', flexWrap: 'wrap', gap: '10px', flexShrink: 0 }}>
<button
className="back-button"
onClick={onBack}
@@ -114,7 +423,6 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
<div style={{ width: '100px' }} />
</div>
{/* Карточка карты */}
<motion.div
whileHover={{ y: -5, boxShadow: '0 8px 30px rgba(46, 204, 113, 0.3)' }}
whileTap={{ scale: 0.98 }}
@@ -131,53 +439,30 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '80px',
flexShrink: 0,
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
}}>
<div style={{
fontSize: '36px',
color: 'white',
}}>
🗺️
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div style={{ fontSize: '36px', color: 'white' }}>🗺️</div>
<div>
<h3 style={{
margin: '0 0 5px 0',
color: 'white',
fontSize: '20px',
fontWeight: 500,
}}>
<h3 style={{ margin: '0 0 5px 0', color: 'white', fontSize: '20px', fontWeight: 500 }}>
Карта предприятия
</h3>
<p style={{
margin: 0,
color: 'rgba(255,255,255,0.85)',
fontSize: '14px',
}}>
<p style={{ margin: 0, color: 'rgba(255,255,255,0.85)', fontSize: '14px' }}>
Схема проезда, план здания, расположение корпусов
</p>
</div>
</div>
<div style={{
color: 'white',
fontSize: '20px',
opacity: 0.8,
}}>
→
</div>
<div style={{ color: 'white', fontSize: '20px', opacity: 0.8 }}>→</div>
</motion.div>
{/* Вкладки */}
<div style={{
display: 'flex',
gap: '10px',
marginBottom: '25px',
borderBottom: '2px solid #f0f0f0',
paddingBottom: '10px',
flexShrink: 0,
}}>
<button
onClick={() => setActiveTab('directory')}
@@ -213,176 +498,329 @@ const Contacts: React.FC<ContactsProps> = ({ onBack }) => {
</button>
</div>
{/* Контент вкладок */}
{activeTab === 'directory' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{/* Экстренные номера */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
marginBottom: '25px',
}}>
{emergencyNumbers.map((item, index) => (
<motion.div
key={index}
whileHover={{ scale: 1.05 }}
style={{
background: '#e74c3c',
color: 'white',
padding: '15px',
borderRadius: '12px',
textAlign: 'center',
cursor: 'pointer',
boxShadow: '0 4px 15px rgba(231, 76, 60, 0.3)',
}}
>
<div style={{ fontSize: '24px', marginBottom: '5px' }}>{item.icon}</div>
<div style={{ fontSize: '13px', fontWeight: 500 }}>{item.name}</div>
<div style={{ fontSize: '20px', fontWeight: 700 }}>{item.phone}</div>
</motion.div>
))}
</div>
{/* Поиск */}
<div style={{
background: 'white',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
marginBottom: '20px',
}}>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{activeTab === 'directory' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<div style={{
background: 'white',
borderRadius: '12px',
padding: '16px 20px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
marginBottom: '20px',
flexShrink: 0,
display: 'flex',
gap: '10px',
alignItems: 'center',
gap: '12px',
background: '#f8f9fa',
borderRadius: '10px',
padding: '8px 16px',
border: '2px solid #dfe6e9',
flexWrap: 'wrap',
}}>
🔍
<input
type="text"
placeholder="Поиск по справочнику..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
flex: 1,
border: 'none',
background: 'transparent',
padding: '10px 0',
fontSize: '15px',
outline: 'none',
}}
/>
</div>
</div>
{/* Таблица */}
<div style={{
background: 'white',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ background: '#f8f9fa', borderBottom: '2px solid #f1f2f6' }}>
<tr>
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>ФИО</th>
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>Должность</th>
<th style={{ padding: '15px 20px', textAlign: 'left', fontSize: '13px', color: '#636e72' }}>Телефон</th>
</tr>
</thead>
<tbody>
{filteredData.map((item, index) => (
<motion.tr
key={index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.05 }}
style={{
borderBottom: '1px solid #f1f2f6',
transition: 'background 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#f8f9fa'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<td style={{ padding: '12px 20px', fontSize: '14px' }}>{item.name}</td>
<td style={{ padding: '12px 20px', fontSize: '14px', color: '#636e72' }}>{item.position}</td>
<td style={{ padding: '12px 20px', fontSize: '14px', color: '#6c5ce7', fontWeight: 500 }}>
📞 {item.phone}
</td>
</motion.tr>
))}
</tbody>
</table>
{filteredData.length === 0 && (
<div style={{ padding: '40px', textAlign: 'center', color: '#636e72' }}>
Ничего не найдено
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#f8f9fa',
borderRadius: '10px',
padding: '4px 16px',
border: '2px solid #dfe6e9',
flex: 1,
minWidth: '200px',
}}>
🔍
<input
ref={inputRef}
type="text"
inputMode="search"
autoFocus
placeholder="Поиск по справочнику..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
onKeyPress={handleKeyPress}
style={{
flex: 1,
border: 'none',
background: 'transparent',
padding: '10px 0',
fontSize: '15px',
outline: 'none',
}}
/>
</div>
)}
</div>
</motion.div>
)}
{activeTab === 'map' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '60px 40px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px dashed #dfe6e9',
minHeight: '300px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleOpenMap}
onMouseEnter={(e) => {
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)';
}}
>
<div style={{ fontSize: '64px', marginBottom: '15px' }}>🗺️</div>
<h3 style={{ color: '#2d3436', marginBottom: '10px', fontSize: '22px' }}>
Карта предприятия
</h3>
<p style={{ color: '#636e72', marginBottom: '20px', fontSize: '15px' }}>
Нажмите для просмотра карты в полноэкранном режиме
</p>
<div style={{
display: 'inline-block',
padding: '12px 30px',
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
color: 'white',
borderRadius: '10px',
fontSize: '15px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}>
Открыть карту →
</div>
</motion.div>
)}
{searchTerm.trim() && matchCount > 0 && (
<span style={{
fontSize: '13px',
color: '#636e72',
whiteSpace: 'nowrap',
fontWeight: 500,
}}>
{currentMatchIndex + 1} / {matchCount}
</span>
)}
<div style={{
display: 'flex',
gap: '6px',
alignItems: 'center',
}}>
<button
onClick={goToPrevMatch}
disabled={matchCount === 0}
style={{
padding: '8px 12px',
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
color: matchCount > 0 ? 'white' : '#b2bec3',
border: 'none',
borderRadius: '8px',
cursor: matchCount > 0 ? 'pointer' : 'default',
fontSize: '14px',
transition: 'all 0.3s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '40px',
minHeight: '40px',
}}
onMouseEnter={(e) => {
if (matchCount > 0) {
e.currentTarget.style.transform = 'scale(1.05)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<FaArrowUp size={16} />
</button>
<button
onClick={goToNextMatch}
disabled={matchCount === 0}
style={{
padding: '8px 12px',
background: matchCount > 0 ? 'linear-gradient(135deg, #2ecc71, #27ae60)' : '#dfe6e9',
color: matchCount > 0 ? 'white' : '#b2bec3',
border: 'none',
borderRadius: '8px',
cursor: matchCount > 0 ? 'pointer' : 'default',
fontSize: '14px',
transition: 'all 0.3s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '40px',
minHeight: '40px',
}}
onMouseEnter={(e) => {
if (matchCount > 0) {
e.currentTarget.style.transform = 'scale(1.05)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<FaArrowDown size={16} />
</button>
</div>
</div>
{/* ✅ БЕЛЫЙ ФОН, ТЁМНЫЙ ТЕКСТ */}
<div
ref={contentRef}
style={{
flex: 1,
overflowY: 'auto',
background: '#ffffff',
borderRadius: '16px',
padding: '24px 28px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06)',
border: '1px solid #f1f2f6',
}}
>
{loadingHandbook ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{
display: 'inline-block',
width: '40px',
height: '40px',
border: '3px solid #f3f3f3',
borderTop: '3px solid #6c5ce7',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
<p style={{ color: '#636e72' }}>Загрузка справочника...</p>
</div>
) : errorHandbook ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<p style={{ color: '#e74c3c' }}>Ошибка загрузки: {errorHandbook}</p>
<button
onClick={loadHandbook}
style={{
padding: '8px 20px',
background: '#6c5ce7',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
marginTop: '10px',
}}
>
Повторить
</button>
</div>
) : blocks.length > 0 ? (
<HandbookRenderer
blocks={blocks}
searchTerm={searchTerm}
onMatchCount={(count) => {
setMatchCount(count);
}}
/>
) : (
<p style={{ textAlign: 'center', color: '#b2bec3' }}>Нет данных для отображения</p>
)}
</div>
</motion.div>
)}
{activeTab === 'map' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
style={{
background: 'white',
borderRadius: '16px',
padding: '60px 40px',
boxShadow: '0 4px 20px rgba(0,0,0,0.06)',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
border: '2px dashed #dfe6e9',
minHeight: '300px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
}}
onClick={handleOpenMap}
onMouseEnter={(e) => {
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)';
}}
>
<div style={{ fontSize: '64px', marginBottom: '15px' }}>🗺️</div>
<h3 style={{ color: '#2d3436', marginBottom: '10px', fontSize: '22px' }}>
Карта предприятия
</h3>
<p style={{ color: '#636e72', marginBottom: '20px', fontSize: '15px' }}>
Нажмите для просмотра карты в полноэкранном режиме
</p>
<div style={{
display: 'inline-block',
padding: '12px 30px',
background: 'linear-gradient(135deg, #2ecc71, #27ae60)',
color: 'white',
borderRadius: '10px',
fontSize: '15px',
fontWeight: 500,
transition: 'all 0.3s ease',
}}>
Открыть карту →
</div>
</motion.div>
)}
</div>
{/* ✅ ТЁМНЫЙ ТЕКСТ ДЛЯ СПРАВОЧНИКА */}
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.handbook-content {
font-size: 14px;
line-height: 1.6;
color: #2d3436;
}
.handbook-content h1, .handbook-content h2, .handbook-content h3, .handbook-content h4 {
margin: 16px 0 8px 0;
color: #2d3436;
}
.handbook-content p {
margin: 8px 0;
color: #2d3436;
}
.handbook-content ul, .handbook-content ol {
padding-left: 24px;
}
.handbook-content li {
margin: 4px 0;
color: #2d3436;
}
.handbook-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
color: #2d3436;
}
.handbook-content th, .handbook-content td {
border: 1px solid #dfe6e9;
padding: 8px 12px;
text-align: left;
color: #2d3436;
}
.handbook-content th {
background: #f8f9fa;
font-weight: 600;
}
.handbook-content a {
color: #2d3436;
text-decoration: underline;
}
.handbook-content strong, .handbook-content b {
color: #2d3436;
}
.handbook-content mark {
background: #ffdd57;
color: #1a3c34;
padding: 0 4px;
border-radius: 3px;
font-weight: 600;
transition: outline 0.3s ease;
}
mark[data-match-index] {
cursor: pointer;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f2f6;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #b2bec3;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6c5ce7;
}
`}
</style>
</motion.div>
);
};
+70 -6
View File
@@ -9,7 +9,10 @@ const MainMenu: React.FC<MainMenuProps> = ({ 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<MainMenuProps> = ({ onSectionSelect, employee }) => {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
padding: '10px',
marginTop: '-5vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
{employee && (
<div style={{
color: '#fff',
fontSize: '1.2rem',
fontSize: '1.5rem',
marginBottom: '10px',
}}>
Здравствуйте, {employee.ima} {employee.famaly}
Приветствуем Вас, {employee.ima} {employee.famaly}
</div>
)}
<h1 style={{
@@ -49,14 +53,74 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 870px))',
gap: '25px',
maxWidth: '900px',
width: '100%',
//height: '100%',
justifyContent: 'center',
}}>
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionSelect(item.id)}
style={{
background: 'linear-gradient(135deg, #1b718b 0%, #7ec87b 100%)',
borderRadius: '16px',
padding: '25px 30px',
marginBottom: '25px',
cursor: 'pointer',
transition: 'all 0.3s ease',
boxShadow: '0 4px 20px rgba(198, 231, 212, 0.25)',
display: 'inline-flex',
justifyContent: 'flex-start',
alignItems: 'center',
minHeight: '80px',
fontSize: '6.0rem',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 12px 40px rgba(0,0,0,0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 8px 30px rgba(0,0,0,0.12)';
}}
>
<div>{item.emoji}</div>
<span style={{
fontSize: '1rem',
fontWeight: 500,
color: '#2d3436',
textAlign: 'center',
}}>
<div>
<h3 style={{
color: 'white',
fontSize: '25px',
fontWeight: 600,
}}>
Расчетный лист
</h3>
</div>
</span>
</button>
))}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 200px))',
gap: '15px',
maxWidth: '900px',
width: '100%',
//height: '100%',
justifyContent: 'center',
marginTop: '15px'
}}>
{menuItems_2.map((item) => (
<button
key={item.id}
onClick={() => onSectionSelect(item.id)}
@@ -73,7 +137,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSectionSelect, employee }) => {
cursor: 'pointer',
boxShadow: '0 8px 30px rgba(0,0,0,0.12)',
minHeight: '140px',
fontSize: '2.5rem',
fontSize: '3.5rem',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
+23 -2
View File
@@ -25,15 +25,26 @@ const renderIcon = (Icon: any, style?: React.CSSProperties) => {
const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
const [activeTab, setActiveTab] = useState('about');
const [isEditing, setIsEditing] = useState(false);
// ✅ ФИО из базы
const fullName = employee ? `${employee.famaly} ${employee.ima} ${employee.otch}` : 'Сотрудник не найден';
// ✅ Табельный номер из базы
const tnom = employee?.tnom || '—';
// ✅ Телефон из базы (если есть, иначе заглушка)
const phone = employee?.phone || '+7 (999) 123-45-67';
const [formData, setFormData] = useState({
fullName: employee?.fullName || 'Иванов Иван Иванович',
fullName: fullName,
position: employee?.position || 'Инженер-технолог',
department: employee?.department || 'Цех №5',
phone: employee?.phone || '+7 (999) 123-45-67',
phone: phone,
email: employee?.email || 'ivanov@company.ru',
address: employee?.address || 'г. Москва, ул. Ленина, д. 1, кв. 10',
birthDate: employee?.birthDate || '1985-06-15',
education: employee?.education || 'Высшее, МГУ им. Ломоносова, химический факультет',
tnom: tnom,
});
const tabs: Tab[] = [
@@ -107,6 +118,15 @@ const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
{/* ✅ Табельный номер из базы */}
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Табельный номер</div>
<div style={{ fontSize: '15px', color: '#2d3436', fontWeight: 500 }}>
{formData.tnom}
</div>
</div>
{/* ✅ Телефон из базы */}
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Телефон</div>
{isEditing ? (
@@ -126,6 +146,7 @@ const Profile: React.FC<ProfileProps> = ({ onBack, employee }) => {
<div style={{ fontSize: '15px', color: '#2d3436' }}>{formData.phone}</div>
)}
</div>
<div>
<div style={{ fontSize: '13px', color: '#b2bec3', marginBottom: '4px' }}>Email</div>
{isEditing ? (
+395
View File
@@ -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<WeatherWidgetProps> = ({ onLogout }) => {
const [weatherData, setWeatherData] = useState<WeatherDay[]>([]);
const [currentWeather, setCurrentWeather] = useState<WeatherDay | null>(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<string, string> = {
'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 (
<motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'rgba(44, 62, 80, 0.75)', // ✅ полупрозрачный
backdropFilter: 'blur(12px)',
padding: '5px 15px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: '70px',
boxShadow: '0 2px 20px rgba(0,0,0,0.15)',
}}
>
{/* Левая часть: Время */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexShrink: 0,
minWidth: '140px',
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}>
<span style={{
fontSize: '30px',
fontWeight: 700,
color: '#fff',
letterSpacing: '0.5px',
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}
</span>
<span style={{
fontSize: '12px',
color: 'rgba(255,255,255,0.5)',
letterSpacing: '0.3px',
fontWeight: 400,
}}>
{currentTime.toLocaleString('ru-RU', {
timeZone: 'Europe/Minsk',
weekday: 'short',
day: 'numeric',
month: 'short',
})}
</span>
</div>
</div>
{/* Центральная часть: Погода */}
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 16px',
minHeight: '56px',
}}>
<AnimatePresence mode="wait">
{isOnline && !loading && currentWeather ? (
<motion.div
key="weather"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
background: 'rgba(255,255,255,0.08)',
padding: '4px 16px 4px 10px',
borderRadius: '30px',
border: '1px solid rgba(255,255,255,0.06)',
}}>
<span style={{ fontSize: '32px', lineHeight: 1 }}>
{getWeatherIcon(currentWeather.icon)}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: '18px',
fontWeight: 600,
color: '#fff',
}}>
{Math.round(currentWeather.tempMax)}°
</span>
<span style={{
fontSize: '11px',
color: 'rgba(255,255,255,0.5)',
}}>
{currentWeather.description}
</span>
</div>
</div>
<div style={{
display: 'flex',
gap: '10px',
overflow: 'hidden',
}}>
{weatherData.slice(1, 7).map((day, index) => (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
padding: '2px 8px',
borderRadius: '6px',
background: 'rgba(255,255,255,0.04)',
minWidth: '40px',
}}
>
<span style={{
fontSize: '9px',
color: 'rgba(255,255,255,0.5)',
fontWeight: 500,
}}>
{day.dayOfWeek}
</span>
<span style={{ fontSize: '16px', lineHeight: 1 }}>
{getWeatherIcon(day.icon)}
</span>
<span style={{
fontSize: '10px',
fontWeight: 600,
color: 'rgba(255,255,255,0.8)',
}}>
{Math.round(day.tempMax)}°
</span>
</motion.div>
))}
</div>
</motion.div>
) : (
<motion.div
key="placeholder"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
color: 'rgba(255,255,255,0.3)',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Правая часть: Кнопка выхода */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexShrink: 0,
minWidth: '80px',
justifyContent: 'flex-end',
}}>
{onLogout && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleLogout}
style={{
padding: '6px 16px',
background: 'rgba(231, 76, 60, 0.7)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
transition: 'all 0.2s',
whiteSpace: 'nowrap',
minHeight: '34px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
backdropFilter: 'blur(4px)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'rgba(255,255,255,0.1)',
boxShadow: '0 2px 10px rgba(231, 76, 60, 0.2)',
}}
onMouseEnter={(e) => {
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)';
}}
>
<span style={{ fontSize: '16px' }}>⏻</span>
Выйти
</motion.button>
)}
</div>
</motion.div>
);
};
export default WeatherWidget;
+1 -1
View File
@@ -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"}
{"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"}