diff --git a/modules/about/index.js b/modules/about/index.js
new file mode 100644
index 0000000..123ad19
--- /dev/null
+++ b/modules/about/index.js
@@ -0,0 +1,32 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ app.get('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ db.all(`SELECT * FROM about_settings ORDER BY id`, (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ const obj = {};
+ rows.forEach(r => { obj[r.key] = r.value; });
+ res.json(obj);
+ });
+ });
+
+ app.put('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const entries = req.body;
+ if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
+ const stmt = db.prepare(`UPDATE about_settings SET value = ? WHERE key = ?`);
+ for (const [key, value] of Object.entries(entries)) {
+ stmt.run(value, key);
+ }
+ stmt.finalize();
+ res.json({ message: 'Обновлено' });
+ });
+
+ createCrudRoutes(app, auth, 'about_features', {
+ fields: ['svg_icon', 'title', 'description', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/activities/index.js b/modules/activities/index.js
new file mode 100644
index 0000000..b11e3a7
--- /dev/null
+++ b/modules/activities/index.js
@@ -0,0 +1,55 @@
+const { createCrudRoutes } = require('../database/crud');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const sharp = require('sharp');
+
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const uploadDir = path.join(__dirname, '..', '..', 'data', 'activity_images');
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+ const storage = multer.diskStorage({
+ destination: uploadDir,
+ filename: (req, file, cb) => {
+ const ext = path.extname(file.originalname);
+ cb(null, 'act_' + Date.now() + ext);
+ }
+ });
+ const upload = multer({
+ storage,
+ limits: { fileSize: 5 * 1024 * 1024 },
+ fileFilter: (req, file, cb) => {
+ const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
+ cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
+ }
+ });
+
+ app.post('/api/admin/activities/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
+ if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
+ let fname = req.file.filename;
+ const ext = path.extname(fname).toLowerCase();
+ if (ext !== '.webp') {
+ try {
+ const webpName = path.basename(fname, ext) + '.webp';
+ await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
+ fs.unlinkSync(req.file.path);
+ fname = webpName;
+ } catch (e) { console.error(e); }
+ }
+ res.json({ path: fname, url: `/data/activity_images/${fname}` });
+ });
+
+ createCrudRoutes(app, auth, 'public_activities', {
+ fields: ['svg_icon', 'title', 'description', 'sort_order']
+ });
+
+ createCrudRoutes(app, auth, 'activity_gallery', {
+ fields: ['title', 'description', 'image_path', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/auth/index.js b/modules/auth/index.js
new file mode 100644
index 0000000..bf19cd6
--- /dev/null
+++ b/modules/auth/index.js
@@ -0,0 +1,124 @@
+const jwt = require('jsonwebtoken');
+const bcrypt = require('bcryptjs');
+
+let db;
+
+function init(database) {
+ db = database;
+}
+
+function authenticateToken(req, res, next) {
+ const authHeader = req.headers['authorization'];
+ const token = authHeader && authHeader.split(' ')[1];
+ if (!token) {
+ return res.status(401).json({ error: 'Требуется авторизация' });
+ }
+ jwt.verify(token, process.env.HOTEL777KEY, (err, user) => {
+ if (err) {
+ return res.status(403).json({ error: 'Недействительный токен' });
+ }
+ req.user = user;
+ next();
+ });
+}
+
+function requireAdmin(req, res, next) {
+ if (req.user && req.user.role === 'admin') {
+ return next();
+ }
+ return res.status(403).json({ error: 'Требуются права администратора' });
+}
+
+function syncAdmin() {
+ return new Promise((resolve) => {
+ const login = process.env.ADMIN_USERNAME || 'admin';
+ const password = process.env.ADMIN_PASSWORD || 'admin123';
+ const hash = bcrypt.hashSync(password, 10);
+
+ db.get(`SELECT id FROM users WHERE login = ?`, [login], (err, row) => {
+ if (row) {
+ db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, login], function() {
+ resolve();
+ });
+ } else {
+ db.run(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, 'admin')`,
+ [login, hash, 'Администратор'], function() {
+ resolve();
+ });
+ }
+ });
+ });
+}
+
+function setupRoutes(app) {
+ app.post('/api/auth/login', (req, res) => {
+ const { login, password } = req.body;
+ if (!login || !password) {
+ return res.status(400).json({ error: 'Логин и пароль обязательны' });
+ }
+
+ db.get(`SELECT * FROM users WHERE login = ?`, [login], (err, user) => {
+ if (err || !user) {
+ return res.status(401).json({ error: 'Неверный логин или пароль' });
+ }
+
+ const valid = bcrypt.compareSync(password, user.password_hash);
+ if (!valid) {
+ return res.status(401).json({ error: 'Неверный логин или пароль' });
+ }
+
+ const token = jwt.sign(
+ { id: user.id, login: user.login, role: user.role },
+ process.env.HOTEL777KEY,
+ { expiresIn: '24h' }
+ );
+
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ login: user.login,
+ full_name: user.full_name,
+ role: user.role
+ }
+ });
+ });
+ });
+
+ app.get('/api/auth/me', authenticateToken, (req, res) => {
+ db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [req.user.id], (err, user) => {
+ if (err || !user) {
+ return res.status(404).json({ error: 'Пользователь не найден' });
+ }
+ res.json(user);
+ });
+ });
+
+ app.put('/api/auth/me', authenticateToken, (req, res) => {
+ const { full_name } = req.body;
+ db.run(`UPDATE users SET full_name = ? WHERE id = ?`, [full_name || '', req.user.id], function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка обновления' });
+ res.json({ message: 'Профиль обновлён' });
+ });
+ });
+
+ app.post('/api/auth/change-password', authenticateToken, (req, res) => {
+ const { old_password, new_password } = req.body;
+ if (!old_password || !new_password) {
+ return res.status(400).json({ error: 'Старый и новый пароль обязательны' });
+ }
+ db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, user) => {
+ if (err || !user) return res.status(404).json({ error: 'Пользователь не найден' });
+ if (!bcrypt.compareSync(old_password, user.password_hash)) {
+ return res.status(400).json({ error: 'Неверный старый пароль' });
+ }
+ const hash = bcrypt.hashSync(new_password, 10);
+ db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err2) => {
+ if (err2) return res.status(500).json({ error: 'Ошибка обновления пароля' });
+ res.json({ message: 'Пароль изменён' });
+ });
+ });
+ });
+}
+
+module.exports = { init, authenticateToken, requireAdmin, syncAdmin, setupRoutes };
diff --git a/modules/database/crud.js b/modules/database/crud.js
new file mode 100644
index 0000000..00769bb
--- /dev/null
+++ b/modules/database/crud.js
@@ -0,0 +1,65 @@
+function createCrudRoutes(app, auth, table, options = {}) {
+ const { authenticateToken, requireAdmin } = auth;
+ const prefix = options.prefix || `/api/admin/${table}`;
+ const allowedFields = options.fields || [];
+ const extraRoutes = options.extraRoutes || null;
+
+ app.get(prefix, authenticateToken, requireAdmin, (req, res) => {
+ const orderBy = options.orderBy || 'sort_order';
+ db.all(`SELECT * FROM ${table} ORDER BY ${orderBy}`, (err, rows) => {
+ if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка БД' }); }
+ res.json(rows);
+ });
+ });
+
+ app.post(prefix, authenticateToken, requireAdmin, (req, res) => {
+ const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null);
+ const placeholders = values.map(() => '?').join(', ');
+ const cols = allowedFields.join(', ');
+ const stmt = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`);
+ stmt.run(...values, function(err) {
+ if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка создания' }); }
+ db.get(`SELECT * FROM ${table} WHERE id = ?`, [this.lastID], (err2, row) => {
+ if (err2) return res.status(500).json({ error: 'Ошибка получения' });
+ res.status(201).json(row);
+ });
+ });
+ stmt.finalize();
+ });
+
+ app.put(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ const setClauses = allowedFields.map(f => `${f} = ?`).join(', ');
+ const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null);
+ values.push(id);
+ const stmt = db.prepare(`UPDATE ${table} SET ${setClauses} WHERE id = ?`);
+ stmt.run(...values, function(err) {
+ if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка обновления' }); }
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ db.get(`SELECT * FROM ${table} WHERE id = ?`, [id], (err2, row) => {
+ if (err2) return res.status(500).json({ error: 'Ошибка получения' });
+ res.json(row);
+ });
+ });
+ stmt.finalize();
+ });
+
+ app.delete(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ const stmt = db.prepare(`DELETE FROM ${table} WHERE id = ?`);
+ stmt.run(id, function(err) {
+ if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка удаления' }); }
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ res.json({ message: 'Удалено' });
+ });
+ stmt.finalize();
+ });
+
+ if (extraRoutes) extraRoutes(app, authenticateToken, requireAdmin);
+}
+
+let db;
+function init(database) { db = database; }
+module.exports = { createCrudRoutes, init };
diff --git a/modules/database/index.js b/modules/database/index.js
new file mode 100644
index 0000000..459c2ab
--- /dev/null
+++ b/modules/database/index.js
@@ -0,0 +1,301 @@
+let db;
+
+function init(database) {
+ db = database;
+ return initDatabase();
+}
+
+function initDatabase() {
+ return new Promise((resolve, reject) => {
+ db.serialize(() => {
+ db.run(`CREATE TABLE IF NOT EXISTS leads (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ phone TEXT NOT NULL,
+ message TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ login TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ full_name TEXT,
+ role TEXT NOT NULL DEFAULT 'admin',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS hero_settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT UNIQUE NOT NULL,
+ value TEXT
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS hero_stats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ value TEXT NOT NULL,
+ is_dynamic INTEGER DEFAULT 0,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS about_settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT UNIQUE NOT NULL,
+ value TEXT
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS about_features (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ svg_icon TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS services (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ svg_icon TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ items TEXT DEFAULT '[]',
+ is_large INTEGER DEFAULT 0,
+ sort_order INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS work_schemes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ svg_icon TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ items TEXT DEFAULT '[]',
+ sort_order INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS process_steps (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ step_number INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS equipment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ brand TEXT,
+ image_path TEXT,
+ qty TEXT,
+ specs TEXT DEFAULT '[]',
+ age_badge TEXT DEFAULT '',
+ sort_order INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS equipment_gallery (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ equipment_id INTEGER NOT NULL,
+ image_path TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS geo_regions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS geo_settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT UNIQUE NOT NULL,
+ value TEXT
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS licenses (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ svg_icon TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS license_gallery (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT,
+ image_path TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS public_activities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ svg_icon TEXT,
+ title TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS activity_gallery (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT,
+ image_path TEXT,
+ sort_order INTEGER DEFAULT 0
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS partners (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ logo_path TEXT,
+ sort_order INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1
+ )`);
+ db.run(`CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT UNIQUE NOT NULL,
+ value TEXT
+ )`);
+
+ db.get(`SELECT COUNT(*) as cnt FROM settings`, (err, row) => {
+ if (row && row.cnt > 0) { resolve(); return; }
+ seedAll().then(resolve).catch(reject);
+ });
+ });
+ });
+}
+
+async function seedAll() {
+ const run = (sql, params) => new Promise(r => db.run(sql, params || [], () => r()));
+ const runEach = (sql, rows) => Promise.all(rows.map(r => {
+ const stmt = db.prepare(sql);
+ return new Promise(res => {
+ stmt.run(...r, function() { stmt.finalize(); res(); });
+ });
+ }));
+
+ const heroDefaults = [
+ ['hero_bg', './img/hero-bg.webp'], ['badge_text', 'Лицензии СРО и НОСТРОЙ'],
+ ['h1_text', 'демонтаж нефтегазопроводов по России, утилизация отходов'],
+ ['subtitle', 'Собственная спецтехника. Лицензии СРО и НОСТРОЙ. Более {years} лет опыта. Работаем на опасных производственных объектах без привлечения субподрядчиков.'],
+ ['phone_800', '8 (800) 555-29-31'], ['phone_ekb', '8 (343) 237-25-66'],
+ ['email', 'sts_zakaz@mail.ru'], ['address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'],
+ ['button1_text', 'Оставить заявку'], ['button1_link', '#contacts'],
+ ['button2_text', 'Наши услуги'], ['button2_link', '#services']
+ ];
+ await runEach('INSERT OR IGNORE INTO hero_settings (key, value) VALUES (?, ?)', heroDefaults);
+
+ await runEach('INSERT INTO hero_stats (label, value, is_dynamic, sort_order) VALUES (?, ?, ?, ?)', [
+ ['лет на рынке', '{years}', 1, 0], ['объектов выполнено', '200', 0, 1],
+ ['единиц техники', '80', 0, 2], ['федеральных округов', '6', 0, 3]
+ ]);
+
+ await runEach('INSERT OR IGNORE INTO about_settings (key, value) VALUES (?, ?)', [
+ ['section_subtitle', 'Компания «СТС-Авто» — надёжный партнёр в сфере демонтажа, оказывает услуги по демонтажу магистральных трубопроводов, объектов инфраструктуры, зданий, сооружений и разделки жд транспорта, с последующей переработкой и утилизацией для предприятий. Мы работаем на всей территории Российской Федерации.'],
+ ['main_heading', 'Собственная техника и полный контроль'], ['about_image', './img/hero-bg.webp'],
+ ['paragraph_1', 'Наш автопарк состоит из более чем 80 единиц современной спецтехники, возраст которой не превышает 5 лет. Мы используем экскаваторы, краны, погрузчики, самосвалы и тралы ведущих мировых производителей: KAMAZ, Komatsu, Zoomlion, SDLG, Соболь, УАЗ.'],
+ ['paragraph_2', 'Все работы выполняются исключительно силами компании — без привлечения субподрядчиков. Это гарантирует полное соответствие качества, сроков и ответственности на каждом этапе проекта.'],
+ ['paragraph_3', 'Мы работаем в самых сложных условиях: труднодоступные районы, заболоченные территории, Крайний Север. Гибкие варианты оплаты: работаем по договору, включая вариант расчёта трубой за услуги (бартер с металлоломом после демонтажа).']
+ ]);
+
+ await runEach('INSERT INTO about_features (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
+ [' ', 'Парк техники не старше 5 лет', 'Более 80 единиц современной спецтехники', 0],
+ [' ', 'Без субподрядчиков', 'Все работы силами компании', 1],
+ [' ', 'Сложные условия', 'Болота, Крайний Север, труднодоступные районы', 2],
+ [' ', 'Гибкая оплата', 'Включая расчёт трубой (бартер)', 3]
+ ]);
+
+ const svcIcon = {
+ demolish: ' ',
+ recult: ' ',
+ clear: ' ',
+ lezh: ' ',
+ scheme1: ' ',
+ scheme2: ' '
+ };
+
+ await runEach('INSERT INTO services (svg_icon, title, description, items, is_large, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [
+ [svcIcon.demolish, 'Демонтаж нефтегазопроводов, утилизация отходов', 'Выполняем полный цикл работ по демонтажу магистральных и промысловых трубопроводов любого типа и диаметра, а также утилизацию отходов.', '["Нефтепроводы, газопроводы, водоводы — все типы магистральных и промысловых сетей","Полный цикл: от вскрытия и вырезки дефектных участков до вывоза и сдачи металлолома","Работа с трубопроводами любого диаметра в любых климатических условиях","Разделка ж/д транспорта, переработка и утилизация для предприятий"]', 1, 0],
+ [svcIcon.recult, 'Техническая рекультивация земель', 'Восстановление нарушенных земель после проведения строительных и ремонтных работ.', '["Засыпка траншей, планировка, восстановление плодородного слоя","Работа с нефтезагрязнёнными, засоленными землями","Сдача по актам под контролем экологов"]', 0, 1],
+ [svcIcon.clear, 'Расчистка территории от мелколесья и ДКР', 'Подготовка полосы отвода: расчистка от древесно-кустарниковой растительности.', '["Валка, корчёвка, дробление пней","Планировка полосы отвода","Мульчеры и кусторезы на гусеничном ходу"]', 0, 2],
+ [svcIcon.lezh, 'Устройство лежневого настила', 'Обеспечение проходимости для тяжёлой техники по болотам и слабым грунтам.', '["Изготовление секций из бревен и бруса","Укладка перекрытий на слабых грунтах","Обеспечение проходимости для тяжёлой гусеничной техники"]', 0, 3]
+ ]);
+
+ await runEach('INSERT INTO work_schemes (svg_icon, title, description, items, sort_order) VALUES (?, ?, ?, ?, ?)', [
+ [svcIcon.scheme1, 'Схема 1 — с доставкой заказчику', 'Подходит для компаний, планирующих повторное использование труб или их последующую реализацию. Расходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Резка и маркировка труб в соответствии с требованиями","Транспортировка демонтированных труб на указанные территории хранения","Оформление всей сопроводительной документации (акты приёма-передачи)"]', 0],
+ [svcIcon.scheme2, 'Схема 2 — вывоз на базы подрядчика', 'Экономически выгодное решение для заказчиков, заинтересованных в минимизации затрат. Доходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Перевозка и размещение на сертифицированных площадках временного хранения ООО «СТС-АВТО»","Покупка трубы в земле — заказчик получает доход от металлолома","Оформление полного пакета закрывающей документации"]', 1]
+ ]);
+
+ await runEach('INSERT INTO process_steps (step_number, title, description, sort_order) VALUES (?, ?, ?, ?)', [
+ [1, 'Заявка', 'Вы оставляете заявку на сайте, по электронной почте или звоните нам. Наши специалисты оперативно свяжутся с вами для обсуждения деталей проекта, объёма работ и сроков.', 0],
+ [2, 'Выезд специалиста и оценка', 'Наш инженер выезжает на объект для проведения осмотра, оценки объёма работ, определения необходимых ресурсов и техники. По результатам составляется техническое задание и подписывается договор.', 1],
+ [3, 'Выполнение работ', 'Мобилизуем технику и персонал на объект. Выполняем демонтаж, утилизацию отходов и рекультивацию в соответствии с проектной документацией. Ежедневные отчёты о ходе работ.', 2],
+ [4, 'Сдача объекта', 'По завершении всех работ подписывается акт выполненных работ, передаётся полный комплект закрывающей документации. При необходимости — сопровождение сдачи объекта экспертизе.', 3]
+ ]);
+
+ await runEach('INSERT INTO equipment (name, brand, image_path, qty, specs, age_badge, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', [
+ ['Автокран КАМАЗ КС-55717К-3', 'КАМАЗ', 'ks-45721.webp', '10 единиц', '[{"label":"Грузоподъёмность","value":"25 тонн"},{"label":"Вылет стрелы","value":"до 28 м"},{"label":"Шасси","value":"Полноприводное 6×6"}]', 'Не старше 5 лет', 0],
+ ['Автокран Zoomlion ZTC300', 'Zoomlion', 'zoomlion-ztc300.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"30 тонн"},{"label":"Вылет стрелы","value":"до 32 м"},{"label":"Стрела","value":"Телескопическая"}]', 'Не старше 5 лет', 1],
+ ['Экскаватор гусеничный Komatsu PC210-10M0', 'Komatsu', 'komatsu-pc210.webp', '20 единиц', '[{"label":"Масса","value":"21 тонна"},{"label":"Объём ковша","value":"1.0–1.2 м³"},{"label":"Глубина копания","value":"до 6.5 м"}]', 'Не старше 5 лет', 2],
+ ['Экскаватор гусеничный Zoomlion ZE215E', 'Zoomlion', 'zoomlion-ze215e.webp', '2 единицы', '[{"label":"Масса","value":"21.5 тонны"},{"label":"Объём ковша","value":"1.1 м³"},{"label":"Аналог","value":"Komatsu PC210"}]', 'Не старше 5 лет', 3],
+ ['Погрузчик колёсный SDLG LG933L', 'SDLG', 'sdlg-lg933l.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"3 тонны"},{"label":"Объём ковша","value":"1.7 м³"},{"label":"Тип","value":"Колёсный"}]', 'Не старше 5 лет', 4],
+ ['Соболь 4×4 грузо-пассажирский', 'Соболь', 'sobol-4x4.webp', '13 единиц', '[{"label":"Грузоподъёмность","value":"до 1 т"},{"label":"Привод","value":"Полный 4×4"},{"label":"Назначение","value":"Доставка персонала и запчастей"}]', 'Не старше 5 лет', 5],
+ ['Ломовоз с КМУ (кран-манипулятор)', 'Ломовоз', 'lomovoz-kmu.webp', '3 единицы', '[{"label":"Грузоподъёмность КМУ","value":"до 8 т"},{"label":"Объём платформы","value":"до 20 м³"},{"label":"Назначение","value":"Вывоз металлолома"}]', 'Не старше 5 лет', 6],
+ ['Седельный тягач Камаз 53504-50', 'КамАЗ', 'kamaz-53504.webp', '6 единиц', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Перевозка негабарита и тралов"}]', 'Не старше 5 лет', 7],
+ ['Экскаватор-амфибия на понтонной платформе', 'Амфибия', 'amfibia.webp', '4 единицы', '[{"label":"Тип","value":"Гусеничный, плавающий"},{"label":"Работает","value":"На болотах и водных преградах"},{"label":"Особенность","value":"Там, где не видно дна"}]', 'Не старше 5 лет', 8],
+ ['УАЗ Пикап (полный привод)', 'УАЗ', 'uaz-pickup.webp', 'Несколько единиц', '[{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Доставка запчастей на месторождения"}]', 'Не старше 5 лет', 9],
+ ['Тралы тяжеловозы для доставки спецтехники', 'Тралы', 'tral-1.webp', '3 единицы', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Назначение","value":"Доставка спецтехники на объекты"}]', 'Не старше 5 лет', 10]
+ ]);
+
+ await runEach('INSERT INTO geo_regions (name, sort_order) VALUES (?, ?)', [
+ ['УрФО', 0], ['СФО', 1], ['ПФО', 2], ['СЗФО', 3], ['ДФО', 4], ['ЮФО', 5]
+ ]);
+
+ await runEach('INSERT OR IGNORE INTO geo_settings (key, value) VALUES (?, ?)', [
+ ['section_subtitle', 'Работаем во всех федеральных округах Российской Федерации. Особые условия для труднодоступных районов Крайнего Севера и заболоченных территорий.'],
+ ['difficult_title', '🔧 Труднодоступные районы'],
+ ['difficult_desc', 'Мы располагаем специализированной техникой для работы в болотах ХМАО-Югры, ЯНАО, на севере Красноярского края, в Иркутской области. Гусеничная техника и лежневые настилы обеспечивают проходимость даже в самых сложных условиях.'],
+ ['map_image', './img/geo.webp']
+ ]);
+
+ const licIcon = ' ';
+ await runEach('INSERT INTO licenses (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
+ [licIcon, 'СРО — допуск к работам на опасных объектах', 'Свидетельство о допуске к работам на опасных производственных объектах нефтегазового комплекса.', 0],
+ [licIcon, 'Членство в НОСТРОЙ', 'Действующее членство в Национальном объединении строителей — допуск к строительным работам.', 1],
+ [' ', 'Сертификат ISO 9001 (качество)', 'Международный стандарт системы менеджмента качества.', 2],
+ [' ', 'Сертификат OHSAS 18001 (охрана труда)', 'Система менеджмента охраны труда и промышленной безопасности.', 3],
+ [' ', 'Договор ДМС для сотрудников', 'Добровольное медицинское страхование всех сотрудников.', 4],
+ [' ', 'Лицензия на заготовку и переработку лома', 'Лицензия на заготовку, хранение и переработку лома черных металлов.', 5]
+ ]);
+
+ await runEach('INSERT INTO license_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [
+ ['Лицензия на отходы I–IV класса опасности', '№ (66) -6527 - СТО/П от 21 мая 2019 г.', 'license-otxody.webp', 0],
+ ['Лицензия на лом черных и цветных металлов', '№ ЛМ 000092 от 31 октября 2018 г.', 'license-lom.webp', 1]
+ ]);
+
+ await runEach('INSERT INTO public_activities (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [
+ [' ', 'Спонсорство', 'Поддерживаем спортивные, культурные и общественные мероприятия в регионах присутствия. Помогаем развитию местных сообществ.', 0],
+ [' ', 'Благотворительность', 'Оказываем адресную помощь детским домам, ветеранским и общественным организациям. Участвуем в социальных проектах.', 1],
+ [' ', 'Экология', 'Участвуем в экологических акциях, субботниках и проектах по озеленению. Следим за снижением воздействия на окружающую среду.', 2],
+ [' ', 'Поддержка образования', 'Сотрудничаем с профильными вузами и колледжами. Организуем практику для студентов, участвуем в ярмарках вакансий.', 3]
+ ]);
+
+ await runEach('INSERT INTO activity_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [
+ ['Благодарственное письмо', 'За вклад в развитие региона', 'thanks-1.webp', 0],
+ ['Благодарственное письмо', 'За благотворительную деятельность', 'thanks-2.webp', 1]
+ ]);
+
+ await runEach('INSERT INTO partners (name, logo_path, sort_order) VALUES (?, ?, ?)', [
+ ['Газпром', 'газпром.webp', 0], ['Роснефть', 'роснефть.webp', 1],
+ ['Сибур', 'сибур.webp', 2], ['Сургутнефтегаз', 'сургутнефтегаз.webp', 3],
+ ['Транснефть', 'транснефть.webp', 4]
+ ]);
+
+ await runEach('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [
+ ['contact_phone_800', '8 (800) 555-29-31'], ['contact_phone_ekb', '8 (343) 237-25-66'],
+ ['contact_email', 'sts_zakaz@mail.ru'], ['contact_address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'],
+ ['contact_work_hours', 'Пн–Пт: 9:00 – 18:00|Сб: 10:00 – 15:00'], ['contact_map_image', './img/secret.webp'],
+ ['footer_text', '© {year} ООО «СТС-Авто». Все права защищены. 16+'],
+ ['form_title', 'Оставить заявку'], ['form_subtitle', 'Заполните форму и мы свяжемся с вами в ближайшее время'],
+ ['section_services_subtitle', 'Полный спектр работ по демонтажу и утилизации трубопроводной инфраструктуры. Работаем на опасных производственных объектах с собственным парком спецтехники.'],
+ ['section_schemes_subtitle', 'Мы разработали гибкие схемы работы, позволяющие выбрать оптимальное решение с учётом ваших целей и бюджета.'],
+ ['section_process_subtitle', 'Прозрачный и отлаженный процесс от первого обращения до сдачи объекта. Каждый этап контролируется инженерами компании.'],
+ ['section_equipment_subtitle', 'Собственный парк из более чем 80 единиц современной спецтехники ведущих мировых производителей. Возраст всей техники — не старше 5 лет.'],
+ ['section_licenses_subtitle', 'Все необходимые допуски и сертификаты для выполнения работ на опасных производственных объектах. Полная прозрачность и соответствие требованиям законодательства РФ.'],
+ ['section_activity_subtitle', 'Компания «СТС-Авто» активно участвует в социальной и общественной жизни регионов присутствия.'],
+ ['section_partners_subtitle', 'Сотрудничаем с ведущими компаниями нефтегазового сектора.'],
+ ['section_contacts_subtitle', 'Оставьте заявку или позвоните — мы оперативно ответим на ваши вопросы и рассчитаем стоимость работ.']
+ ]);
+}
+
+function getDb() {
+ return db;
+}
+
+module.exports = { init, getDb };
diff --git a/modules/equipment/index.js b/modules/equipment/index.js
new file mode 100644
index 0000000..a566927
--- /dev/null
+++ b/modules/equipment/index.js
@@ -0,0 +1,116 @@
+const { createCrudRoutes } = require('../database/crud');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const sharp = require('sharp');
+
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const uploadDir = path.join(__dirname, '..', '..', 'data', 'equipment_images');
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+ const storage = multer.diskStorage({
+ destination: uploadDir,
+ filename: (req, file, cb) => {
+ const ext = path.extname(file.originalname);
+ cb(null, 'equip_' + Date.now() + '_' + Math.round(Math.random() * 1000) + ext);
+ }
+ });
+ const upload = multer({
+ storage,
+ limits: { fileSize: 5 * 1024 * 1024 },
+ fileFilter: (req, file, cb) => {
+ const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
+ const ext = path.extname(file.originalname).toLowerCase();
+ cb(null, allowed.includes(ext));
+ }
+ });
+
+ async function convertToWebp(filePath, fname) {
+ const ext = path.extname(fname).toLowerCase();
+ if (ext === '.webp') return fname;
+ try {
+ const webpName = path.basename(fname, ext) + '.webp';
+ await sharp(filePath).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
+ fs.unlinkSync(filePath);
+ return webpName;
+ } catch (e) { return fname; }
+ }
+
+ app.post('/api/admin/equipment/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
+ if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
+ const fname = await convertToWebp(req.file.path, req.file.filename);
+ res.json({ path: fname, url: `/data/equipment_images/${fname}` });
+ });
+
+ app.post('/api/admin/equipment/gallery/upload', auth.authenticateToken, auth.requireAdmin, upload.array('images', 10), async (req, res) => {
+ if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'Файлы не загружены' });
+ const results = [];
+ for (const file of req.files) {
+ const fname = await convertToWebp(file.path, file.filename);
+ results.push({ path: fname, url: `/data/equipment_images/${fname}` });
+ }
+ res.json(results);
+ });
+
+ app.get('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ db.all(`SELECT * FROM equipment_gallery WHERE equipment_id = ? ORDER BY sort_order`, [id], (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ res.json(rows);
+ });
+ });
+
+ app.post('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ const { image_path, sort_order } = req.body;
+ if (!image_path) return res.status(400).json({ error: 'Не указан путь к изображению' });
+ const stmt = db.prepare(`INSERT INTO equipment_gallery (equipment_id, image_path, sort_order) VALUES (?, ?, ?)`);
+ stmt.run(id, image_path, sort_order || 0, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка создания' });
+ db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [this.lastID], (e2, row) => {
+ if (e2) return res.status(500).json({});
+ res.status(201).json(row);
+ });
+ });
+ stmt.finalize();
+ });
+
+ app.put('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const imageId = parseInt(req.params.imageId);
+ if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' });
+ const { image_path, sort_order } = req.body;
+ const stmt = db.prepare(`UPDATE equipment_gallery SET image_path = COALESCE(?, image_path), sort_order = COALESCE(?, sort_order) WHERE id = ?`);
+ stmt.run(image_path || null, sort_order != null ? sort_order : null, imageId, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка обновления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [imageId], (e2, row) => {
+ res.json(row);
+ });
+ });
+ stmt.finalize();
+ });
+
+ app.delete('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const imageId = parseInt(req.params.imageId);
+ if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' });
+ const stmt = db.prepare(`DELETE FROM equipment_gallery WHERE id = ?`);
+ stmt.run(imageId, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка удаления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ res.json({ message: 'Удалено' });
+ });
+ stmt.finalize();
+ });
+
+ createCrudRoutes(app, auth, 'equipment', {
+ fields: ['name', 'brand', 'image_path', 'qty', 'specs', 'age_badge', 'sort_order', 'is_active']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/geography/index.js b/modules/geography/index.js
new file mode 100644
index 0000000..f30aaf8
--- /dev/null
+++ b/modules/geography/index.js
@@ -0,0 +1,32 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ app.get('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ db.all(`SELECT * FROM geo_settings ORDER BY id`, (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ const obj = {};
+ rows.forEach(r => { obj[r.key] = r.value; });
+ res.json(obj);
+ });
+ });
+
+ app.put('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const entries = req.body;
+ if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
+ const stmt = db.prepare(`UPDATE geo_settings SET value = ? WHERE key = ?`);
+ for (const [key, value] of Object.entries(entries)) {
+ stmt.run(value, key);
+ }
+ stmt.finalize();
+ res.json({ message: 'Обновлено' });
+ });
+
+ createCrudRoutes(app, auth, 'geo_regions', {
+ fields: ['name', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/hero/index.js b/modules/hero/index.js
new file mode 100644
index 0000000..8396d90
--- /dev/null
+++ b/modules/hero/index.js
@@ -0,0 +1,33 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ app.get('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ db.all(`SELECT * FROM hero_settings ORDER BY id`, (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ const obj = {};
+ rows.forEach(r => { obj[r.key] = r.value; });
+ res.json(obj);
+ });
+ });
+
+ app.put('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => {
+ const entries = req.body;
+ if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
+ const stmt = db.prepare(`UPDATE hero_settings SET value = ? WHERE key = ?`);
+ let count = 0;
+ for (const [key, value] of Object.entries(entries)) {
+ stmt.run(value, key, function() { count++; });
+ }
+ stmt.finalize();
+ res.json({ message: 'Обновлено', updated: count });
+ });
+
+ createCrudRoutes(app, auth, 'hero_stats', {
+ fields: ['label', 'value', 'is_dynamic', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/leads/index.js b/modules/leads/index.js
new file mode 100644
index 0000000..bf7f1fc
--- /dev/null
+++ b/modules/leads/index.js
@@ -0,0 +1,45 @@
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const { authenticateToken, requireAdmin } = auth;
+
+ app.get('/api/admin/leads', authenticateToken, requireAdmin, (req, res) => {
+ const page = parseInt(req.query.page) || 1;
+ const limit = 20;
+ const offset = (page - 1) * limit;
+ const search = req.query.search || '';
+
+ let where = '';
+ const params = [];
+ if (search) {
+ where = `WHERE name LIKE ? OR phone LIKE ? OR message LIKE ?`;
+ params.push(`%${search}%`, `%${search}%`, `%${search}%`);
+ }
+
+ db.get(`SELECT COUNT(*) as total FROM leads ${where}`, params, (err, countRow) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ const total = countRow.total;
+ db.all(`SELECT * FROM leads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
+ [...params, limit, offset], (err2, rows) => {
+ if (err2) return res.status(500).json({ error: 'Ошибка БД' });
+ res.json({ data: rows, total, page, totalPages: Math.ceil(total / limit) });
+ });
+ });
+ });
+
+ app.delete('/api/admin/leads/:id', authenticateToken, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ const stmt = db.prepare(`DELETE FROM leads WHERE id = ?`);
+ stmt.run(id, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка удаления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ res.json({ message: 'Заявка удалена' });
+ });
+ stmt.finalize();
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/licenses/index.js b/modules/licenses/index.js
new file mode 100644
index 0000000..3450a01
--- /dev/null
+++ b/modules/licenses/index.js
@@ -0,0 +1,55 @@
+const { createCrudRoutes } = require('../database/crud');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const sharp = require('sharp');
+
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const uploadDir = path.join(__dirname, '..', '..', 'data', 'license_images');
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+ const storage = multer.diskStorage({
+ destination: uploadDir,
+ filename: (req, file, cb) => {
+ const ext = path.extname(file.originalname);
+ cb(null, 'lic_' + Date.now() + ext);
+ }
+ });
+ const upload = multer({
+ storage,
+ limits: { fileSize: 5 * 1024 * 1024 },
+ fileFilter: (req, file, cb) => {
+ const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
+ cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
+ }
+ });
+
+ app.post('/api/admin/licenses/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
+ if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
+ let fname = req.file.filename;
+ const ext = path.extname(fname).toLowerCase();
+ if (ext !== '.webp') {
+ try {
+ const webpName = path.basename(fname, ext) + '.webp';
+ await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
+ fs.unlinkSync(req.file.path);
+ fname = webpName;
+ } catch (e) { console.error(e); }
+ }
+ res.json({ path: fname, url: `/data/license_images/${fname}` });
+ });
+
+ createCrudRoutes(app, auth, 'licenses', {
+ fields: ['svg_icon', 'title', 'description', 'sort_order']
+ });
+
+ createCrudRoutes(app, auth, 'license_gallery', {
+ fields: ['title', 'description', 'image_path', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/partners/index.js b/modules/partners/index.js
new file mode 100644
index 0000000..1f1cf5f
--- /dev/null
+++ b/modules/partners/index.js
@@ -0,0 +1,51 @@
+const { createCrudRoutes } = require('../database/crud');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
+const sharp = require('sharp');
+
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const uploadDir = path.join(__dirname, '..', '..', 'data', 'partner_images');
+ if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
+
+ const storage = multer.diskStorage({
+ destination: uploadDir,
+ filename: (req, file, cb) => {
+ const ext = path.extname(file.originalname);
+ cb(null, 'prt_' + Date.now() + ext);
+ }
+ });
+ const upload = multer({
+ storage,
+ limits: { fileSize: 3 * 1024 * 1024 },
+ fileFilter: (req, file, cb) => {
+ const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.svg'];
+ cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
+ }
+ });
+
+ app.post('/api/admin/partners/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => {
+ if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
+ let fname = req.file.filename;
+ const ext = path.extname(fname).toLowerCase();
+ if (ext !== '.webp' && ext !== '.svg') {
+ try {
+ const webpName = path.basename(fname, ext) + '.webp';
+ await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName));
+ fs.unlinkSync(req.file.path);
+ fname = webpName;
+ } catch (e) { console.error(e); }
+ }
+ res.json({ path: fname, url: `/data/partner_images/${fname}` });
+ });
+
+ createCrudRoutes(app, auth, 'partners', {
+ fields: ['name', 'logo_path', 'sort_order', 'is_active']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/processSteps/index.js b/modules/processSteps/index.js
new file mode 100644
index 0000000..90d9c38
--- /dev/null
+++ b/modules/processSteps/index.js
@@ -0,0 +1,12 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ createCrudRoutes(app, auth, 'process_steps', {
+ fields: ['step_number', 'title', 'description', 'sort_order']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/services/index.js b/modules/services/index.js
new file mode 100644
index 0000000..ab1b086
--- /dev/null
+++ b/modules/services/index.js
@@ -0,0 +1,12 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ createCrudRoutes(app, auth, 'services', {
+ fields: ['svg_icon', 'title', 'description', 'items', 'is_large', 'sort_order', 'is_active']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/settings/index.js b/modules/settings/index.js
new file mode 100644
index 0000000..9a826c4
--- /dev/null
+++ b/modules/settings/index.js
@@ -0,0 +1,29 @@
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const { authenticateToken, requireAdmin } = auth;
+
+ app.get('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
+ db.all(`SELECT * FROM settings ORDER BY id`, (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ const obj = {};
+ rows.forEach(r => { obj[r.key] = r.value; });
+ res.json(obj);
+ });
+ });
+
+ app.put('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
+ const entries = req.body;
+ if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' });
+ const stmt = db.prepare(`UPDATE settings SET value = ? WHERE key = ?`);
+ for (const [key, value] of Object.entries(entries)) {
+ stmt.run(String(value), key);
+ }
+ stmt.finalize();
+ res.json({ message: 'Обновлено' });
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/siteData/index.js b/modules/siteData/index.js
new file mode 100644
index 0000000..174031b
--- /dev/null
+++ b/modules/siteData/index.js
@@ -0,0 +1,86 @@
+let db;
+
+function init(database) { db = database; }
+
+function loadEquipmentGallery(data) {
+ return new Promise((resolve) => {
+ db.all(`SELECT * FROM equipment_gallery ORDER BY equipment_id, sort_order`, (err, rows) => {
+ if (err || !rows) { data.equipment_gallery = []; resolve(); return; }
+ data.equipment_gallery = rows;
+ resolve();
+ });
+ });
+}
+
+function setupRoutes(app) {
+ app.get('/api/site-data', (req, res) => {
+ const data = {};
+
+ function loadTable(table, key) {
+ return new Promise((resolve) => {
+ db.all(`SELECT * FROM ${table} ORDER BY sort_order`, (err, rows) => {
+ if (err) { data[key] = []; resolve(); return; }
+ if (table === 'equipment' || table === 'partners') {
+ rows = rows.filter(r => r.is_active == 1);
+ }
+ if (table === 'services' || table === 'work_schemes') {
+ rows = rows.filter(r => r.is_active == 1);
+ }
+ data[key] = rows;
+ resolve();
+ });
+ });
+ }
+
+ function loadSettings(table, key, transformFn) {
+ return new Promise((resolve) => {
+ db.all(`SELECT * FROM ${table} ORDER BY id`, (err, rows) => {
+ if (err) { data[key] = {}; resolve(); return; }
+ if (transformFn) {
+ data[key] = transformFn(rows);
+ } else {
+ const obj = {};
+ rows.forEach(r => { obj[r.key] = r.value; });
+ data[key] = obj;
+ }
+ resolve();
+ });
+ });
+ }
+
+ Promise.all([
+ loadSettings('hero_settings', 'hero'),
+ loadTable('hero_stats', 'hero_stats'),
+ loadSettings('about_settings', 'about'),
+ loadTable('about_features', 'about_features'),
+ loadTable('services', 'services'),
+ loadTable('work_schemes', 'work_schemes'),
+ loadTable('process_steps', 'process_steps'),
+ loadTable('equipment', 'equipment'),
+ loadEquipmentGallery(data),
+ loadTable('geo_regions', 'geo_regions'),
+ loadSettings('geo_settings', 'geo'),
+ loadTable('licenses', 'licenses'),
+ loadTable('license_gallery', 'license_gallery'),
+ loadTable('public_activities', 'public_activities'),
+ loadTable('activity_gallery', 'activity_gallery'),
+ loadTable('partners', 'partners'),
+ loadSettings('settings', 'settings')
+ ]).then(() => {
+ res.json(data);
+ });
+ });
+
+ app.post('/api/leads', (req, res) => {
+ const { name, phone, message } = req.body;
+ if (!name || !phone) return res.status(400).json({ error: 'Имя и телефон обязательны' });
+ const stmt = db.prepare(`INSERT INTO leads (name, phone, message) VALUES (?, ?, ?)`);
+ stmt.run(name.trim(), phone.trim(), (message || '').trim(), function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ res.status(201).json({ id: this.lastID, message: 'Заявка сохранена' });
+ });
+ stmt.finalize();
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/users/index.js b/modules/users/index.js
new file mode 100644
index 0000000..ae9f430
--- /dev/null
+++ b/modules/users/index.js
@@ -0,0 +1,84 @@
+const bcrypt = require('bcryptjs');
+
+let db;
+
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ const { authenticateToken, requireAdmin } = auth;
+
+ app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
+ db.all(`SELECT id, login, full_name, role, created_at FROM users ORDER BY id`, (err, rows) => {
+ if (err) return res.status(500).json({ error: 'Ошибка БД' });
+ res.json(rows);
+ });
+ });
+
+ app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
+ const { login, password, full_name, role } = req.body;
+ if (!login || !password) return res.status(400).json({ error: 'Логин и пароль обязательны' });
+ const hash = bcrypt.hashSync(password, 10);
+ const stmt = db.prepare(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, ?)`);
+ stmt.run(login, hash, full_name || '', role || 'admin', function(err) {
+ if (err) {
+ if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Пользователь с таким логином уже существует' });
+ return res.status(500).json({ error: 'Ошибка создания' });
+ }
+ db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [this.lastID], (e2, row) => {
+ res.status(201).json(row);
+ });
+ });
+ stmt.finalize();
+ });
+
+ app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ const { login, password, full_name, role } = req.body;
+
+ if (password) {
+ const hash = bcrypt.hashSync(password, 10);
+ const stmt = db.prepare(`UPDATE users SET login = ?, password_hash = ?, full_name = ?, role = ? WHERE id = ?`);
+ stmt.run(login, hash, full_name || '', role || 'admin', id, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка обновления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => {
+ res.json(row);
+ });
+ });
+ stmt.finalize();
+ } else {
+ const stmt = db.prepare(`UPDATE users SET login = ?, full_name = ?, role = ? WHERE id = ?`);
+ stmt.run(login, full_name || '', role || 'admin', id, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка обновления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => {
+ res.json(row);
+ });
+ });
+ stmt.finalize();
+ }
+ });
+
+ app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
+ const id = parseInt(req.params.id);
+ if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
+ if (id === req.user.id) return res.status(400).json({ error: 'Нельзя удалить самого себя' });
+
+ const superadmin = process.env.ADMIN_USERNAME || 'admin';
+ db.get(`SELECT login FROM users WHERE id = ?`, [id], (e1, row) => {
+ if (row && row.login === superadmin) {
+ return res.status(400).json({ error: 'Нельзя удалить суперадминистратора из .env' });
+ }
+ const stmt = db.prepare(`DELETE FROM users WHERE id = ?`);
+ stmt.run(id, function(err) {
+ if (err) return res.status(500).json({ error: 'Ошибка удаления' });
+ if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
+ res.json({ message: 'Пользователь удалён' });
+ });
+ stmt.finalize();
+ });
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/modules/workSchemes/index.js b/modules/workSchemes/index.js
new file mode 100644
index 0000000..0fa9567
--- /dev/null
+++ b/modules/workSchemes/index.js
@@ -0,0 +1,12 @@
+const { createCrudRoutes } = require('../database/crud');
+
+let db;
+function init(database) { db = database; }
+
+function setupRoutes(app, auth) {
+ createCrudRoutes(app, auth, 'work_schemes', {
+ fields: ['svg_icon', 'title', 'description', 'items', 'sort_order', 'is_active']
+ });
+}
+
+module.exports = { init, setupRoutes };
diff --git a/package.json b/package.json
index f8bf237..bfea10c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,10 @@
{
"dependencies": {
+ "bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "jsonwebtoken": "^9.0.3",
+ "multer": "^2.2.0",
"sharp": "^0.34.5",
"sqlite3": "^6.0.1"
},
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..4247665
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,989 @@
+
+
+
+
+
+Админ-панель | СТС-Авто
+
+
+
+
+
+
+
+
+
СТС-Авто
+
Административная панель
+
+
+
Войти
+
Неверный логин или пароль
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/index.html b/public/index.html
index 2b28ba0..835ad07 100644
--- a/public/index.html
+++ b/public/index.html
@@ -278,6 +278,19 @@ img { max-width: 100%; display: block; }
.equipment-card-image { height: clamp(160px, 25vw, 220px); overflow: hidden; position: relative; }
.equipment-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
.equipment-card:hover .equipment-card-image img { transform: scale(1.05); }
+.equipment-card-image:hover .equip-nav { opacity: 1; }
+.equip-nav { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,.5); color:#fff; border:none; cursor:pointer; padding:8px 12px; border-radius:3px; font-size:18px; z-index:3; opacity:0; transition:opacity .3s; }
+.equip-nav.prev { left:8px; }
+.equip-nav.next { right:8px; }
+.equip-dots { position: absolute; bottom:8px; left:50%; transform:translateX(-50%); display:flex; gap:6px; z-index:3; }
+.equip-dot { width:8px; height:8px; border-radius:50%; background:rgba(255,255,255,.4); border:none; cursor:pointer; padding:0; transition:all .3s; }
+.equip-dot.active { background:var(--orange); width:20px; border-radius:4px; }
+.equip-fullview { position:fixed; inset:0; background:rgba(0,0,0,.93); z-index:10001; display:flex; align-items:center; justify-content:center; cursor:pointer; opacity:0; visibility:hidden; transition:all .3s; }
+.equip-fullview.open { opacity:1; visibility:visible; }
+.equip-fullview img { max-width:90vw; max-height:90vh; object-fit:contain; border-radius:4px; }
+.equip-fullview-close { position:absolute; top:20px; right:20px; width:40px; height:40px; background:var(--orange); border-radius:50%; border:none; color:#fff; font-size:20px; cursor:pointer; z-index:2; display:flex; align-items:center; justify-content:center; }
+.equipment-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
+.equipment-card:hover .equipment-card-image img { transform: scale(1.05); }
.equipment-card-image .equip-overlay {
position: absolute; bottom: 0; left: 0; right: 0; padding: clamp(6px, 1.2vw, 10px) clamp(10px, 1.8vw, 16px);
background: linear-gradient(transparent, rgba(0,0,0,0.7));
@@ -296,7 +309,7 @@ img { max-width: 100%; display: block; }
font-family: 'Montserrat', sans-serif; text-transform: uppercase; letter-spacing: 0.5px;
}
.equipment-age-badge::before {
- content: '✓';
+ content: '\2713';
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; background: #27AE60; color: #fff;
border-radius: 50%; font-size: 10px;
@@ -653,13 +666,11 @@ img { max-width: 100%; display: block; }
-
-
-
О компании
@@ -696,577 +706,54 @@ img { max-width: 100%; display: block; }
Лицензии
Деятельность
Контакты
- 8(800) 555-29-31
+ 8(800) 555-29-31
-
-
-
-
-
-
-
-
- Лицензии СРО и НОСТРОЙ
-
-
демонтаж нефтегазопроводов по России, утилизация отходов
-
Собственная спецтехника. Лицензии СРО и НОСТРОЙ. Более {years} лет опыта. Работаем на опасных производственных объектах без привлечения субподрядчиков.
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
Собственная техника и полный контроль
-
Наш автопарк состоит из более чем 80 единиц современной спецтехники, возраст которой не превышает 5 лет. Мы используем экскаваторы, краны, погрузчики, самосвалы и тралы ведущих мировых производителей: KAMAZ, Komatsu, Zoomlion, SDLG, Соболь, УАЗ.
-
Все работы выполняются исключительно силами компании — без привлечения субподрядчиков. Это гарантирует полное соответствие качества, сроков и ответственности на каждом этапе проекта.
-
Мы работаем в самых сложных условиях: труднодоступные районы, заболоченные территории, Крайний Север. Гибкие варианты оплаты: работаем по договору, включая вариант расчёта трубой за услуги (бартер с металлоломом после демонтажа).
-
-
-
-
Парк техники не старше 5 лет Более 80 единиц современной спецтехники
-
-
-
-
Без субподрядчиков Все работы силами компании
-
-
-
-
Сложные условия Болота, Крайний Север, труднодоступные районы
-
-
-
-
Гибкая оплата Включая расчёт трубой (бартер)
-
-
-
-
-
+
-
-
-
-
-
-
Демонтаж нефтегазопроводов, утилизация отходов
-
Выполняем полный цикл работ по демонтажу магистральных и промысловых трубопроводов любого типа и диаметра, а также утилизацию отходов.
-
- Нефтепроводы, газопроводы, водоводы — все типы магистральных и промысловых сетей
- Полный цикл: от вскрытия и вырезки дефектных участков до вывоза и сдачи металлолома
- Работа с трубопроводами любого диаметра в любых климатических условиях
- Разделка ж/д транспорта, переработка и утилизация для предприятий
-
-
-
-
-
Техническая рекультивация земель
-
Восстановление нарушенных земель после проведения строительных и ремонтных работ.
-
- Засыпка траншей, планировка, восстановление плодородного слоя
- Работа с нефтезагрязнёнными, засоленными землями
- Сдача по актам под контролем экологов
-
-
-
-
-
Расчистка территории от мелколесья и ДКР
-
Подготовка полосы отвода: расчистка от древесно-кустарниковой растительности.
-
- Валка, корчёвка, дробление пней
- Планировка полосы отвода
- Мульчеры и кусторезы на гусеничном ходу
-
-
-
-
-
Устройство лежневого настила
-
Обеспечение проходимости для тяжёлой техники по болотам и слабым грунтам.
-
- Изготовление секций из бревен и бруса
- Укладка перекрытий на слабых грунтах
- Обеспечение проходимости для тяжёлой гусеничной техники
-
-
-
-
+
-
-
-
-
-
-
-
Схема 1 — с доставкой заказчику
-
Подходит для компаний, планирующих повторное использование труб или их последующую реализацию. Расходный договор для заказчика.
-
- Полный цикл работ по демонтажу трубопровода
- Резка и маркировка труб в соответствии с требованиями
- Транспортировка демонтированных труб на указанные территории хранения
- Оформление всей сопроводительной документации (акты приёма-передачи)
-
-
-
-
-
Схема 2 — вывоз на базы подрядчика
-
Экономически выгодное решение для заказчиков, заинтересованных в минимизации затрат. Доходный договор для заказчика.
-
- Полный цикл работ по демонтажу трубопровода
- Перевозка и размещение на сертифицированных площадках временного хранения ООО «СТС-АВТО»
- Покупка трубы в земле — заказчик получает доход от металлолома
- Оформление полного пакета закрывающей документации
-
-
-
-
+
-
-
-
-
-
-
1
-
-
Заявка
-
Вы оставляете заявку на сайте, по электронной почте или звоните нам. Наши специалисты оперативно свяжутся с вами для обсуждения деталей проекта, объёма работ и сроков.
-
-
-
-
2
-
-
Выезд специалиста и оценка
-
Наш инженер выезжает на объект для проведения осмотра, оценки объёма работ, определения необходимых ресурсов и техники. По результатам составляется техническое задание и подписывается договор.
-
-
-
-
3
-
-
Выполнение работ
-
Мобилизуем технику и персонал на объект. Выполняем демонтаж, утилизацию отходов и рекультивацию в соответствии с проектной документацией. Ежедневные отчёты о ходе работ.
-
-
-
-
4
-
-
Сдача объекта
-
По завершении всех работ подписывается акт выполненных работ, передаётся полный комплект закрывающей документации. При необходимости — сопровождение сдачи объекта экспертизе.
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
КАМАЗ
-
-
-
Автокран КАМАЗ КС-55717К-3
-
Не старше 5 лет
-
10 единиц
-
-
Грузоподъёмность 25 тонн
-
Вылет стрелы до 28 м
-
Шасси Полноприводное 6×6
-
-
-
-
-
-
-
-
-
Zoomlion
-
-
-
Автокран Zoomlion ZTC300
-
Не старше 5 лет
-
2 единицы
-
-
Грузоподъёмность 30 тонн
-
Вылет стрелы до 32 м
-
Стрела Телескопическая
-
-
-
-
-
-
-
-
-
Komatsu
-
-
-
Экскаватор гусеничный Komatsu PC210-10M0
-
Не старше 5 лет
-
20 единиц
-
-
Масса 21 тонна
-
Объём ковша 1.0–1.2 м³
-
Глубина копания до 6.5 м
-
-
-
-
-
-
-
-
-
Zoomlion
-
-
-
Экскаватор гусеничный Zoomlion ZE215E
-
Не старше 5 лет
-
2 единицы
-
-
Масса 21.5 тонны
-
Объём ковша 1.1 м³
-
Аналог Komatsu PC210
-
-
-
-
-
-
-
-
-
SDLG
-
-
-
Погрузчик колёсный SDLG LG933L
-
Не старше 5 лет
-
2 единицы
-
-
Грузоподъёмность 3 тонны
-
Объём ковша 1.7 м³
-
Тип Колёсный
-
-
-
-
-
-
-
-
-
Соболь
-
-
-
Соболь 4×4 грузо-пассажирский
-
Не старше 5 лет
-
13 единиц
-
-
Грузоподъёмность до 1 т
-
Привод Полный 4×4
-
Назначение Доставка персонала и запчастей
-
-
-
-
-
-
-
-
-
Ломовоз
-
-
-
Ломовоз с КМУ (кран-манипулятор)
-
Не старше 5 лет
-
3 единицы
-
-
Грузоподъёмность КМУ до 8 т
-
Объём платформы до 20 м³
-
Назначение Вывоз металлолома
-
-
-
-
-
-
-
-
-
КамАЗ
-
-
-
Седельный тягач Камаз 53504-50
-
Не старше 5 лет
-
6 единиц
-
-
Тяговый класс до 50 т
-
Привод Полный привод
-
Назначение Перевозка негабарита и тралов
-
-
-
-
-
-
-
-
-
Амфибия
-
-
-
Экскаватор-амфибия на понтонной платформе
-
Не старше 5 лет
-
4 единицы
-
-
Тип Гусеничный, плавающий
-
Работает На болотах и водных преградах
-
Особенность Там, где не видно дна
-
-
-
-
-
-
-
-
-
УАЗ
-
-
-
УАЗ Пикап (полный привод)
-
Не старше 5 лет
-
Несколько единиц
-
-
Привод Полный привод
-
Назначение Доставка запчастей на месторождения
-
-
-
-
-
-
-
-
-
Тралы
-
-
-
Тралы тяжеловозы для доставки спецтехники
-
Не старше 5 лет
-
3 единицы
-
-
Тяговый класс до 50 т
-
Назначение Доставка спецтехники на объекты
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
🔧 Труднодоступные районы
-
Мы располагаем специализированной техникой для работы в болотах ХМАО-Югры, ЯНАО, на севере Красноярского края, в Иркутской области. Гусеничная техника и лежневые настилы обеспечивают проходимость даже в самых сложных условиях.
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
СРО — допуск к работам на опасных объектах Свидетельство о допуске к работам на опасных производственных объектах нефтегазового комплекса.
-
-
-
-
Членство в НОСТРОЙ Действующее членство в Национальном объединении строителей — допуск к строительным работам.
-
-
-
-
Сертификат ISO 9001 (качество) Международный стандарт системы менеджмента качества.
-
-
-
-
Сертификат OHSAS 18001 (охрана труда) Система менеджмента охраны труда и промышленной безопасности.
-
-
-
-
Договор ДМС для сотрудников Добровольное медицинское страхование всех сотрудников.
-
-
-
-
Лицензия на заготовку и переработку лома Лицензия на заготовку, хранение и переработку лома черных металлов.
-
-
-
-
-
-
-
-
-
-
Лицензия на отходы I–IV класса опасности
-
№ (66) -6527 - СТО/П от 21 мая 2019 г.
-
-
-
-
-
-
-
-
-
Лицензия на лом черных и цветных металлов
-
№ ЛМ 000092 от 31 октября 2018 г.
-
-
-
-
+
-
-
-
-
-
-
-
Спонсорство
-
Поддерживаем спортивные, культурные и общественные мероприятия в регионах присутствия. Помогаем развитию местных сообществ.
-
-
-
-
Благотворительность
-
Оказываем адресную помощь детским домам, ветеранским и общественным организациям. Участвуем в социальных проектах.
-
-
-
-
Экология
-
Участвуем в экологических акциях, субботниках и проектах по озеленению. Следим за снижением воздействия на окружающую среду.
-
-
-
-
Поддержка образования
-
Сотрудничаем с профильными вузами и колледжами. Организуем практику для студентов, участвуем в ярмарках вакансий.
-
-
-
-
Благодарственные письма
-
-
-
-
-
-
-
Благодарственное письмо
-
За вклад в развитие региона
-
-
-
-
-
-
-
-
-
Благодарственное письмо
-
За благотворительную деятельность
-
-
-
-
+
+
+
+
+
+
-
@@ -1280,102 +767,15 @@ img { max-width: 100%; display: block; }
-
-
+
+
×
+
+
-
-
-
-
-
@@ -1398,79 +797,445 @@ img { max-width: 100%; display: block; }