diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..84b5c4b
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,13 @@
+{
+ "require": {
+ "php": "^8.1",
+ "slim/slim": "^4.0",
+ "slim/psr7": "^1.6",
+ "php-di/php-di": "^7.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "App\\": "src/"
+ }
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..04cc20c
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,675 @@
+let currentSchema = null;
+let currentTable = null;
+let currentMeta = null;
+let table = null;
+let enterHandler = null;
+
+// Простая обёртка для fetch JSON
+async function api(url, method = 'GET', body) {
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
+ if (body) opts.body = JSON.stringify(body);
+
+ const res = await fetch(url, opts);
+ if (!res.ok) {
+ const txt = await res.text();
+ throw new Error(`HTTP ${res.status}: ${txt}`);
+ }
+ return res.json();
+}
+
+// Логин
+document.getElementById('loginBtn').addEventListener('click', async () => {
+ console.log('=== LOGIN BUTTON CLICKED ===');
+ const user = document.getElementById('loginUser').value.trim();
+ const pass = document.getElementById('loginPass').value;
+ const statusEl = document.getElementById('loginStatus');
+
+ if (!user || !pass) {
+ statusEl.textContent = 'Введите логин и пароль';
+ statusEl.style.color = 'red';
+ return;
+ }
+
+ statusEl.textContent = 'Проверяем подключение...';
+ statusEl.style.color = '';
+
+ try {
+ console.log('Отправляем /api/login:', { user, pass: '***' });
+ const res = await api('/api/login', 'POST', { user, pass });
+ console.log('Login ответ:', res);
+
+ if (res.ok) {
+ statusEl.textContent = '✓ Авторизация успешна';
+ statusEl.style.color = 'green';
+ await loadTree();
+ } else {
+ statusEl.textContent = 'Ошибка: ' + (res.error || 'Неизвестная ошибка');
+ statusEl.style.color = 'red';
+ }
+ } catch (e) {
+ console.error('Login ошибка:', e);
+ statusEl.textContent = 'Ошибка подключения: ' + e.message;
+ statusEl.style.color = 'red';
+ }
+});
+
+// loadTree
+async function loadTree() {
+ console.log('=== LOAD TREE ВЫЗВАН ===');
+ const treeEl = document.getElementById('tree');
+ treeEl.style.color = '';
+ treeEl.innerHTML = 'Загрузка...';
+
+ try {
+ const tree = await api('/api/tree');
+ console.log('Дерево получено:', tree);
+ treeEl.innerHTML = '';
+
+ tree.forEach(schema => {
+ const schemaEl = document.createElement('div');
+ schemaEl.className = 'schema';
+ schemaEl.textContent = schema.name;
+ schemaEl.style.cursor = 'pointer';
+ schemaEl.style.padding = '4px';
+ schemaEl.style.fontWeight = 'bold';
+ treeEl.appendChild(schemaEl);
+
+ (schema.tables || []).forEach(tbl => {
+ const tableEl = document.createElement('div');
+ tableEl.className = 'table';
+ tableEl.textContent = ` ${tbl}`;
+ tableEl.style.cursor = 'pointer';
+ tableEl.style.paddingLeft = '12px';
+ tableEl.style.padding = '2px';
+ tableEl.addEventListener('click', () => selectTable(schema.name, tbl));
+ treeEl.appendChild(tableEl);
+ });
+ });
+ } catch (e) {
+ console.error('loadTree ошибка:', e);
+ treeEl.innerHTML = 'Ошибка загрузки: ' + e.message;
+ treeEl.style.color = 'red';
+ }
+}
+
+let lastEditedRow = null;
+
+async function selectTable(schema, tableName) {
+ currentSchema = schema;
+ currentTable = tableName;
+ lastEditedRow = null;
+
+ console.log('=== SELECT TABLE ===', { schema, tableName });
+
+ if (enterHandler) {
+ document.removeEventListener('keydown', enterHandler);
+ enterHandler = null;
+ }
+
+ currentMeta = await api(
+ `/api/table/meta?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(tableName)}`
+ );
+
+ const columns = currentMeta.columns.map(col => ({
+ title: col.COLUMN_NAME,
+ field: col.COLUMN_NAME,
+ editor: "input",
+ headerFilter: "input"
+ }));
+
+ if (table) {
+ table.destroy();
+ table = null;
+ }
+
+ table = new Tabulator("#table", {
+ selectableRows: 1,
+ columns: columns,
+ layout: "fitColumns",
+ resizableColumnFit: true,
+
+ pagination: true,
+ paginationMode: "remote",
+ paginationSize: 50,
+ paginationSizeSelector: [25, 50, 100, 200],
+
+ filterMode: "remote",
+ sortMode: "remote",
+
+ ajaxURL: "/api/table/data",
+ ajaxConfig: "POST",
+ ajaxContentType: "json",
+
+ ajaxParams: function () {
+ const headerFilters = this.getHeaderFilters ? this.getHeaderFilters() : [];
+ const filters = (headerFilters || []).map(f => ({
+ field: f.field,
+ value: f.value
+ })).filter(f => f.value !== null && f.value !== '');
+
+ const sorters = this.getSorters ? this.getSorters() : [];
+ const sort = (sorters && sorters.length > 0) ? {
+ field: sorters[0].field,
+ dir: sorters[0].dir
+ } : null;
+
+ const params = {
+ schema: currentSchema,
+ table: currentTable,
+ filters: filters,
+ sort: sort,
+ columns: currentMeta.columns,
+ page: this.getPage ? this.getPage() : 1,
+ pageSize: this.getPageSize ? this.getPageSize() : 50
+ };
+
+ console.log('📤 Запрос к серверу:', {
+ page: params.page,
+ pageSize: params.pageSize,
+ filters: params.filters,
+ sort: params.sort
+ });
+
+ return params;
+ },
+
+ ajaxResponse: function (url, params, response) {
+ console.log('📥 Ответ от сервера:', {
+ page: response.current_page,
+ last_page: response.last_page,
+ total: response.total,
+ rows: response.data ? response.data.length : 0
+ });
+
+ return {
+ last_page: response.last_page || 1,
+ data: response.data || []
+ };
+ },
+
+ rowClick: function(e, row) {
+ row.toggleSelect();
+ },
+
+ cellEdited: function(cell) {
+ const row = cell.getRow();
+ lastEditedRow = row.getData();
+ row.getElement().style.backgroundColor = '#fffae6';
+
+ console.log('Ячейка отредактирована:', cell.getField(), '→', cell.getValue());
+ },
+
+ headerFilterLiveFilterDelay: 800,
+
+ dataFiltering: function(filters) {
+ const activeFilters = filters.filter(f => f.value);
+ console.log('🔍 Активные фильтры:', activeFilters.map(f => `${f.field}=${f.value}`));
+ },
+
+ dataFiltered: function(filters, rows) {
+ console.log('✅ Фильтрация применена, записей на странице:', rows.length);
+ },
+
+ pageLoaded: function(pageno) {
+ console.log('📄 Загружена страница:', pageno);
+ }
+ });
+
+ enterHandler = async function(e) {
+ if (e.key === 'Enter' && lastEditedRow && currentSchema && currentTable) {
+ e.preventDefault();
+ console.log('Enter нажат, сохраняем строку:', lastEditedRow);
+
+ try {
+ const res = await api('/api/table/update', 'POST', {
+ schema: currentSchema,
+ table: currentTable,
+ row: lastEditedRow
+ });
+
+ console.log('Сохранено:', res);
+ lastEditedRow = null;
+
+ table.getRows().forEach(r => {
+ r.getElement().style.backgroundColor = '';
+ });
+
+ await table.replaceData();
+ } catch (err) {
+ console.error('Ошибка сохранения:', err);
+ alert('Ошибка: ' + err.message);
+ }
+ }
+ };
+
+ document.addEventListener('keydown', enterHandler);
+}
+
+
+
+// CRUD кнопки
+document.getElementById('btnInsert').addEventListener('click', async () => {
+ if (!currentSchema || !currentTable || !currentMeta) {
+ alert('Сначала выберите таблицу');
+ return;
+ }
+
+ const rowData = {};
+ const fkFields = [];
+
+ currentMeta.columns.forEach(col => {
+ const name = col.COLUMN_NAME;
+
+ if (col.IS_AUTO_INCREMENT) return;
+
+ if (col.HAS_DEFAULT && col.COLUMN_DEFAULT !== null) {
+ if (String(col.COLUMN_DEFAULT).toUpperCase().includes('CURRENT_TIMESTAMP')) return;
+ rowData[name] = col.COLUMN_DEFAULT;
+ return;
+ }
+
+ if (col.IS_FOREIGN_KEY) {
+ fkFields.push({
+ name: name,
+ ref_schema: col.FOREIGN_KEY.ref_schema,
+ ref_table: col.FOREIGN_KEY.ref_table,
+ ref_column: col.FOREIGN_KEY.ref_column,
+ required: col.IS_REQUIRED
+ });
+
+ if (!col.IS_REQUIRED) {
+ rowData[name] = null;
+ }
+ return;
+ }
+
+ if (col.IS_REQUIRED) {
+ if (col.EDITOR_TYPE === 'number') rowData[name] = 0;
+ else if (col.EDITOR_TYPE === 'datetime' || col.EDITOR_TYPE === 'date')
+ rowData[name] = new Date().toISOString().slice(0, 10);
+ else if (col.EDITOR_TYPE === 'time') rowData[name] = '00:00:00';
+ else rowData[name] = '';
+ }
+ });
+
+ // ✅ Если есть обязательные FK - показываем модальное окно для выбора
+ const requiredFKs = fkFields.filter(f => f.required);
+ if (requiredFKs.length > 0) {
+ try {
+ const fkValues = await promptForForeignKeys(requiredFKs);
+ if (!fkValues) {
+ // Пользователь отменил
+ return;
+ }
+
+ // Добавляем выбранные FK значения
+ Object.assign(rowData, fkValues);
+ } catch (err) {
+ console.error('Ошибка получения FK значений:', err);
+ alert('Ошибка: ' + err.message);
+ return;
+ }
+ }
+
+ try {
+ await api('/api/table/insert', 'POST', {
+ schema: currentSchema,
+ table: currentTable,
+ row: rowData
+ });
+ await table.replaceData();
+ alert('✓ Строка успешно создана');
+ } catch (e) {
+ console.error('Ошибка вставки:', e);
+ alert('Ошибка вставки: ' + e.message);
+ }
+});
+
+// ✅ Функция для выбора FK значений
+async function promptForForeignKeys(fkFields) {
+ // Создаём модальное окно
+ const modal = document.createElement('div');
+ modal.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ `;
+
+ const dialog = document.createElement('div');
+ dialog.style.cssText = `
+ background: white;
+ padding: 20px;
+ border-radius: 8px;
+ max-width: 500px;
+ width: 90%;
+ max-height: 80vh;
+ overflow-y: auto;
+ `;
+
+ let html = '
Заполните обязательные поля ';
+
+ // Загружаем доступные значения для каждого FK
+ const fkOptions = {};
+ for (const fk of fkFields) {
+ try {
+ const result = await api(
+ `/api/fk-values?schema=${encodeURIComponent(fk.ref_schema)}&` +
+ `table=${encodeURIComponent(fk.ref_table)}&` +
+ `column=${encodeURIComponent(fk.ref_column)}`
+ );
+ fkOptions[fk.name] = result.values || [];
+ } catch (err) {
+ console.error(`Ошибка загрузки FK для ${fk.name}:`, err);
+ fkOptions[fk.name] = [];
+ }
+ }
+
+ // Создаём поля для каждого FK
+ fkFields.forEach(fk => {
+ const options = fkOptions[fk.name];
+ html += `
+
+
+ ${fk.name}
+ *
+
+ (→ ${fk.ref_table}.${fk.ref_column})
+
+
+ `;
+
+ if (options.length > 0) {
+ // Если есть значения - показываем select
+ html += ``;
+ html += '-- Выберите значение -- ';
+ options.forEach(val => {
+ html += `${escapeHtml(val)} `;
+ });
+ html += ' ';
+ } else {
+ // Если нет значений - показываем input
+ html += `
+
+ ⚠️ Нет доступных значений в ${fk.ref_table}
+ `;
+ }
+
+ html += '
';
+ });
+
+ html += `
+
+
+ Отмена
+
+
+ Создать строку
+
+
+ `;
+
+ dialog.innerHTML = html;
+ modal.appendChild(dialog);
+ document.body.appendChild(modal);
+
+ // Обработка кнопок
+ return new Promise((resolve) => {
+ document.getElementById('fkCancel').addEventListener('click', () => {
+ document.body.removeChild(modal);
+ resolve(null);
+ });
+
+ document.getElementById('fkSubmit').addEventListener('click', () => {
+ const values = {};
+ let allFilled = true;
+
+ for (const fk of fkFields) {
+ const input = document.getElementById(`fk_${fk.name}`);
+ const value = input.value.trim();
+
+ if (!value) {
+ alert(`Поле "${fk.name}" обязательно для заполнения!`);
+ allFilled = false;
+ break;
+ }
+
+ values[fk.name] = value;
+ }
+
+ if (allFilled) {
+ document.body.removeChild(modal);
+ resolve(values);
+ }
+ });
+ });
+}
+
+// Вспомогательная функция для экранирования HTML
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+
+document.getElementById('btnUpdate').addEventListener('click', async () => {
+ if (!table) return;
+ let selected = table.getSelectedData();
+ let rowToSave = selected.length === 1 ? selected[0] : lastEditedRow;
+ if (!rowToSave) {
+ alert('Кликните по строке или отредактируйте ячейку');
+ return;
+ }
+
+ try {
+ const res = await api('/api/table/update', 'POST', {
+ schema: currentSchema, table: currentTable, row: rowToSave
+ });
+ lastEditedRow = null;
+ await table.replaceData();
+ alert(`✓ Обновлено строк: ${res.updated}`);
+ } catch (e) {
+ console.error(e);
+ alert('Ошибка обновления: ' + e.message);
+ }
+});
+
+document.getElementById('btnDelete').addEventListener('click', async () => {
+ if (!table || !currentSchema || !currentTable) return;
+ const selected = table.getSelectedData();
+ if (selected.length === 0) {
+ alert('Ничего не выбрано');
+ return;
+ }
+ if (!confirm(`Удалить ${selected.length} строк(и)?`)) return;
+
+ try {
+ for (const row of selected) {
+ await api('/api/table/delete', 'POST', { schema: currentSchema, table: currentTable, row });
+ }
+ await table.replaceData();
+ } catch (e) {
+ console.error(e);
+ alert('Ошибка удаления: ' + e.message);
+ }
+});
+
+// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ CSV ==========
+
+// Функция автоопределения разделителя
+function detectDelimiter(text) {
+ const firstLine = text.split('\n')[0];
+ const semicolonCount = (firstLine.match(/;/g) || []).length;
+ const commaCount = (firstLine.match(/,/g) || []).length;
+
+ // Если есть точки с запятой и их больше, чем запятых, используем ;
+ if (semicolonCount > 0 && semicolonCount >= commaCount) {
+ return ';';
+ }
+ return ',';
+}
+
+// Улучшенный парсер CSV с поддержкой ; и , разделителей
+function parseCSV(text) {
+ // Автоопределение разделителя
+ const delimiter = detectDelimiter(text);
+ console.log('Обнаружен разделитель CSV:', delimiter);
+
+ const lines = [];
+ let currentLine = [];
+ let currentField = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+ const nextChar = text[i + 1];
+
+ if (char === '"') {
+ if (inQuotes && nextChar === '"') {
+ currentField += '"';
+ i++; // пропускаем следующую кавычку
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (char === delimiter && !inQuotes) {
+ currentLine.push(currentField.trim());
+ currentField = '';
+ } else if ((char === '\n' || char === '\r') && !inQuotes) {
+ if (char === '\r' && nextChar === '\n') {
+ i++; // пропускаем \n после \r
+ }
+ if (currentField || currentLine.length > 0) {
+ currentLine.push(currentField.trim());
+ lines.push(currentLine);
+ currentLine = [];
+ currentField = '';
+ }
+ } else {
+ currentField += char;
+ }
+ }
+
+ // Добавляем последнюю строку
+ if (currentField || currentLine.length > 0) {
+ currentLine.push(currentField.trim());
+ lines.push(currentLine);
+ }
+
+ return lines;
+}
+
+// ========== ИМПОРТ CSV ==========
+document.getElementById('btnImportCSV').addEventListener('click', () => {
+ if (!currentSchema || !currentTable) {
+ alert('Сначала выберите таблицу');
+ return;
+ }
+ document.getElementById('csvFileInput').click();
+});
+
+document.getElementById('csvFileInput').addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ console.log('Содержимое файла:', text.substring(0, 200)); // первые 200 символов для отладки
+
+ const rows = parseCSV(text);
+ console.log('Распарсенные строки:', rows);
+
+ if (rows.length === 0) {
+ alert('CSV файл пуст');
+ return;
+ }
+
+ const headers = rows[0];
+ console.log('Заголовки:', headers);
+
+ const dataRows = rows.slice(1);
+
+ if (dataRows.length === 0) {
+ alert('Нет данных для импорта (только заголовки)');
+ return;
+ }
+
+ // Преобразуем в массив объектов
+ const records = dataRows.map(row => {
+ const obj = {};
+ headers.forEach((header, i) => {
+ obj[header] = row[i] || null;
+ });
+ return obj;
+ });
+
+ console.log('Импортируем записи:', records);
+
+ const result = await api('/api/table/import-csv', 'POST', {
+ schema: currentSchema,
+ table: currentTable,
+ rows: records
+ });
+
+ alert(`✓ Импортировано строк: ${result.inserted}\nОшибок: ${result.errors}`);
+ await table.replaceData();
+
+ // Очищаем input
+ e.target.value = '';
+ } catch (err) {
+ console.error('Ошибка импорта:', err);
+ alert('Ошибка импорта: ' + err.message);
+ }
+});
+
+// ========== ЭКСПОРТ CSV ==========
+document.getElementById('btnExportCSV').addEventListener('click', async () => {
+ if (!currentSchema || !currentTable || !table) {
+ alert('Сначала выберите таблицу');
+ return;
+ }
+
+ try {
+ // Получаем текущие фильтры и сортировку
+ const headerFilters = table.getHeaderFilters ? table.getHeaderFilters() : [];
+ const filters = (headerFilters || []).map(f => ({ field: f.field, value: f.value }));
+ const sorters = table.getSorters ? table.getSorters() : [];
+ const sort = (sorters && sorters.length > 0) ? { field: sorters[0].field, dir: sorters[0].dir } : null;
+
+ const result = await api('/api/table/export-csv', 'POST', {
+ schema: currentSchema,
+ table: currentTable,
+ filters: filters,
+ sort: sort,
+ columns: currentMeta.columns
+ });
+
+ // Создаем CSV текст
+ const csv = result.csv;
+
+ // Скачиваем файл
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); // BOM для Excel
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+
+ link.setAttribute('href', url);
+ link.setAttribute('download', `${currentTable}_${new Date().toISOString().slice(0,10)}.csv`);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ console.log(`Экспортировано ${result.rowCount} строк`);
+ } catch (err) {
+ console.error('Ошибка экспорта:', err);
+ alert('Ошибка экспорта: ' + err.message);
+ }
+});
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..3682609
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,118 @@
+
+
+
+
+ Turbo RFQ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..3f76b51
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,230 @@
+addErrorMiddleware(true, true, true);
+
+$container->set('db', function () {
+ return \App\Db::connectFromSession();
+});
+
+// --- ROUTES ---
+
+// Статическая страница с фронтендом
+$app->get('/', function (Request $request, Response $response) {
+ // Очень простой view встроен прямо здесь
+ $html = file_get_contents(__DIR__ . '/index.html'); // вынесем в отдельный файл ниже
+ $response->getBody()->write($html);
+ return $response;
+});
+
+// Логин: сохраняет логин/пароль MariaDB в сессии после проверки
+$app->post('/api/login', function (Request $request, Response $response) {
+ $data = json_decode((string)$request->getBody(), true);
+ $user = $data['user'] ?? '';
+ $pass = $data['pass'] ?? '';
+
+ try {
+ \App\Db::testConnection($user, $pass);
+ $_SESSION['db_user'] = $user;
+ $_SESSION['db_pass'] = $pass;
+
+ $payload = ['ok' => true];
+ $status = 200;
+ } catch (\RuntimeException $e) {
+ $payload = ['ok' => false, 'error' => $e->getMessage()];
+ $status = 401;
+ }
+
+ $response->getBody()->write(json_encode($payload));
+ return $response->withHeader('Content-Type', 'application/json')
+ ->withStatus($status);
+});
+
+// Middleware на /api/*: проверяем, что аутентифицированы
+$app->add(function (Request $request, $handler) {
+ $path = $request->getUri()->getPath();
+ if (str_starts_with($path, '/api/') && $path !== '/api/login') {
+ if (empty($_SESSION['db_user']) || empty($_SESSION['db_pass'])) {
+ $res = new \Slim\Psr7\Response();
+ $res->getBody()->write(json_encode(['error' => 'Not authenticated']));
+ return $res->withStatus(401)->withHeader('Content-Type', 'application/json');
+ }
+ }
+ return $handler->handle($request);
+});
+
+// API: дерево схем/таблиц
+$app->get('/api/tree', function (Request $request, Response $response) use ($container) {
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $tree = $meta->getSchemaTree();
+
+ $response->getBody()->write(json_encode($tree));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+// API: метаданные таблицы
+$app->get('/api/table/meta', function (Request $request, Response $response) use ($container) {
+ $params = $request->getQueryParams();
+ $schema = $params['schema'] ?? '';
+ $table = $params['table'] ?? '';
+
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $data = $meta->getTableMeta($schema, $table);
+
+ $response->getBody()->write(json_encode($data));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+// API: данные таблицы
+$app->post('/api/table/data', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+
+ $schema = $payload['schema'] ?? '';
+ $table = $payload['table'] ?? '';
+ $page = (int)($payload['page'] ?? 1);
+ $pageSize = (int)($payload['pageSize'] ?? 50);
+ $filters = $payload['filters'] ?? [];
+ $sort = $payload['sort'] ?? null;
+ $columns = $payload['columns'] ?? [];
+
+ // ✅ Логирование для отладки
+ error_log("Data request: page=$page, pageSize=$pageSize, filters=" . json_encode($filters));
+
+ $pdo = $container->get('db');
+ $ds = new \App\DataService($pdo);
+ $resData = $ds->fetchData($schema, $table, $columns, $page, $pageSize, $filters, $sort);
+
+ // ✅ Логирование ответа
+ error_log("Data response: " . json_encode([
+ 'data_count' => count($resData['data']),
+ 'last_page' => $resData['last_page'],
+ 'current_page' => $resData['current_page'],
+ 'total' => $resData['total']
+ ]));
+
+ $response->getBody()->write(json_encode($resData));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+// API: insert / update / delete
+$app->post('/api/table/insert', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $ds = new \App\DataService($pdo);
+
+ $schema = $payload['schema'];
+ $table = $payload['table'];
+ $row = $payload['row'];
+ $metaArr = $meta->getTableMeta($schema, $table);
+
+ $result = $ds->insertRow($schema, $table, $row, $metaArr['columns']);
+ $response->getBody()->write(json_encode($result));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+$app->post('/api/table/update', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $ds = new \App\DataService($pdo);
+
+ $schema = $payload['schema'];
+ $table = $payload['table'];
+ $row = $payload['row'];
+ $metaArr = $meta->getTableMeta($schema, $table);
+
+ $result = $ds->updateRow($schema, $table, $row, $metaArr['columns'], $metaArr['primaryKey']);
+ $response->getBody()->write(json_encode($result));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+$app->post('/api/table/delete', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $ds = new \App\DataService($pdo);
+
+ $schema = $payload['schema'];
+ $table = $payload['table'];
+ $row = $payload['row'];
+ $metaArr = $meta->getTableMeta($schema, $table);
+
+ $result = $ds->deleteRow($schema, $table, $row, $metaArr['primaryKey']);
+ $response->getBody()->write(json_encode($result));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+// API: импорт CSV (массовая вставка)
+$app->post('/api/table/import-csv', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+ $pdo = $container->get('db');
+ $meta = new \App\MetaService($pdo);
+ $ds = new \App\DataService($pdo);
+
+ $schema = $payload['schema'];
+ $table = $payload['table'];
+ $rows = $payload['rows'] ?? [];
+ $metaArr = $meta->getTableMeta($schema, $table);
+
+ $result = $ds->insertMultipleRows($schema, $table, $rows, $metaArr['columns']);
+
+ $response->getBody()->write(json_encode($result));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+
+// API: экспорт CSV
+$app->post('/api/table/export-csv', function (Request $request, Response $response) use ($container) {
+ $payload = json_decode((string)$request->getBody(), true);
+ $pdo = $container->get('db');
+ $ds = new \App\DataService($pdo);
+
+ $schema = $payload['schema'] ?? '';
+ $table = $payload['table'] ?? '';
+ $filters = $payload['filters'] ?? [];
+ $sort = $payload['sort'] ?? null;
+ $columns = $payload['columns'] ?? [];
+
+ $result = $ds->exportCSV($schema, $table, $columns, $filters, $sort);
+
+ $response->getBody()->write(json_encode($result));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+// API: получить доступные значения для Foreign Key
+$app->get('/api/fk-values', function (Request $request, Response $response) use ($container) {
+ $params = $request->getQueryParams();
+ $schema = $params['schema'] ?? '';
+ $table = $params['table'] ?? '';
+ $column = $params['column'] ?? '';
+
+ $pdo = $container->get('db');
+
+ // Получаем до 100 первых значений
+ $sql = "SELECT DISTINCT `{$column}` FROM `{$schema}`.`{$table}`
+ WHERE `{$column}` IS NOT NULL
+ ORDER BY `{$column}`
+ LIMIT 100";
+
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute();
+ $values = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+ $response->getBody()->write(json_encode(['values' => $values]));
+ return $response->withHeader('Content-Type', 'application/json');
+});
+$app->run();
diff --git a/src/DataService.php b/src/DataService.php
new file mode 100644
index 0000000..2561ea0
--- /dev/null
+++ b/src/DataService.php
@@ -0,0 +1,386 @@
+ $c['COLUMN_NAME'], $columns);
+ $quotedColumns = array_map(fn($name) => "`$name`", $colNames);
+ $selectList = implode(', ', $quotedColumns);
+
+ $whereParts = [];
+ $params = [];
+ foreach ($filters as $i => $f) {
+ $field = $f['field'] ?? null;
+ $value = $f['value'] ?? null;
+ if (!$field || !in_array($field, $colNames, true) || $value === null || $value === '') {
+ continue;
+ }
+ $param = ":f{$i}";
+ $whereParts[] = "`$field` LIKE $param";
+ $params[$param] = '%' . $value . '%';
+ }
+ $whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
+
+ $orderSql = '';
+ if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
+ $dir = strtoupper($sort['dir'] ?? 'ASC');
+ if (!in_array($dir, ['ASC', 'DESC'], true)) {
+ $dir = 'ASC';
+ }
+ $orderSql = "ORDER BY `{$sort['field']}` $dir";
+ }
+
+ $sql = "SELECT $selectList
+ FROM `{$schema}`.`{$table}`
+ $whereSql
+ $orderSql
+ LIMIT :limit OFFSET :offset";
+
+ $stmt = $this->pdo->prepare($sql);
+ foreach ($params as $k => $v) {
+ $stmt->bindValue($k, $v);
+ }
+ $stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
+ $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+
+ $countSql = "SELECT COUNT(*) AS cnt
+ FROM `{$schema}`.`{$table}` $whereSql";
+ $countStmt = $this->pdo->prepare($countSql);
+ foreach ($params as $k => $v) {
+ $countStmt->bindValue($k, $v);
+ }
+ $countStmt->execute();
+ $total = (int)$countStmt->fetchColumn();
+
+ $lastPage = max(1, (int)ceil($total / $pageSize));
+
+ return [
+ 'data' => $rows,
+ 'last_page' => $lastPage,
+ 'total' => $total,
+ 'current_page' => $page
+ ];
+ }
+
+ public function insertRow(string $schema, string $table, array $row, array $columns): array
+ {
+ $insertCols = [];
+ $placeholders = [];
+ $params = [];
+
+ foreach ($columns as $c) {
+ $name = $c['COLUMN_NAME'];
+ $extra = $c['EXTRA'] ?? '';
+
+ if (str_contains($extra, 'auto_increment')) {
+ continue;
+ }
+
+ if (array_key_exists($name, $row)) {
+ $insertCols[] = "`$name`";
+ $placeholders[] = ":$name";
+ $params[":$name"] = $row[$name];
+ }
+ }
+
+ if (empty($insertCols)) {
+ $sql = "INSERT INTO `{$schema}`.`{$table}` () VALUES ()";
+ $stmt = $this->pdo->prepare($sql);
+
+ try {
+ $stmt->execute();
+ return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
+ } catch (\PDOException $e) {
+ throw new \RuntimeException($this->formatPDOError($e, $schema, $table));
+ }
+ }
+
+ $sql = sprintf(
+ "INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
+ $schema, $table,
+ implode(',', $insertCols),
+ implode(',', $placeholders)
+ );
+
+ $stmt = $this->pdo->prepare($sql);
+
+ try {
+ $stmt->execute($params);
+ return ['inserted' => true, 'id' => $this->pdo->lastInsertId()];
+ } catch (\PDOException $e) {
+ throw new \RuntimeException($this->formatPDOError($e, $schema, $table, $params));
+ }
+ }
+
+ // ✅ Метод для форматирования ошибок
+ private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
+ {
+ $code = $e->getCode();
+ $message = $e->getMessage();
+
+ // Foreign Key constraint
+ if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
+ if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
+ $field = $matches[1];
+ return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
+ }
+ return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
+ }
+
+ // Duplicate key
+ if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
+ if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
+ $value = $matches[1];
+ $key = $matches[2];
+ return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
+ }
+ return "Ошибка: запись с таким значением уже существует.";
+ }
+
+ // NOT NULL constraint
+ if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
+ if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
+ $field = $matches[1];
+ return "Ошибка: поле '{$field}' обязательно для заполнения.";
+ }
+ return "Ошибка: не заполнены обязательные поля.";
+ }
+
+ return "Ошибка БД: " . $message;
+ }
+
+ public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
+ {
+ if (empty($pk)) {
+ throw new \RuntimeException('No primary key — update disabled');
+ }
+
+ $sets = [];
+ $params = [];
+
+ foreach ($columns as $c) {
+ $name = $c['COLUMN_NAME'];
+ if (in_array($name, $pk, true)) {
+ continue;
+ }
+ if (array_key_exists($name, $row)) {
+ $sets[] = "`$name` = :v_$name";
+ $params[":v_$name"] = $row[$name];
+ }
+ }
+
+ if (empty($sets)) {
+ return ['updated' => 0, 'message' => 'No changes'];
+ }
+
+ $whereParts = [];
+ foreach ($pk as $name) {
+ if (!array_key_exists($name, $row)) {
+ throw new \RuntimeException("Missing PK value: $name");
+ }
+ $whereParts[] = "`$name` = :pk_$name";
+ $params[":pk_$name"] = $row[$name];
+ }
+
+ $sql = sprintf(
+ "UPDATE `%s`.`%s` SET %s WHERE %s",
+ $schema, $table,
+ implode(', ', $sets),
+ implode(' AND ', $whereParts)
+ );
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($params);
+
+ return ['updated' => $stmt->rowCount()];
+ }
+
+ public function deleteRow(string $schema, string $table, array $row, array $pk): array
+ {
+ if (empty($pk)) {
+ throw new \RuntimeException('No primary key — delete disabled');
+ }
+
+ $whereParts = [];
+ $params = [];
+
+ foreach ($pk as $name) {
+ if (!array_key_exists($name, $row)) {
+ throw new \RuntimeException("Missing PK value: $name");
+ }
+ $whereParts[] = "`$name` = :pk_$name";
+ $params[":pk_$name"] = $row[$name];
+ }
+
+ $sql = sprintf(
+ "DELETE FROM `%s`.`%s` WHERE %s",
+ $schema, $table,
+ implode(' AND ', $whereParts)
+ );
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($params);
+
+ return ['deleted' => $stmt->rowCount()];
+ }
+
+ public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
+ {
+ if (empty($rows)) {
+ return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
+ }
+
+ $inserted = 0;
+ $errors = 0;
+ $errorMessages = [];
+
+ $validColumns = [];
+ foreach ($columns as $c) {
+ $name = $c['COLUMN_NAME'];
+ $extra = $c['EXTRA'] ?? '';
+
+ if (!str_contains($extra, 'auto_increment')) {
+ $validColumns[] = $name;
+ }
+ }
+
+ $this->pdo->beginTransaction();
+
+ try {
+ foreach ($rows as $index => $row) {
+ try {
+ $insertCols = [];
+ $placeholders = [];
+ $params = [];
+
+ foreach ($validColumns as $name) {
+ if (array_key_exists($name, $row)) {
+ $insertCols[] = "`$name`";
+ $placeholders[] = ":$name";
+ $params[":$name"] = $row[$name];
+ }
+ }
+
+ if (empty($insertCols)) {
+ continue;
+ }
+
+ $sql = sprintf(
+ "INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
+ $schema, $table,
+ implode(',', $insertCols),
+ implode(',', $placeholders)
+ );
+
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($params);
+ $inserted++;
+ } catch (\PDOException $e) {
+ $errors++;
+ $errorMessages[] = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
+ }
+ }
+
+ $this->pdo->commit();
+ } catch (\Exception $e) {
+ $this->pdo->rollBack();
+ throw new \RuntimeException('Import failed: ' . $e->getMessage());
+ }
+
+ return [
+ 'inserted' => $inserted,
+ 'errors' => $errors,
+ 'errorMessages' => $errorMessages
+ ];
+ }
+
+ public function exportCSV(
+ string $schema,
+ string $table,
+ array $columns,
+ array $filters,
+ ?array $sort
+ ): array {
+ $colNames = array_map(fn($c) => $c['COLUMN_NAME'], $columns);
+ $quotedColumns = array_map(fn($name) => "`$name`", $colNames);
+ $selectList = implode(', ', $quotedColumns);
+
+ $whereParts = [];
+ $params = [];
+ foreach ($filters as $i => $f) {
+ $field = $f['field'] ?? null;
+ $value = $f['value'] ?? null;
+ if (!$field || !in_array($field, $colNames, true) || $value === null) {
+ continue;
+ }
+ $param = ":f{$i}";
+ $whereParts[] = "`$field` LIKE $param";
+ $params[$param] = '%' . $value . '%';
+ }
+ $whereSql = $whereParts ? 'WHERE ' . implode(' AND ', $whereParts) : '';
+
+ $orderSql = '';
+ if ($sort && !empty($sort['field']) && in_array($sort['field'], $colNames, true)) {
+ $dir = strtoupper($sort['dir'] ?? 'ASC');
+ if (!in_array($dir, ['ASC', 'DESC'], true)) {
+ $dir = 'ASC';
+ }
+ $orderSql = "ORDER BY `{$sort['field']}` $dir";
+ }
+
+ $sql = "SELECT $selectList
+ FROM `{$schema}`.`{$table}`
+ $whereSql
+ $orderSql";
+
+ $stmt = $this->pdo->prepare($sql);
+ foreach ($params as $k => $v) {
+ $stmt->bindValue($k, $v);
+ }
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+
+ $csv = $this->arrayToCSV($colNames, $rows);
+
+ return [
+ 'csv' => $csv,
+ 'rowCount' => count($rows)
+ ];
+ }
+
+ private function arrayToCSV(array $headers, array $rows): string
+ {
+ $output = fopen('php://temp', 'r+');
+
+ fputcsv($output, $headers, ';');
+
+ foreach ($rows as $row) {
+ $rowData = [];
+ foreach ($headers as $header) {
+ $rowData[] = $row[$header] ?? '';
+ }
+ fputcsv($output, $rowData, ';');
+ }
+
+ rewind($output);
+ $csv = stream_get_contents($output);
+ fclose($output);
+
+ return $csv;
+ }
+}
diff --git a/src/Db.php b/src/Db.php
new file mode 100644
index 0000000..6037a34
--- /dev/null
+++ b/src/Db.php
@@ -0,0 +1,43 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_EMULATE_PREPARES => false, // важная настройка [web:28][web:25]
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+
+ return $pdo;
+ }
+
+ public static function testConnection(string $user, string $pass): void
+ {
+ $dsn = 'mysql:host=localhost;port=3306;charset=utf8mb4'; // MariaDB совместим [web:22][web:28]
+ try {
+ new PDO($dsn, $user, $pass, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+ } catch (PDOException $e) {
+ throw new \RuntimeException('Connection failed: ' . $e->getMessage());
+ }
+ }
+}
diff --git a/src/MetaService.php b/src/MetaService.php
new file mode 100644
index 0000000..c312eac
--- /dev/null
+++ b/src/MetaService.php
@@ -0,0 +1,121 @@
+pdo->query($sql)->fetchAll();
+
+ $tree = [];
+ foreach ($rows as $row) {
+ $schema = $row['TABLE_SCHEMA'];
+ $table = $row['TABLE_NAME'];
+ $tree[$schema]['name'] = $schema;
+ $tree[$schema]['tables'][] = $table;
+ }
+ return array_values($tree);
+ }
+
+ public function getTableMeta(string $schema, string $table): array
+ {
+ $sql = "
+ SELECT
+ COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, COLUMN_KEY,
+ IS_NULLABLE, COLUMN_DEFAULT, EXTRA, ORDINAL_POSITION
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
+ ORDER BY ORDINAL_POSITION
+ ";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([':schema' => $schema, ':table' => $table]);
+ $cols = $stmt->fetchAll();
+
+ // ✅ Получаем информацию о Foreign Keys
+ $fkSql = "
+ SELECT
+ COLUMN_NAME,
+ REFERENCED_TABLE_SCHEMA,
+ REFERENCED_TABLE_NAME,
+ REFERENCED_COLUMN_NAME
+ FROM information_schema.KEY_COLUMN_USAGE
+ WHERE TABLE_SCHEMA = :schema
+ AND TABLE_NAME = :table
+ AND REFERENCED_TABLE_NAME IS NOT NULL
+ ";
+ $fkStmt = $this->pdo->prepare($fkSql);
+ $fkStmt->execute([':schema' => $schema, ':table' => $table]);
+ $foreignKeys = $fkStmt->fetchAll();
+
+ // Создаём map FK для быстрого доступа
+ $fkMap = [];
+ foreach ($foreignKeys as $fk) {
+ $fkMap[$fk['COLUMN_NAME']] = [
+ 'ref_schema' => $fk['REFERENCED_TABLE_SCHEMA'],
+ 'ref_table' => $fk['REFERENCED_TABLE_NAME'],
+ 'ref_column' => $fk['REFERENCED_COLUMN_NAME']
+ ];
+ }
+
+ // Primary keys
+ $pk = [];
+ foreach ($cols as $c) {
+ if ($c['COLUMN_KEY'] === 'PRI') {
+ $pk[] = $c['COLUMN_NAME'];
+ }
+ }
+
+ $enrichedCols = array_map(function($c) use ($fkMap) {
+ $name = $c['COLUMN_NAME'];
+
+ return [
+ 'COLUMN_NAME' => $name,
+ 'DATA_TYPE' => $c['DATA_TYPE'],
+ 'COLUMN_TYPE' => $c['COLUMN_TYPE'],
+ 'COLUMN_KEY' => $c['COLUMN_KEY'],
+ 'IS_NULLABLE' => $c['IS_NULLABLE'] === 'YES',
+ 'COLUMN_DEFAULT' => $c['COLUMN_DEFAULT'],
+ 'HAS_DEFAULT' => !empty($c['COLUMN_DEFAULT']),
+ 'EXTRA' => $c['EXTRA'],
+ 'IS_AUTO_INCREMENT' => str_contains($c['EXTRA'] ?? '', 'auto_increment'),
+ 'ORDINAL_POSITION' => (int)$c['ORDINAL_POSITION'],
+ 'IS_REQUIRED' => $c['IS_NULLABLE'] === 'NO' && empty($c['COLUMN_DEFAULT']),
+ 'EDITOR_TYPE' => $this->getEditorType($c),
+ // ✅ Добавляем информацию о FK
+ 'IS_FOREIGN_KEY' => isset($fkMap[$name]),
+ 'FOREIGN_KEY' => $fkMap[$name] ?? null
+ ];
+ }, $cols);
+
+ return [
+ 'columns' => $enrichedCols,
+ 'primaryKey' => $pk,
+ 'totalColumns' => count($enrichedCols),
+ 'foreignKeys' => $foreignKeys
+ ];
+ }
+
+ private function getEditorType(array $col): string
+ {
+ $type = strtolower($col['DATA_TYPE']);
+ $fullType = strtolower($col['COLUMN_TYPE']);
+
+ if (str_contains($fullType, 'int') || str_contains($type, 'int')) return 'number';
+ if (str_contains($fullType, 'float') || str_contains($fullType, 'decimal') || str_contains($fullType, 'double')) return 'number';
+ if (str_contains($type, 'date')) return 'datetime';
+ if (str_contains($type, 'time')) return 'time';
+ if (str_contains($type, 'bool') || str_contains($type, 'boolean')) return 'tickCross';
+ return 'input';
+ }
+}