admin_to_react

This commit is contained in:
2026-08-09 14:13:06 +03:00
commit 5d2fc5773c
163 changed files with 140326 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+46
View File
@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
Binary file not shown.
+24221
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"main": "public/electron.js",
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"electron-is-dev": "^2.0.0",
"fs": "^0.0.1-security",
"http": "^0.0.1-security",
"https": "^1.0.0",
"mammoth": "^1.9.1",
"os": "^0.1.2",
"path": "^0.12.7",
"pdfjs-dist": "2.16.105",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-pdf": "6.2.2",
"react-scripts": "5.0.1",
"serialport": "^13.0.0",
"stream": "^0.0.3",
"textract": "^2.5.0",
"typescript": "^4.8.4",
"util": "^0.12.5",
"web-vitals": "^2.1.4",
"zlib": "^1.0.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"electron-dev": "concurrently \"npm start\" \"wait-on http://localhost:3000 && electron .\"",
"electron-pack": "electron-builder -c.extraMetadata.main=build/electron.js",
"preelectron-pack": "npm run build",
"rebuild": "electron-rebuild -f -w nfc-pcsc"
},
"build": {
"appId": "com.example.electron-react",
"files": [
"build/**/*",
"node_modules/**/*"
],
"directories": {
"buildResources": "assets"
},
"extraResources": [
{
"from": "node_modules/pdfjs-dist/build/pdf.worker.min.mjs",
"to": "pdf.worker.mjs",
"filter": [
"**/*"
]
}
]
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react-pdf": "^6.2.0",
"buffer": "^6.0.3",
"concurrently": "^7.5.0",
"crypto-browserify": "^3.12.1",
"electron": "^21.2.0",
"electron-builder": "^23.6.0",
"electron-rebuild": "^3.2.9",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"querystring-es3": "^0.2.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.4",
"wait-on": "^6.0.1"
}
}
+7
View File
@@ -0,0 +1,7 @@
# Документы
Поместите ваш файл справочника с именем `handbook.docx` в эту папку.
Структура:
- `handbook.docx` - основной справочник
- Другие документы можно добавить по необходимости
+2
View File
@@ -0,0 +1,2 @@
COM_PORT=COM6
COM_SPEED=9600
+201
View File
@@ -0,0 +1,201 @@
const { app, BrowserWindow, protocol, ipcMain, globalShortcut } = require('electron');
const path = require('path');
require('dotenv').config({path: path.join(__dirname, 'config.env')})
const isDev = require('electron-is-dev');
const fs = require('fs');
const COM_PORT = process.env.COM_PORT;
const COM_SPEED = process.env.COM_SPEED;
const { SerialPort } = require('serialport');
const axios = require('axios');
const { logger } = require('../../backend/src/config/logger');
const config = require('../src/config.json')
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
corsEnabled: true
}
}
]);
if (!Promise.withResolvers) {
Promise.withResolvers = function () {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
let mainWindow;
let appConfig = {};
const configPath = isDev
? path.join(__dirname, '../src/config.json')
: path.join(process.resourcesPath, 'config.json');
try {
logger.info(`[Config] Trying to load config from: ${configPath}`);
if (fs.existsSync(configPath)) {
appConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
logger.info('[Config] External config loaded successfully:', appConfig);
} else {
logger.warn('[Config] External config file not found. Using default values.');
}
} catch (error) {
logger.error('[Config] Could not load external config file:', error);
}
global.allowClose = false;
function createWindow() {
const preloadPath = path.join(__dirname, 'preload.js');
try {
fs.accessSync(preloadPath);
logger.debug(`[DEBUG] Preload script found at: ${preloadPath}`);
} catch (e) {
logger.error(`[FATAL] Preload script not found at: ${preloadPath}`);
app.quit();
return;
}
mainWindow = new BrowserWindow({
kiosk: true,
titleBarStyle:'hidden',
alwaysOnTop: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false
},
});
mainWindow.loadURL(
isDev
? `${config.frontUrl}`
: `file://${path.join(__dirname, '../build/index.html')}`
);
mainWindow.on('close', (e) => {
if (!global.allowClose) {
e.preventDefault();
}
});
mainWindow.on('minimize', (e) => {
e.preventDefault();
});
}
app.whenReady().then(() => {
createWindow();
global.allowMinimize = false;
globalShortcut.register('Control+Alt+Backspace', () => {
global.allowMinimize = true;
if (mainWindow) mainWindow.minimize();
});
mainWindow.on('minimize', function (event) {
if (global.allowMinimize) {
global.allowMinimize = false;
return;
}
event.preventDefault();
setTimeout(() => {
if (mainWindow.isMinimized()) {
mainWindow.restore();
mainWindow.focus();
}
}, 10);
});
globalShortcut.register('Control+Alt+Enter', () => {
global.allowClose = true;
app.quit();
});
globalShortcut.register('F11', () => {
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
//считыватель
SerialPort.list().then(ports => {
logger.info('Доступные порты:', ports.map(p => p.path));
}).catch(err => {
logger.error('Ошибка при получении списка портов:', err);
});
try {
const port = new SerialPort({ path: COM_PORT, baudRate: parseInt(COM_SPEED, 10)});
let buffer = '';
port.on('open', () => {
logger.info(' Serial port открыт');
});
port.on('data', (data) => {
logger.info('RAW DATA:', data);
buffer += data.toString('utf8');
if (buffer.includes('\n')) {
const lines = buffer.split('\n');
lines.forEach((line, idx) => {
if (idx < lines.length - 1) {
const uid = line.trim().replace(/\D/g, '');
if (uid) {
console.log(`UID карты: ${uid}`);
console.log('[DEBUG] Отправляю событие на admin-server:', {
event: 'CARD_PLACED',
uid: uid,
timestamp: Date.now()
});
axios.post(`${config.adminUrl}}/api/card-event`, {
event: 'CARD_PLACED',
uid: uid,
timestamp: Date.now()
}).catch(err => {
console.error('[ADMIN][CARD_LOG] Ошибка отправки:', err.message);
});
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('card-detected', uid);
console.log(` Отправка события 'card-detected' в рендерер с UID: ${uid}`);
} else {
console.warn('mainWindow или webContents не определены, событие не отправлено');
}
}
}
});
buffer = lines[lines.length - 1];
}
});
port.on('error', (err) => {
console.error('Ошибка serialport:', err);
});
} catch (err) {
console.error('!!! FATAL: FAILED TO INITIALIZE SERIALPORT !!!', err);
}
ipcMain.handle('get-app-config', async (event) => {
console.log('[IPC] Renderer requested app config. Sending:', appConfig);
return appConfig;
});
app.whenReady().then(() => {
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Инфокиоск</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<style>
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
</style>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
Binary file not shown.
Binary file not shown.
+75390
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
const { logger } = require('../../backend/src/config/logger');
logger.info('[Preload] Script started.');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
'electronAPI',
{
getAppConfig: () => ipcRenderer.invoke('get-app-config'),
onCardDetected: (callback) => {
ipcRenderer.on('card-detected', (event, uid) => callback(uid));
},
removeCardDetectedListeners: () => {
ipcRenderer.removeAllListeners('card-detected');
}
}
);
logger.log('[Preload] Script finished. electronAPI exposed.');
window.dispatchEvent(new Event('electronApiReady'));
+4
View File
@@ -0,0 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+99
View File
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import './styles/main.css';
import LoginWindow from './components/LoginWindow';
import EmployeeInfo from './components/EmployeeInfo';
import GuestMode from './components/GuestMode';
import PayslipViewer from './components/PayslipViewer';
import config from './config.json';
interface Employee {
id: string;
tnom: string;
famaly: string;
ima: string;
otch: string;
category: string;
organization: string;
identNumber: string;
}
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isGuestMode, setIsGuestMode] = useState(false);
const [employee, setEmployee] = useState<Employee | null>(null);
const [showPayslip, setShowPayslip] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState('');
const [loginError, setLoginError] = useState('');
const handleLogin = async (id: string) => {
try {
const isCardUid = /^\d{7}$/.test(id);
const url = isCardUid
? `${config.backendUrl}/api/employee/card/${id}`
: `${config.backendUrl}/api/employee/${id}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
setEmployee(data);
setIsLoggedIn(true);
setLoginError('');
} else {
setLoginError('Сотрудник не найден');
}
} catch (error) {
console.error('Error:', error);
setLoginError('Ошибка при получении данных');
}
};
const handleGuestMode = () => {
setIsGuestMode(true);
};
const handleBack = () => {
setIsLoggedIn(false);
setEmployee(null);
setIsGuestMode(false);
setShowPayslip(false);
setSelectedPeriod('');
};
const handleViewPayslip = (period: string) => {
setSelectedPeriod(period);
setShowPayslip(true);
};
const handlePayslipBack = () => {
setShowPayslip(false);
setSelectedPeriod('');
};
const getPayslipUrl = (employee: Employee, period: string) => {
const fileName = `${employee.tnom}.pdf`;
return `${config.backendUrl}/api/payslip/${period}/${fileName}`;
};
return (
<div className="app">
{!isLoggedIn && !isGuestMode ? (
<LoginWindow onLogin={handleLogin} onGuestMode={handleGuestMode} loginError={loginError} />
) : isGuestMode ? (
<GuestMode onBack={handleBack} />
) : showPayslip ? (
<PayslipViewer
fileUrl={employee && selectedPeriod ? getPayslipUrl(employee, selectedPeriod) : "/payslips/test.pdf"}
onBack={handlePayslipBack}
/>
) : employee ? (
<EmployeeInfo
employee={employee}
onViewPayslip={handleViewPayslip}
onBack={handleBack}
/>
) : null}
</div>
);
}
export default App;
+102
View File
@@ -0,0 +1,102 @@
import React, { useState } from 'react';
interface Employee {
id: string;
cardNumber?: string;
tnom: string;
famaly: string;
ima: string;
otch: string;
profession?: string;
category?: string;
departmentName?: string;
organization: string;
}
interface EmployeeInfoProps {
employee: Employee;
onViewPayslip: (period: string) => void;
onBack: () => void;
}
const EmployeeInfo: React.FC<EmployeeInfoProps> = ({
employee,
onViewPayslip,
onBack
}) => {
const [selectedPeriod, setSelectedPeriod] = useState(() => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${month}${year}`;
});
// Генерируем список периодов за последние 12 месяцев
const generatePeriods = () => {
const periods = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
periods.push(`${month}${year}`);
}
return periods;
};
const periods = generatePeriods();
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
return (
<div className="employee-info">
<div className="top-bar">
<button className="back-button" onClick={onBack}>
← Назад
</button>
</div>
<div className="employee-header">
<div className="employee-photo">
<div className="photo-placeholder">Фото</div>
</div>
<div className="employee-details">
<h2>{`${employee.famaly} ${employee.ima} ${employee.otch}`}</h2>
<p>Табельный номер: {employee.tnom}</p>
{employee.category?.trim() && <p>Категория: {employee.category}</p>}
{employee.departmentName && (
<p>Подразделение: {employee.departmentName}</p>
)}
<p>Организация: {employee.organization}</p>
{employee.profession && <p>Профессия: {employee.profession}</p>}
</div>
</div>
<div className="payslip-section">
<h3>Расчётный лист</h3>
<div className="period-selector">
<label htmlFor="period-select">Выберите период:</label>
<select
id="period-select"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
>
{periods.map(period => (
<option key={period} value={period}>
{monthNames[parseInt(period.substring(0, 2)) - 1]} {period.substring(2)}
</option>
))}
</select>
</div>
<button onClick={() => onViewPayslip(selectedPeriod)}>
Посмотреть расчётный лист
</button>
</div>
</div>
);
};
export default EmployeeInfo;
+241
View File
@@ -0,0 +1,241 @@
import React, { useState, useEffect } from 'react';
import config from "../../src/config.json";
interface GuestModeProps {
onBack: () => void;
}
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 HandbookBlocks: React.FC<{ html: string }> = ({ html }) => {
const [blocks, setBlocks] = useState<(string | { type: 'phones', rows: { desc: string, internal: string, city: string }[] })[]>([]);
useEffect(() => {
setBlocks(parseHandbookBlocks(html));
}, [html]);
return (
<>
{blocks.map((block, i) => {
if (typeof block === 'string') {
return <div key={i} dangerouslySetInnerHTML={{ __html: block }} />;
} else if (block.type === 'phones') {
return (
<table className="handbook-table" key={i} style={{ margin: '24px auto' }}>
<thead>
<tr>
<th>Описание</th>
<th>АТС предприятия</th>
<th>Городская АТС</th>
</tr>
</thead>
<tbody>
{block.rows.map((row, j) => (
<tr key={j}>
<td>{row.desc}</td>
<td>{row.internal}</td>
<td>{row.city}</td>
</tr>
))}
</tbody>
</table>
);
}
return null;
})}
</>
);
};
const ADMIN_SERVER_URL = config.adminServerUrl;
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"`);
}
const renderContentBlocks = (blocks: any[]) => (
<>
{blocks.map((block, i) => (
<div key={block.id || i} style={{marginBottom: 16}} dangerouslySetInnerHTML={{ __html: patchMediaSrc(block.content) }} />
))}
</>
);
const GuestMode: React.FC<GuestModeProps> = ({ onBack }) => {
const [activeTab, setActiveTab] = useState<'handbook' | 'union' | 'info'>('handbook');
const [handbookContent, setHandbookContent] = useState<string>('');
const [loading, setLoading] = useState(false);
const [infoBlocks, setInfoBlocks] = useState<any[]>([]);
const [unionBlocks, setUnionBlocks] = useState<any[]>([]);
useEffect(() => {
if (activeTab === 'handbook') {
loadHandbook();
} else if (activeTab === 'info') {
loadInfoBlocks();
} else if (activeTab === 'union') {
loadUnionBlocks();
}
// eslint-disable-next-line
}, [activeTab]);
const loadHandbook = async () => {
setLoading(true);
try {
const response = await fetch(`${config.backendUrl}/api/handbook/html`);
const html = await response.text();
setHandbookContent(html);
} catch (error) {
setHandbookContent('Ошибка загрузки справочника');
}
setLoading(false);
};
const loadInfoBlocks = async () => {
setLoading(true);
try {
const res = await fetch(`${ADMIN_SERVER_URL}/moders/content/info`);
const data = await res.json();
setInfoBlocks(Array.isArray(data) ? data : []);
} catch (e) {
setInfoBlocks([]);
}
setLoading(false);
};
const loadUnionBlocks = async () => {
setLoading(true);
try {
const res = await fetch(`${ADMIN_SERVER_URL}/moders/content/union`);
const data = await res.json();
setUnionBlocks(Array.isArray(data) ? data : []);
} catch (e) {
setUnionBlocks([]);
}
setLoading(false);
};
return (
<div className="guest-mode">
<div className="top-bar">
<button className="back-button" onClick={onBack}>
← Назад
</button>
</div>
<div className="guest-content">
<h2>Гостевой режим</h2>
<div className="tab-navigation">
<button
className={`tab-button ${activeTab === 'handbook' ? 'active' : ''}`}
onClick={() => setActiveTab('handbook')}
>
Справочник
</button>
<button
className={`tab-button ${activeTab === 'union' ? 'active' : ''}`}
onClick={() => setActiveTab('union')}
>
Профсоюз
</button>
<button
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
onClick={() => setActiveTab('info')}
>
Общая информация
</button>
</div>
<div className="tab-content">
{activeTab === 'handbook' && (
<div className="handbook-content">
{loading ? (
<div className="loading">Загрузка справочника...</div>
) : (
<HandbookBlocks html={handbookContent} />
)}
</div>
)}
{activeTab === 'union' && (
<div className="union-content">
{loading ? (
<div className="loading">Загрузка...</div>
) : (
unionBlocks.length ? renderContentBlocks(unionBlocks) : <p>Нет информации о профсоюзе.</p>
)}
</div>
)}
{activeTab === 'info' && (
<div className="info-content">
{loading ? (
<div className="loading">Загрузка...</div>
) : (
infoBlocks.length ? renderContentBlocks(infoBlocks) : <p>Нет общей информации.</p>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default GuestMode;
+105
View File
@@ -0,0 +1,105 @@
import React, { useEffect, useRef, useState } from 'react';
interface LoginWindowProps {
onLogin: (id: string) => void;
onGuestMode: () => void;
loginError?: string;
}
const LoginWindow: React.FC<LoginWindowProps> = ({ onLogin, onGuestMode, loginError }) => {
const [id, setId] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const testConnection = async () => {
try {
const response = await fetch('http://localhost:3001/api/test', {
mode: 'cors', // Явно указываем режим CORS
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
console.log('Backend response:', data);
} else {
console.error('Backend returned error status:', response.status);
}
} catch (err) {
console.error('Connection test failed:', err);
}
};
testConnection();
const setupCardListener = () => {
console.log('[LoginWindow] Setting up card listener...');
const handleCardDetected = (uid: string) => {
console.log('Card detected in renderer via preload:', uid);
onLogin(uid);
};
window.electronAPI.onCardDetected(handleCardDetected);
};
if (window.electronAPI) {
setupCardListener();
} else {
console.log('[LoginWindow] electronAPI not ready, waiting for event...');
window.addEventListener('electronApiReady', setupCardListener, { once: true });
}
return () => {
console.log('[LoginWindow] Cleaning up listeners.');
window.removeEventListener('electronApiReady', setupCardListener);
if (window.electronAPI) {
window.electronAPI.removeCardDetectedListeners();
}
};
}, [onLogin]);
useEffect(() => {
if (loginError) {
setId('');
inputRef.current?.focus();
}
}, [loginError]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (id.trim()) {
onLogin(id.trim());
}
};
return (
<div className="login-window">
<h2>Вход в систему</h2>
<div className="card-prompt">
<p>Пожалуйста, приложите вашу карту пропуска к считывателю</p>
<div className="card-icon">💳</div>
</div>
<form onSubmit={handleSubmit}>
<div className="input-group">
<label htmlFor="id"></label>
<input
ref={inputRef}
type="password"
id="id"
value={id}
onChange={(e) => setId(e.target.value)}
placeholder=" "
autoFocus
/>
</div>
{loginError && <div className="error" style={{ color: 'red', marginTop: 8 }}>{loginError}</div>}
<div className="button-group">
<button type="submit" disabled={!id.trim()}>
Войти
</button>
<button type="button" onClick={onGuestMode}>
Гостевой режим
</button>
</div>
</form>
</div>
);
};
export default LoginWindow;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
interface PayslipViewerProps {
fileUrl: string;
onBack: () => void;
}
const PayslipViewer: React.FC<PayslipViewerProps> = ({ fileUrl, onBack }) => {
const handlePrint = () => {
const win = window.open(fileUrl, '_blank');
if (win) {
win.focus();
win.print();
}
};
return (
<div className="payslip-viewer">
<div className="top-bar">
<button className="back-button" onClick={onBack}>
← Назад
</button>
<button className="print-button" onClick={handlePrint}>
🖨️ Печать
</button>
</div>
<div className="pdf-container" style={{
display: 'flex',
justifyContent: 'center',
width: '100%'
}}>
<Document file={fileUrl} loading="Загрузка PDF...">
<div style={{ margin: '0 auto' }}>
<Page pageNumber={1} />
</div>
</Document>
</div>
</div>
);
};
export default PayslipViewer;
+6
View File
@@ -0,0 +1,6 @@
const config = {
backendUrl: 'http://localhost:3001',
adminUrl: 'http://localhost:3005'
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
{
"backendUrl": "http://localhost:3001",
"adminUrl" : "http://localhost:3005",
"frontUrl" : "http://localhost:3000",
"adminServerUrl": "http://localhost:3005"
}
+23
View File
@@ -0,0 +1,23 @@
declare module '*.png';
declare module '*.svg';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.gif';
declare module '*.bmp';
declare module '*.tiff';
declare module 'fs';
declare module 'path';
declare module 'os';
interface Config {
backendUrl: string;
}
interface Window {
electronAPI: {
getAppConfig: () => Promise<Config>;
onCardDetected: (callback: (uid: string) => void) => void;
removeCardDetectedListeners: () => void;
};
}
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
declare global {
interface PromiseConstructor {
withResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
};
}
}
if (!Promise.withResolvers) {
Promise.withResolvers = function<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+154
View File
@@ -0,0 +1,154 @@
const ffi = require('ffi-napi');
const ref = require('ref-napi');
const Struct = require('ref-struct-napi');
const ArrayType = require('ref-array-napi');
const E_RG_ENDPOINT_TYPE = {
ET_UNKNOWN: 0,
ET_USBHID: 1,
ET_SERIAL: 2
};
const E_RG_STATUS_TYPE = {
STE_UNKNOWN: 0,
STE_NO_CARD: 1,
STE_CARD: 9,
STE_CARD_NO_AUTH: 10,
STE_CARD_AUTH: 26
};
const E_RG_CARD_FAMILY_CODE = {
CF_PIN: 1,
CF_TEMIC: 2,
CF_HID: 4,
CF_EM: 8,
CF_INDALA: 16,
CF_COTAG: 32,
CF_MIFARE: 64,
CF_INDALA_MT: 128,
CF_ALL: 255
};
const RG_CARD_AUTH_PARAMS = Struct({
flags: 'uint8',
classicKey: ArrayType('uint8', 6),
plusKey: ArrayType('uint8', 16)
});
const RG_CARD_MEMORY = Struct({
profile_block: 'uint8',
block_data: ArrayType('uint8', 16)
});
const E_API_ERROR_CODES = {
EC_OK: 0,
EC_FAIL: 1,
EC_NOT_IMPLEMENTED: 2,
EC_BAD_ARGUMENT: 3,
EC_INVALID_HANDLE: 4,
EC_INVALID_RESOURCE: 5,
EC_INVALID_CONNECTION_TYPE: 6,
EC_INVALID_CONNECTION_ADDRESS: 7,
EC_INVALID_DEVICE_ADDRESS: 8,
EC_DEVICE_OPERATION_UNSUPPORTED: 9,
EC_DEVICE_NOT_CONNECTED: 10,
EC_DEVICE_NO_RESPOND: 11,
EC_DEVICE_COMM_FAILURE: 12,
EC_DEVICE_PROTOCOL_FAILURE: 13,
EC_POLL_NO_EVENTS: 14,
EC_POLL_QUEUE_CLOSED: 15,
EC_CALL_INIT: 16,
EC_DEVICE_INVALID_COMMAND: 17,
EC_DEVICE_INVALID_PARAM: 18,
EC_DEVICE_INVALID_PIN: 19,
EC_DEVICE_COMMAND_TIMEOUT: 20,
EC_DEVICE_NO_CARD: 21,
EC_DEVICE_UOWN_CARD: 22,
EC_DEVICE_INCOMPATIBLE_CARD: 23,
EC_DEVICE_AUTH_FAIL: 24,
EC_DEVICE_PROFILE_FAIL: 25,
EC_DEVICE_RW_FAIL: 26,
EC_IO_OPEN_FAIL: 27,
EC_IO_CLOSE_FAIL: 28,
EC_IO_READ_FAIL: 29,
EC_IO_WRITE_FAIL: 30,
EC_IO_CLOSED: 31,
EC_DEVICE_IN_BOOT: 32,
EC_DEVICE_FW_INVALID_MODEL: 33,
EC_FILE_NOT_FOUND: 34,
EC_FINGERPRINT_UNFOUND: 35
};
const E_RG_DEVICE_EVENT_TYPE = {
DET_UNKNOWN_EVENT: 0,
DET_CARD_PLACED_EVENT: 2,
DET_CARD_REMOVED_EVENT: 4,
DET_RELAY_STATE_CHANGED: 8,
DET_TAMPER_STATE_CHANGED: 16,
DET_BUTTON_STATE_CHANGED: 32,
DET_DOOR_SENSOR_STATE_CHANGED: 64,
DET_POLL_ERROR: 128
};
// Структуры
const RG_ENDPOINT = Struct({
type: 'uint8',
address: 'string' // const char*
});
const RG_CARD_INFO = Struct({
type: 'uint8',
uid: ArrayType('uint8', 7)
});
const RG_ENDPOINT_INFO = Struct({
type: 'uint8',
address: ArrayType('char', 64),
friendly_name: ArrayType('char', 128)
});
const RG_DEVICE_INFO_EXT = Struct({
address: 'uint8',
serial: 'uint32',
firmwareUpdateLock: 'uint8',
type: 'uint8',
firmware: 'uint16',
capabilities: 'uint32'
});
// DLL
const rgsec = ffi.Library('librgsec.dll', {
'RG_InitializeLib': ['uint32', []],
'RG_Uninitialize': ['uint32', []],
'RG_FindEndPoints': ['uint32', ['pointer', 'uint8', 'pointer']],
'RG_GetFoundEndPointInfo': ['uint32', ['pointer', 'uint32', 'pointer']],
'RG_InitDevice': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8']],
'RG_SetCardsMask': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8', 'uint8']],
'RG_GetStatus': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8', 'pointer', 'pointer', ref.refType(RG_CARD_INFO), 'pointer']],
'RG_CloseResource': ['uint32', ['pointer']],
'RG_WriteProfile': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8', 'uint8', 'uint8', ref.refType(RG_CARD_AUTH_PARAMS)]],
'RG_ReadBlockDirect': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8', ref.refType(RG_CARD_MEMORY), ref.refType(RG_CARD_AUTH_PARAMS)]],
'RG_Subscribe': ['uint32', ['pointer', ref.refType(RG_ENDPOINT), 'uint8', 'uint32', 'pointer']],
'RG_PollEvents': ['uint32', ['pointer', 'pointer', 'pointer', 'uint32']],
'RG_IsolateDevice': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8']],
'RG_ResetIsolation': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8']],
'RG_CloseDevice': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8']],
'RG_GetInfoExt': ['uint32', [ref.refType(RG_ENDPOINT), 'uint8', 'pointer']],
'RG_Subscribe': ['uint32', ['pointer', ref.refType(RG_ENDPOINT), 'uint8', 'uint32', 'pointer']],
});
function errorToString(code) {
for (const [k, v] of Object.entries(E_API_ERROR_CODES)) {
if (v === code) return k;
}
return `Unknown error (${code})`;
}
module.exports = {
E_RG_ENDPOINT_TYPE,
E_RG_STATUS_TYPE,
E_RG_CARD_FAMILY_CODE,
E_API_ERROR_CODES,
E_RG_DEVICE_EVENT_TYPE,
RG_DEVICE_INFO_EXT,
RG_ENDPOINT,
RG_CARD_INFO,
RG_ENDPOINT_INFO,
rgsec,
errorToString
};
+209
View File
@@ -0,0 +1,209 @@
const ref = require('ref-napi');
const Struct = require('ref-struct-napi');
const {
E_RG_ENDPOINT_TYPE,
E_RG_DEVICE_EVENT_TYPE,
E_API_ERROR_CODES,
RG_ENDPOINT,
RG_ENDPOINT_INFO,
RG_CARD_INFO,
RG_DEVICE_INFO_EXT,
rgsec,
errorToString
} = require('./r5usb');
const axios = require('axios');
function readCString(buffer, size) {
const bytes = [];
for (let i = 0; i < size; i++) {
const byte = buffer.readUInt8(i);
if (byte === 0) break;
bytes.push(byte);
}
return Buffer.from(bytes).toString('utf8');
}
async function main() {
console.log('Старт программы');
const initRes = rgsec.RG_InitializeLib();
console.log('После RG_InitializeLib, код:', initRes);
if (initRes !== E_API_ERROR_CODES.EC_OK) {
console.error('RG_InitializeLib failed:', errorToString(initRes));
return;
}
try {
console.log('Поиск устройств...');
const endpointListPtr = ref.alloc('pointer');
const countPtr = ref.alloc('uint32');
const findRes = rgsec.RG_FindEndPoints(
endpointListPtr,
E_RG_ENDPOINT_TYPE.ET_USBHID,
countPtr
);
console.log('После RG_FindEndPoints, код:', findRes);
if (findRes !== E_API_ERROR_CODES.EC_OK) {
throw new Error(`RG_FindEndPoints failed: ${errorToString(findRes)}`);
}
const count = countPtr.deref();
console.log('Найдено устройств:', count);
if (count === 0) {
throw new Error('USB HID не найден');
}
const endpointInfo = new RG_ENDPOINT_INFO();
const infoRes = rgsec.RG_GetFoundEndPointInfo(
endpointListPtr.deref(),
0,
endpointInfo.ref()
);
console.log('После RG_GetFoundEndPointInfo, код:', infoRes);
if (infoRes !== E_API_ERROR_CODES.EC_OK) {
throw new Error(`RG_GetFoundEndPointInfo failed: ${errorToString(infoRes)}`);
}
const endpoint = new RG_ENDPOINT();
endpoint.type = endpointInfo.type;
endpoint.address = readCString(
endpointInfo.address.buffer,
endpointInfo.address.length
);
console.log('Endpoint type:', endpoint.type);
console.log('Endpoint address:', endpoint.address);
const address = 0;
const initDevRes = rgsec.RG_InitDevice(endpoint.ref(), address);
console.log('После RG_InitDevice, код:', initDevRes);
if (initDevRes !== E_API_ERROR_CODES.EC_OK) {
throw new Error(`RG_InitDevice failed: ${errorToString(initDevRes)}`);
}
console.log('инициализация прошла успешно');
const deviceInfoExt = new RG_DEVICE_INFO_EXT();
const infoExtRes = rgsec.RG_GetInfoExt(
endpoint.ref(),
address,
deviceInfoExt.ref()
);
console.log('После RG_GetInfoExt, код:', infoExtRes);
if (infoExtRes === E_API_ERROR_CODES.EC_OK) {
console.log('номер:', deviceInfoExt.serial.toString(16));
console.log('прошивка:', deviceInfoExt.firmware.toString(16));
} else {
console.warn('Не удалось получить расширенную информацию об устройстве');
}
const queuePtr = ref.alloc('pointer');
console.log('Подписка на события...');
const subscribeRes = rgsec.RG_Subscribe(
queuePtr,
endpoint.ref(),
address,
E_RG_DEVICE_EVENT_TYPE.DET_CARD_PLACED_EVENT |
E_RG_DEVICE_EVENT_TYPE.DET_CARD_REMOVED_EVENT,
ref.NULL
);
console.log('После RG_Subscribe, код:', subscribeRes);
if (subscribeRes !== E_API_ERROR_CODES.EC_OK) {
throw new Error(`RG_Subscribe failed: ${errorToString(subscribeRes)}`);
}
const eventQueue = queuePtr.deref();
console.log('Ожидание событий...');
const eventTypePtr = ref.alloc('uint32');
const eventDataPtrPtr = ref.alloc('pointer');
const pollEvents = () => {
const pollRes = rgsec.RG_PollEvents(
eventQueue,
eventTypePtr,
eventDataPtrPtr,
1000
);
console.log('PollEvents, код:', pollRes);
switch (pollRes) {
case E_API_ERROR_CODES.EC_OK:
handleEvent(eventTypePtr.deref(), eventDataPtrPtr.deref());
setImmediate(pollEvents);
break;
case E_API_ERROR_CODES.EC_POLL_NO_EVENTS:
setImmediate(pollEvents);
break;
default:
console.error('Ошибка PollEvents:', errorToString(pollRes));
cleanup();
process.exit(1);
}
};
const handleEvent = (eventType, eventDataPtr) => {
console.log('handleEvent, тип события:', eventType);
switch (eventType) {
case E_RG_DEVICE_EVENT_TYPE.DET_CARD_PLACED_EVENT:
const cardInfo = ref.reinterpret(eventDataPtr, RG_CARD_INFO.size).deref();
const uid = Array.from(cardInfo.uid).map(b => b.toString(16).padStart(2, '0'));
console.log(`Карта обнаружена: ${uid.join(':')}`);
// Логгируем в admin-server
console.log('[DEBUG] Отправляю событие на admin-server:', { event, uid, timestamp });
axios.post('http://localhost:3005/api/card-event', {
event: 'CARD_PLACED',
uid: uid.join(''),
timestamp: Date.now()
}).catch(err => {
console.error('[ADMIN][CARD_LOG] Ошибка отправки:', err.message);
});
break;
case E_RG_DEVICE_EVENT_TYPE.DET_CARD_REMOVED_EVENT:
console.log('Карта удалена');
// Логгируем в admin-server
console.log('[DEBUG] Отправляю событие на admin-server:', { event, uid, timestamp });
axios.post('http://localhost:3005/api/card-event', {
event: 'CARD_REMOVED',
uid: null,
timestamp: Date.now()
}).catch(err => {
console.error('[ADMIN][CARD_LOG] Ошибка отправки:', err.message);
});
break;
default:
console.log(`Необработанное событие: ${eventType}`);
}
};
pollEvents();
} catch (err) {
console.error('Ошибка:', err.message);
cleanup();
}
process.on('SIGINT', () => {
console.log('\nЗавершение работы...');
cleanup();
process.exit(0);
});
function cleanup() {
try {
console.log('Очистка ресурсов...');
rgsec.RG_CloseResource(endpointListPtr?.deref());
rgsec.RG_CloseDevice(endpoint?.ref(), address);
rgsec.RG_Uninitialize();
console.log('Ресурсы освобождены.');
} catch (cleanupErr) {
console.error('Ошибка при очистке:', cleanupErr.message);
}
}
}
main();
+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+15
View File
@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+401
View File
@@ -0,0 +1,401 @@
/* Общие стили */
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
/* Окно логина */
.login-window {
max-width: 400px;
margin: 12% auto;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.login-window h2 {
text-align: center;
color: #333;
margin-bottom: 20px;
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
margin-bottom: 5px;
color: #666;
}
.input-group input {
width: 95%;
padding: 10px;
border: 1px solid #b9b9b9;
border-radius: 4px;
font-size: 16px;
}
.button-group {
display: flex;
gap: 10px;
}
button {
flex: 1;
padding: 10px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
button[type="submit"] {
background-color: #007bff;
color: white;
}
button[type="submit"]:hover {
background-color: #0056b3;
}
button[type="button"] {
background-color: #6c757d;
color: white;
}
button[type="button"]:hover {
background-color: #545b62;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
/* Информация о сотруднике */
.employee-info {
max-width: 800px;
margin: 20px auto;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.employee-header {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.employee-photo {
width: 200px;
height: 200px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.photo-placeholder {
color: #999;
font-size: 18px;
}
.employee-details {
flex: 1;
}
.employee-details h2 {
margin: 0 0 10px 0;
color: #333;
}
.employee-details p {
margin: 5px 0;
color: #666;
}
/* Секция расчётных листов */
.payslip-section {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.payslip-section h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 18px;
}
.period-selector {
margin-bottom: 20px;
}
.period-selector label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
}
.period-selector select {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
background-color: white;
cursor: pointer;
}
.period-selector select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.action-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.action-buttons button {
background-color: #28a745;
color: white;
flex: 1;
}
.action-buttons button:hover {
background-color: #218838;
}
.action-buttons button:first-child {
background-color: #007bff;
}
.action-buttons button:first-child:hover {
background-color: #0056b3;
}
/* Верхняя панель с кнопкой назад */
.top-bar {
margin-bottom: 20px;
}
.back-button {
background-color: #6c757d;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
transition: background-color 0.2s;
}
.back-button:hover {
background-color: #545b62;
}
/* Делаем кнопку назад компактнее */
.back-button {
flex: 0 0 auto;
width: auto;
}
/* Гостевой режим */
.guest-mode {
max-width: 1200px;
margin: 20px auto;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.guest-content h2 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
/* Навигация по табам */
.tab-navigation {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.tab-button {
padding: 12px 24px;
border: none;
background: none;
cursor: pointer;
font-size: 16px;
color: #666;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
}
.tab-button:hover {
color: #007bff;
background-color: #f8f9fa;
}
.tab-button.active {
color: #007bff;
border-bottom-color: #007bff;
background-color: #f8f9fa;
}
/* Контент табов */
.tab-content {
min-height: 400px;
padding: 20px 0;
}
.handbook-content,
.union-content,
.info-content {
line-height: 1.6;
}
.content-text {
white-space: pre-line;
font-size: 14px;
line-height: 1.8;
}
.loading {
text-align: center;
color: #666;
font-style: italic;
padding: 40px;
}
/* Заголовки в контенте */
.tab-content h3 {
color: #333;
margin-bottom: 15px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.tab-content p {
margin-bottom: 15px;
color: #555;
}
/* --- Handbook Content Styles --- */
.handbook-content {
max-width: 900px;
margin: 0 auto;
padding: 24px 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.handbook-content h1 {
text-align: center;
font-size: 2rem;
margin: 24px 0 16px 0;
color: #1a237e;
}
.handbook-content h2 {
text-align: center;
font-size: 1.5rem;
margin: 20px 0 12px 0;
color: #283593;
}
.handbook-content p {
margin: 10px 0;
font-size: 1.08rem;
line-height: 1.6;
}
.handbook-content ul {
margin: 12px 0 12px 32px;
padding-left: 18px;
}
.handbook-content li {
margin: 6px 0;
font-size: 1.05rem;
line-height: 1.5;
}
.handbook-content strong {
font-weight: bold;
color: #000000;
text-align: center;
}
.handbook-content h3 {
font-weight: bold;
color: #000000;
text-align: center;
}
.handbook-content em {
font-style: italic;
color: #555;
text-align: center;
}
.handbook-content h4 {
font-weight: bold;
color: #000000;
text-align: center;
font-size: 1.1rem;
}
.handbook-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
.handbook-table th,
.handbook-table td {
border: 1px solid #ccc;
padding: 8px 12px;
}
.handbook-table th {
background: #f5f5f5;
}
.handbook-content table,
.handbook-content th,
.handbook-content td,
.handbook-content tr {
border: 1px solid #bdbdbd;
border-collapse: collapse;
}
.handbook-content th,
.handbook-content td {
padding: 8px;
}
.handbook-content table {
width: 100%;
border-collapse: collapse;
}
+18
View File
@@ -0,0 +1,18 @@
declare module 'textract' {
interface TextractOptions {
preserveLineBreaks?: boolean;
preserveOnlyMultipleLineBreaks?: boolean;
[key: string]: any;
}
function fromBufferWithMime(
mimeType: string,
buffer: Buffer,
options: TextractOptions,
callback: (error: Error | null, text: string) => void
): void;
export = {
fromBufferWithMime
};
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"typeRoots": [
"./node_modules/@types",
"./src/types"
]
},
"include": [
"src"
]
}