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
+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
};
}