Back to blocks

Госзакупки: парсер PHP

4.0
ruПрограммированиегосзакупкиPHPпарсертендерырегионы

Промпт

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

class ZakupkiParser {
    private $curl;
    private $cookies;
    private $cache = [];
    private $regionMapping = [
    // Федеральные города
    '77' => 'Москва',
    '78' => 'Санкт-Петербург',
    '92' => 'Севастополь',
    
    // Области
    '29' => 'Архангельская область',
    '30' => 'Астраханская область',
    '31' => 'Белгородская область',
    '32' => 'Брянская область',
    '33' => 'Владимирская область',
    '34' => 'Волгоградская область',
    '35' => 'Вологодская область',
    '36' => 'Воронежская область',
    '37' => 'Ивановская область',
    '38' => 'Иркутская область',
    '39' => 'Калининградская область',
    '40' => 'Калужская область',
    '42' => 'Кемеровская область',
    '43' => 'Кировская область',
    '44' => 'Костромская область',
    '45' => 'Курганская область',
    '46' => 'Курская область',
    '47' => 'Ленинградская область',
    '48' => 'Липецкая область',
    '49' => 'Магаданская область',
    '50' => 'Московская область',
    '51' => 'Мурманская область',
    '52' => 'Нижегородская область',
    '53' => 'Новгородская область',
    '54' => 'Новосибирская область',
    '55' => 'Омская область',
    '56' => 'Оренбургская область',
    '57' => 'Орловская область',
    '58' => 'Пензенская область',
    '60' => 'Псковская область',
    '61' => 'Ростовская область',
    '62' => 'Рязанская область',
    '63' => 'Самарская область',
    '64' => 'Саратовская область',
    '65' => 'Сахалинская область',
    '66' => 'Свердловская область',
    '67' => 'Смоленская область',
    '68' => 'Тамбовская область',
    '69' => 'Тверская область',
    '70' => 'Томская область',
    '71' => 'Тульская область',
    '72' => 'Тюменская область',
    '73' => 'Ульяновская область',
    '74' => 'Челябинская область',
    '75' => 'Забайкальский край',
    '76' => 'Ярославская область',
    
    // Республики
    '01' => 'Республика Адыгея',
    '02' => 'Республика Башкортостан',
    '03' => 'Республика Бурятия',
    '04' => 'Республика Алтай',
    '05' => 'Республика Дагестан',
    '06' => 'Республика Ингушетия',
    '07' => 'Кабардино-Балкарская Республика',
    '08' => 'Республика Калмыкия',
    '09' => 'Карачаево-Черкесская Республика',
    '10' => 'Республика Карелия',
    '11' => 'Республика Коми',
    '12' => 'Республика Марий Эл',
    '13' => 'Республика Мордовия',
    '14' => 'Республика Саха (Якутия)',
    '15' => 'Республика Северная Осетия — Алания',
    '16' => 'Республика Татарстан',
    '17' => 'Республика Тыва',
    '18' => 'Удмуртская Республика',
    '19' => 'Республика Хакасия',
    '20' => 'Чеченская Республика',
    '21' => 'Чувашская Республика',
    '91' => 'Республика Крым',
    
    // Края
    '22' => 'Алтайский край',
    '23' => 'Краснодарский край',
    '24' => 'Красноярский край',
    '25' => 'Приморский край',
    '26' => 'Ставропольский край',
    '27' => 'Хабаровский край',
    '41' => 'Камчатский край',
    '59' => 'Пермский край',
    
    // Автономные округа
    '79' => 'Еврейская автономная область',
    '83' => 'Ненецкий автономный округ',
    '86' => 'Ханты-Мансийский автономный округ',
    '87' => 'Чукотский автономный округ',
    '89' => 'Ямало-Ненецкий автономный округ'
];

// Добавить массив городов для улучшенного поиска
private $cityMapping = [
    // Москва и МО
    'москва' => '77',
    'московская' => '50',
    'подольск' => '50',
    'химки' => '50',
    'королев' => '50',
    'мытищи' => '50',
    'люберцы' => '50',
    'красногорск' => '50',
    'электросталь' => '50',
    'коломна' => '50',
    'одинцово' => '50',
    'серпухов' => '50',
    
    // Санкт-Петербург и ЛО
    'петербург' => '78',
    'спб' => '78',
    'ленинградская' => '47',
    'гатчина' => '47',
    'выборг' => '47',
    'тосно' => '47',
    'кингисепп' => '47',
    
    // Крупные города
    'новосибирск' => '54',
    'екатеринбург' => '66',
    'казань' => '16',
    'нижний новгород' => '52',
    'челябинск' => '74',
    'самара' => '63',
    'омск' => '55',
    'ростов-на-дону' => '61',
    'ростов' => '61',
    'уфа' => '02',
    'красноярск' => '24',
    'воронеж' => '36',
    'пермь' => '59',
    'волгоград' => '34',
    'краснодар' => '23',
    'саратов' => '64',
    'тюмень' => '72',
    'тольятти' => '63',
    'ижевск' => '18',
    'барнаул' => '22',
    'ульяновск' => '73',
    'иркутск' => '38',
    'хабаровск' => '27',
    'ярославль' => '76',
    'владивосток' => '25',
    'махачкала' => '05',
    'томск' => '70',
    'оренбург' => '56',
    'кемерово' => '42',
    'новокузнецк' => '42',
    'рязань' => '62',
    'астрахань' => '30',
    'пенза' => '58',
    'липецк' => '48',
    'тула' => '71',
    'киров' => '43',
    'чебоксары' => '21',
    'калининград' => '39',
    'брянск' => '32',
    'курск' => '46',
    'иваново' => '37',
    'магнитогорск' => '74',
    'тверь' => '69',
    'ставрополь' => '26',
    'белгород' => '31',
    'сочи' => '23',
    'нижний тагил' => '66',
    'архангельск' => '29',
    'владимир' => '33',
    'калуга' => '40',
    'смоленск' => '67',
    'волжский' => '34',
    'курган' => '45',
    'орел' => '57',
    'череповец' => '35',
    'вологда' => '35',
    'мурманск' => '51',
    'тамбов' => '68',
    'стерлитамак' => '02',
    'нижневартовск' => '86',
    'кострома' => '44',
    'новороссийск' => '23',
    'йошкар-ола' => '12',
    'таганрог' => '61',
    'комсомольск-на-амуре' => '27',
    'сыктывкар' => '11',
    'нижнекамск' => '16',
    'дзержинск' => '52',
    'орск' => '56',
    'ангарск' => '38',
    'балаково' => '64',
    'благовещенск' => '28',
    'прокопьевск' => '42',
    'псков' => '60',
    'бийск' => '22',
    'энгельс' => '64',
    'рыбинск' => '76',
    'балашиха' => '50',
    'северодвинск' => '29',
    'армавир' => '23',
    'подольск' => '50',
    'королев' => '50',
    'петрозаводск' => '10',
    'железнодорожный' => '50',
    'видное' => '50',
    'ковров' => '33',
    'электросталь' => '50',
    'миасс' => '74',
    'первоуральск' => '66',
    'копейск' => '74',
    'коломна' => '50',
    'химки' => '50',
    'серпухов' => '50',
    'новочеркасск' => '61',
    'батайск' => '61',
    'красногорск' => '50',
    'мытищи' => '50',
    'камышин' => '34',
    'новошахтинск' => '61',
    'октябрьский' => '02',
    'ачинск' => '24',
    'первомайский' => '27',
    'елец' => '48',
    'междуреченск' => '42',
    'нефтекамск' => '02',
    'димитровград' => '73',
    'кисловодск' => '26',
    'ессентуки' => '26',
    'пятигорск' => '26',
    'невинномысск' => '26',
    'черкесск' => '09',
    'нальчик' => '07',
    'владикавказ' => '15',
    'грозный' => '20',
    'элиста' => '08',
    'майкоп' => '01',
    'горно-алтайск' => '04',
    'абакан' => '19',
    'кызыл' => '17',
    'улан-удэ' => '03',
    'чита' => '75',
    'якутск' => '14',
    'магадан' => '49',
    'южно-сахалинск' => '65',
    'петропавловск-камчатский' => '41',
    'анадырь' => '87',
    'салехард' => '89',
    'ханты-мансийск' => '86',
    'нарьян-мар' => '83',
    'биробиджан' => '79',
    'симферополь' => '91',
    'севастополь' => '92'
];
    
    public function __construct() {
        $this->cookies = tempnam(sys_get_temp_dir(), 'zakupki_');
        $this->curl = curl_init();
        curl_setopt_array($this->curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
            CURLOPT_TIMEOUT => 30,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_ENCODING => 'gzip, deflate',
            CURLOPT_COOKIEJAR => $this->cookies,
            CURLOPT_COOKIEFILE => $this->cookies
        ]);
    }
    
    private function fetch($url, $post = false, $data = null) {
        curl_setopt($this->curl, CURLOPT_URL, $url);
        curl_setopt($this->curl, CURLOPT_POST, $post);
        
        if ($post && $data) {
            curl_setopt($this->curl, CURLOPT_POSTFIELDS, $data);
        }
        
        $response = curl_exec($this->curl);
        
        if (curl_errno($this->curl)) {
            throw new Exception('CURL error: ' . curl_error($this->curl));
        }
        
        $httpCode = curl_getinfo($this->curl, CURLINFO_HTTP_CODE);
        if ($httpCode !== 200) {
            throw new Exception("HTTP error {$httpCode} for URL: {$url}");
        }
        
        return $response;
    }
    
    // Извлечение ID тендера из различных источников
    private function extractTenderId($block, $xpath) {
        $tenderId = '';
        
        // Попытка 1: из ссылки на номер тендера
        $linkNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a/@href", $block);
        if ($linkNode->length) {
            $href = $linkNode->item(0)->nodeValue;
            if (preg_match('/regNumber=([^&]+)/', $href, $matches)) {
                $tenderId = $matches[1];
            }
        }
        
        // Попытка 2: из атрибута onclick кнопки "Подробнее"
        if (empty($tenderId)) {
            $buttonNode = $xpath->query(".//button[contains(@onclick, 'showTenderDetails')]", $block);
            if ($buttonNode->length) {
                $onclick = $buttonNode->item(0)->getAttribute('onclick');
                if (preg_match("/showTenderDetails$$'([^']+)'$$/", $onclick, $matches)) {
                    $tenderId = $matches[1];
                }
            }
        }
        
        // Попытка 3: из data-атрибутов
        if (empty($tenderId)) {
            $dataNode = $xpath->query(".//*[@data-tender-id]", $block);
            if ($dataNode->length) {
                $tenderId = $dataNode->item(0)->getAttribute('data-tender-id');
            }
        }
        
        return $tenderId;
    }
    
    // Определение региона из адреса или других данных
private function extractRegion($block, $xpath) {
    $region = '';
    
    // Поиск региона в адресе заказчика
    $addressNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-href')]/following-sibling::div", $block);
    if ($addressNode->length) {
        $address = mb_strtolower(trim($addressNode->item(0)->nodeValue), 'UTF-8');
        
        // Сначала ищем по точным названиям регионов
        foreach ($this->regionMapping as $code => $name) {
            if (stripos($address, mb_strtolower($name, 'UTF-8')) !== false) {
                $region = $code;
                break;
            }
        }
        
        // Если не нашли, ищем по городам
        if (empty($region)) {
            foreach ($this->cityMapping as $city => $code) {
                if (stripos($address, $city) !== false) {
                    $region = $code;
                    break;
                }
            }
        }
    }
    
    // Поиск в других местах
    if (empty($region)) {
        $regionNode = $xpath->query(".//span[contains(@class, 'region-info')]", $block);
        if ($regionNode->length) {
            $regionText = mb_strtolower(trim($regionNode->item(0)->nodeValue), 'UTF-8');
            foreach ($this->regionMapping as $code => $name) {
                if (stripos($regionText, mb_strtolower($name, 'UTF-8')) !== false) {
                    $region = $code;
                    break;
                }
            }
        }
    }
    
    return $region;
}
    
    // Основной метод поиска с поддержкой регионов
    public function searchTenders($params = []) {
        $cacheKey = md5(serialize($params));
        if (isset($this->cache[$cacheKey])) {
            return $this->cache[$cacheKey];
        }
        
        // Параметры поиска
        $searchParams = [
            'morphology' => 'on',
            'search-filter' => 'Дате+размещения',
            'pageNumber' => $params['page'] ?? 1,
            'sortDirection' => 'false',
            'recordsPerPage' => '_' . ($params['size'] ?? 50),
            'showLotsInfoHidden' => 'false',
            'sortBy' => 'UPDATE_DATE',
            'fz44' => 'on',
            'fz223' => 'on',
            'af' => 'on',
            'ca' => 'on',
            'pc' => 'on',
            'pa' => 'on',
            'currencyIdGeneral' => '-1'
        ];
        
        // Добавляем параметры поиска
        if (!empty($params['search_text'])) {
            $searchParams['searchString'] = $params['search_text'];
        }
        if (!empty($params['inn'])) {
            $searchParams['customerId'] = $params['inn'];
        }
        if (!empty($params['price_min'])) {
            $searchParams['priceFrom'] = $params['price_min'];
        }
        if (!empty($params['price_max'])) {
            $searchParams['priceTo'] = $params['price_max'];
        }
        if (!empty($params['date_from'])) {
            $searchParams['publishDateFrom'] = $params['date_from'];
        }
        if (!empty($params['date_to'])) {
            $searchParams['publishDateTo'] = $params['date_to'];
        }
        
        // Добавляем фильтр по регионам
        if (!empty($params['regions']) && is_array($params['regions'])) {
            foreach ($params['regions'] as $region) {
                $searchParams["selectedSubjectsIdNameHidden"] = $region;
            }
        }

        // Добавляем поиск по городу/региону
        if (!empty($params['city_search'])) {
            $searchParams['searchString'] = $params['city_search'];
        }
        
        $url = 'https://zakupki.gov.ru/epz/order/extendedsearch/results.html?' . http_build_query($searchParams);
        $html = $this->fetch($url);
        $result = $this->parseTenderList($html, $params);
        
        // Кэшируем результат на 5 минут
        $this->cache[$cacheKey] = $result;
        
        return $result;
    }
    
    // Улучшенный парсинг списка тендеров
    private function parseTenderList($html, $params = []) {
        $dom = new DOMDocument();
        @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
        $xpath = new DOMXPath($dom);
        
        $tenders = [];
        $blocks = $xpath->query("//div[contains(@class, 'registry-entry__form')]");
        
        $selectedRegions = $params['regions'] ?? [];
        
        foreach ($blocks as $block) {
            $tender = [];
            
            // Улучшенное извлечение ID тендера
            $tender['tenderId'] = $this->extractTenderId($block, $xpath);
            $tender['region'] = $this->extractRegion($block, $xpath);
            
            // Фильтрация по регионам на стороне клиента (дополнительная проверка)
            if (!empty($selectedRegions) && !empty($tender['region']) && !in_array($tender['region'], $selectedRegions)) {
                continue;
            }
            
            // Номер тендера
            $numberNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a", $block);
            $tender['purchaseNumber'] = $numberNode->length ? trim($numberNode->item(0)->nodeValue) : '';
            
            // Название тендера
            $titleNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-value')]", $block);
            $tender['purchaseObjectInfo'] = $titleNode->length ? trim($titleNode->item(0)->nodeValue) : '';
            
            // Заказчик
            $customerNode = $xpath->query(".//div[contains(@class, 'registry-entry__body-href')]/a", $block);
            $tender['customer'] = ['fullName' => $customerNode->length ? trim($customerNode->item(0)->nodeValue) : ''];
            
            // Цена
            $priceNode = $xpath->query(".//div[contains(@class, 'price-block__value')]", $block);
            $price = $priceNode->length ? trim($priceNode->item(0)->nodeValue) : '';
            $tender['lot'] = ['price' => preg_replace('/[^\d,.]/', '', $price)];
            
            // Даты
            $dateNodes = $xpath->query(".//div[contains(@class, 'data-block__value')]", $block);
            $tender['publishDate'] = $dateNodes->length > 0 ? trim($dateNodes->item(0)->nodeValue) : '';
            $tender['updateDate'] = $dateNodes->length > 1 ? trim($dateNodes->item(1)->nodeValue) : '';
            $tender['submissionCloseDateTime'] = $dateNodes->length > 2 ? trim($dateNodes->item(2)->nodeValue) : '';
            
            // Регистрационный номер (из ссылки)
            $linkNode = $xpath->query(".//div[contains(@class, 'registry-entry__header-mid__number')]/a/@href", $block);
            if ($linkNode->length) {
                $href = $linkNode->item(0)->nodeValue;
                if (preg_match('/regNumber=([^&]+)/', $href, $matches)) {
                    $tender['regNumber'] = $matches[1];
                }
            }
            
            // Используем tenderId как основной идентификатор
            if (empty($tender['regNumber']) && !empty($tender['tenderId'])) {
                $tender['regNumber'] = $tender['tenderId'];
            }
            
            $tenders[] = $tender;
        }
        
        // Пагинация
        $totalItems = 0;
        $paginationNode = $xpath->query("//div[contains(@class, 'paginator-block')]//span");
        if ($paginationNode->length) {
            $text = $paginationNode->item(0)->textContent;
            if (preg_match('/из (\d+)/', $text, $matches)) {
                $totalItems = (int)$matches[1];
            }
        }
        
        return [
            'content' => $tenders,
            'totalElements' => $totalItems,
            'regions' => $this->regionMapping
        ];
    }
    
    // Получение деталей с кэшированием
    public function getTenderDetails($regNumber) {
        $cacheKey = "details_" . $regNumber;
        if (isset($this->cache[$cacheKey])) {
            return $this->cache[$cacheKey];
        }
        
        $url = "https://zakupki.gov.ru/epz/order/notice/ea20/view/common-info.html?regNumber={$regNumber}";
        $html = $this->fetch($url);
        $result = $this->parseTenderDetails($html, $regNumber);
        
        // Кэшируем детали на 10 минут
        $this->cache[$cacheKey] = $result;
        
        return $result;
    }
    
    // Полный парсинг всех деталей тендера
    private function parseTenderDetails($html, $regNumber) {
        $dom = new DOMDocument();
        @$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
        $xpath = new DOMXPath($dom);
        
        $details = [
            'regNumber' => $regNumber,
            'generalInfo' => [],
            'contactInfo' => [],
            'procedureInfo' => [],
            'priceInfo' => [],
            'objectInfo' => [],
            'requirements' => [],
            'customerRequirements' => [],
            'financialInfo' => [],
            'applicationSecurity' => [],
            'contractConditions' => [],
            'contractSecurity' => [],
            'bankingSupport' => [],
            'documents' => [],
            'lots' => [],
            'region' => '',
            'regionName' => ''
        ];
        
        // 1. ОСНОВНАЯ ИНФОРМАЦИЯ ТЕНДЕРА
        $this->parseMainInfo($xpath, $details);
        
        // 2. ОБЩАЯ ИНФОРМАЦИЯ О ЗАКУПКЕ
        $this->parseGeneralInfo($xpath, $details);
        
        // 3. КОНТАКТНАЯ ИНФОРМАЦИЯ
        $this->parseContactInfo($xpath, $details);
        
        // 4. ИНФОРМАЦИЯ О ПРОЦЕДУРЕ ЗАКУПКИ
        $this->parseProcedureInfo($xpath, $details);
        
        // 5. НАЧАЛЬНАЯ ЦЕНА КОНТРАКТА
        $this->parsePriceInfo($xpath, $details);
        
        // 6. ИНФОРМАЦИЯ ОБ ОБЪЕКТЕ ЗАКУПКИ (КТРУ)
        $this->parseObjectInfo($xpath, $details);
        
        // 7. ПРЕИМУЩЕСТВА И ТРЕБОВАНИЯ
        $this->parseRequirements($xpath, $details);
        
        // 8. ТРЕБОВАНИЯ ЗАКАЗЧИКА
        $this->parseCustomerRequirements($xpath, $details);
        
        // 9. ФИНАНСОВАЯ ИНФОРМАЦИЯ
        $this->parseFinancialInfo($xpath, $details);
        
        // 10. ОБЕСПЕЧЕНИЕ ЗАЯВКИ
        $this->parseApplicationSecurity($xpath, $details);
        
        // 11. УСЛОВИЯ КОНТРАКТА
        $this->parseContractConditions($xpath, $details);
        
        // 12. ОБЕСПЕЧЕНИЕ ИСПОЛНЕНИЯ КОНТРАКТА
        $this->parseContractSecurity($xpath, $details);
        
        // 13. БАНКОВСКОЕ СОПРОВОЖДЕНИЕ
        $this->parseBankingSupport($xpath, $details);
        
        // 14. ДОКУМЕНТЫ
        $this->parseDocuments($xpath, $details);
        
        return $details;
    }
    
    // Парсинг основной информации тендера
    private function parseMainInfo($xpath, &$details) {
        // Извлекаем регион из блока time-zone
        $this->extractRegionFromTimeZone($xpath, $details);
    
        // Заголовок и номер
        $titleNode = $xpath->query("//div[contains(@class, 'cardMainInfo__title')]");
        $details['generalInfo']['title'] = $titleNode->length ? trim($titleNode->item(0)->textContent) : '';
        
        $numberNode = $xpath->query("//span[contains(@class, 'cardMainInfo__purchaseLink')]/a");
        $details['generalInfo']['number'] = $numberNode->length ? trim($numberNode->item(0)->textContent) : '';
        
        // Статус
        $statusNode = $xpath->query("//span[contains(@class, 'cardMainInfo__state')]");
        $details['generalInfo']['status'] = $statusNode->length ? trim($statusNode->item(0)->textContent) : '';
        
        // Объект закупки
        $objectNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]/span[contains(text(), 'Объект закупки')]/following-sibling::span");
        if (!$objectNode->length) {
            // Альтернативный поиск в заголовке
            $objectNode = $xpath->query("//h1[contains(@class, 'cardMainInfo__title')] | //div[contains(@class, 'cardMainInfo__title')]");
        }
        $details['generalInfo']['object'] = $objectNode->length ? trim($objectNode->item(0)->textContent) : 'Не указан';
        
        // Организация
        $orgNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]/span[contains(text(), 'Организация') or contains(text(), 'размещение')]/following-sibling::span/a");
        if (!$orgNode->length) {
            // Альтернативный поиск организации
            $orgNode = $xpath->query("//a[contains(@href, 'organization/view')]");
        }
        $details['generalInfo']['organization'] = $orgNode->length ? trim($orgNode->item(0)->textContent) : 'Не указана';
        
        // Цена
        $priceNode = $xpath->query("//span[contains(@class, 'cost')]");
        $details['generalInfo']['price'] = $priceNode->length ? trim($priceNode->item(0)->textContent) : '';
        
        // Даты из основной карточки
        $publishDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Размещено')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
        if ($publishDateNode->length) {
            $details['generalInfo']['publishDate'] = trim($publishDateNode->item(0)->textContent);
        }

        $updateDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Обновлено')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
        if ($updateDateNode->length) {
            $details['generalInfo']['updateDate'] = trim($updateDateNode->item(0)->textContent);
        }

        $endDateNode = $xpath->query("//div[contains(@class, 'cardMainInfo__section')]//span[contains(@class, 'cardMainInfo__title') and contains(text(), 'Окончание')]/following-sibling::span[contains(@class, 'cardMainInfo__content')]");
        if ($endDateNode->length) {
            $details['generalInfo']['endDate'] = trim($endDateNode->item(0)->textContent);
        }
    
        // Если даты не найдены в основной карточке, ищем в других местах
        if (empty($details['generalInfo']['publishDate'])) {
            $altPublishNode = $xpath->query("//span[contains(text(), 'Размещено')]/following-sibling::span | //td[contains(text(), 'Размещено')]/following-sibling::td");
            if ($altPublishNode->length) {
                $details['generalInfo']['publishDate'] = trim($altPublishNode->item(0)->textContent);
            }
        }

        if (empty($details['generalInfo']['updateDate'])) {
            $altUpdateNode = $xpath->query("//span[contains(text(), 'Обновлено')]/following-sibling::span | //td[contains(text(), 'Обновлено')]/following-sibling::td");
            if ($altUpdateNode->length) {
                $details['generalInfo']['updateDate'] = trim($altUpdateNode->item(0)->textContent);
            }
        }

        if (empty($details['generalInfo']['endDate'])) {
            $altEndNode = $xpath->query("//span[contains(text(), 'Окончание')]/following-sibling::span | //td[contains(text(), 'Окончание')]/following-sibling::td");
            if ($altEndNode->length) {
                $details['generalInfo']['endDate'] = trim($altEndNode->item(0)->textContent);
            }
        }
    
        // Добавляем информацию о часовом поясе
        $timeZoneNode = $xpath->query("//div[contains(@class, 'time-zone__value')]/span");
        if ($timeZoneNode->length) {
            $details['generalInfo']['timeZone'] = trim($timeZoneNode->item(0)->textContent);
        }
    }
    
    // Парсинг общей информации о закупке
    private function parseGeneralInfo($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Общая информация о закупке')]/following-sibling::section | //h2[contains(text(), 'Общая информация о закупке')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['generalInfo'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг контактной информации
    private function parseContactInfo($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Контактная информация')]/following-sibling::section | //h2[contains(text(), 'Контактная информация')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['contactInfo'][$this->normalizeKey($title)] = $info;
            
                // Определение региона из адреса, только если он еще не определен
                if (empty($details['region']) && (stripos($title, 'адрес') !== false || stripos($title, 'нахождение') !== false)) {
                    foreach ($this->regionMapping as $code => $name) {
                        if (stripos($info, $name) !== false) {
                            $details['region'] = $code;
                            $details['regionName'] = $name;
                            break;
                        }
                    }
                }
            }
        }
    }
    
    // Парсинг информации о процедуре закупки
    private function parseProcedureInfo($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Информация о процедуре закупки')]/following-sibling::section | //h2[contains(text(), 'Информация о процедуре закупки')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['procedureInfo'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг информации о цене
    private function parsePriceInfo($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Начальная') and contains(text(), 'цена')]/following-sibling::section | //h2[contains(text(), 'Начальная') and contains(text(), 'цена')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['priceInfo'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг информации об объекте закупки (КТРУ)
    private function parseObjectInfo($xpath, &$details) {
        $table = $xpath->query("//table[contains(@class, 'tableBlock')]//tbody[contains(@class, 'tableBlock__body')]");
        
        if ($table->length) {
            $rows = $xpath->query(".//tr[contains(@class, 'tableBlock__row')]", $table->item(0));
            
            foreach ($rows as $row) {
                $cols = $xpath->query(".//td[contains(@class, 'tableBlock__col')]", $row);
                
                if ($cols->length >= 6) {
                    $item = [
                        'code' => trim($cols->item(1)->textContent),
                        'name' => trim($cols->item(2)->textContent),
                        'unit' => $cols->length > 3 ? trim($cols->item(3)->textContent) : '',
                        'quantity' => $cols->length > 4 ? trim($cols->item(4)->textContent) : '',
                        'price' => $cols->length > 5 ? trim($cols->item(5)->textContent) : '',
                        'sum' => $cols->length > 6 ? trim($cols->item(6)->textContent) : ''
                    ];
                    
                    // Извлечение характеристик
                    $characteristics = [];
                    $charRows = $xpath->query(".//tr[contains(@style, 'display: none')]//table//tr", $row->parentNode);
                    foreach ($charRows as $charRow) {
                        $charCols = $xpath->query(".//td", $charRow);
                        if ($charCols->length >= 2) {
                            $charName = trim($charCols->item(0)->textContent);
                            $charValue = trim($charCols->item(1)->textContent);
                            if (!empty($charName) && !empty($charValue)) {
                                $characteristics[$charName] = $charValue;
                            }
                        }
                    }
                    $item['characteristics'] = $characteristics;
                    
                    $details['objectInfo'][] = $item;
                }
            }
        }
        
        // Итоговая сумма
        $totalNode = $xpath->query("//tfoot//span[contains(@class, 'cost')]");
        if ($totalNode->length) {
            $details['objectInfo']['total'] = trim($totalNode->item(0)->textContent);
        }
    }
    
    // Парсинг требований и преимуществ
    private function parseRequirements($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Преимущества') or contains(text(), 'требования')]/following-sibling::section | //h2[contains(text(), 'Преимущества') or contains(text(), 'требования')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['requirements'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг требований заказчика
    private function parseCustomerRequirements($xpath, &$details) {
        $sections = $xpath->query("//span[contains(text(), 'Требования заказчика')]/ancestor::div[contains(@class, 'collapse__title')]/following-sibling::div//section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['customerRequirements'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг финансовой информации
    private function parseFinancialInfo($xpath, &$details) {
        // Финансовое обеспечение
        $tables = $xpath->query("//h2[contains(text(), 'финансирования') or contains(text(), 'Финансовое обеспечение')]/following-sibling::table | //h2[contains(text(), 'финансирования') or contains(text(), 'Финансовое обеспечение')]/parent::*/following-sibling::*/table");
        
        foreach ($tables as $table) {
            $rows = $xpath->query(".//tr", $table);
            $tableData = [];
            
            foreach ($rows as $row) {
                $cols = $xpath->query(".//td | .//th", $row);
                $rowData = [];
                foreach ($cols as $col) {
                    $rowData[] = trim($col->textContent);
                }
                if (!empty($rowData)) {
                    $tableData[] = $rowData;
                }
            }
            
            $details['financialInfo']['tables'][] = $tableData;
        }
        
        // Дополнительные секции
        $sections = $xpath->query("//h2[contains(text(), 'финансирования')]/following-sibling::section | //h2[contains(text(), 'финансирования')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['financialInfo'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг обеспечения заявки
    private function parseApplicationSecurity($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Обеспечение заявки')]/following-sibling::section | //h2[contains(text(), 'Обеспечение заявки')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['applicationSecurity'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг условий контракта
    private function parseContractConditions($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Условия контракта')]/following-sibling::section | //h2[contains(text(), 'Условия контракта')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['contractConditions'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг обеспечения исполнения контракта
    private function parseContractSecurity($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'Обеспечение исполнения контракта')]/following-sibling::section | //h2[contains(text(), 'Обеспечение исполнения контракта')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['contractSecurity'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг банковского сопровождения
    private function parseBankingSupport($xpath, &$details) {
        $sections = $xpath->query("//h2[contains(text(), 'банковском') or contains(text(), 'казначейском')]/following-sibling::section | //h2[contains(text(), 'банковском') or contains(text(), 'казначейском')]/parent::*/following-sibling::*/section");
        
        foreach ($sections as $section) {
            $titleNode = $xpath->query(".//span[contains(@class, 'section__title')]", $section);
            $infoNode = $xpath->query(".//span[contains(@class, 'section__info')]", $section);
            
            if ($titleNode->length && $infoNode->length) {
                $title = trim($titleNode->item(0)->textContent);
                $info = trim($infoNode->item(0)->textContent);
                $details['bankingSupport'][$this->normalizeKey($title)] = $info;
            }
        }
    }
    
    // Парсинг документов
    private function parseDocuments($xpath, &$details) {
        $docNodes = $xpath->query("//a[contains(@class, 'section__doc-link')] | //a[contains(@href, '/document/')]");
        
        foreach ($docNodes as $node) {
            $docHref = $node->getAttribute('href');
            if (!preg_match('/^https?:\/\//', $docHref)) {
                $docHref = 'https://zakupki.gov.ru' . $docHref;
            }
            
            $details['documents'][] = [
                'fileName' => trim($node->textContent),
                'url' => $docHref
            ];
        }
    }
    
    // Извлечение региона из блока time-zone
private function extractRegionFromTimeZone($xpath, &$details) {
    $timeZoneNode = $xpath->query("//div[contains(@class, 'time-zone__value')]/span");
    
    if ($timeZoneNode->length) {
        $timeZoneText = trim($timeZoneNode->item(0)->textContent);
    
        // Извлекаем название региона из строки типа "Москва МСК (UTC+3)"
        if (preg_match('/^([^М\s]+)/', $timeZoneText, $matches)) {
            $regionName = mb_strtolower(trim($matches[1]), 'UTF-8');
            
            // Ищем соответствие в нашем маппинге регионов
            foreach ($this->regionMapping as $code => $name) {
                if (stripos($regionName, mb_strtolower($name, 'UTF-8')) !== false || 
                    stripos(mb_strtolower($name, 'UTF-8'), $regionName) !== false) {
                    $details['region'] = $code;
                    $details['regionName'] = $name;
                    return true;
                }
            }
            
            // Ищем в городах
            foreach ($this->cityMapping as $city => $code) {
                if (stripos($regionName, $city) !== false) {
                    $details['region'] = $code;
                    $details['regionName'] = $this->regionMapping[$code];
                    return true;
                }
            }
            
            // Если не нашли в маппинге, но регион есть, сохраняем его название
            if (!empty($regionName)) {
                $details['regionName'] = ucfirst($regionName);
                return true;
            }
        }
    }
    
    return false;
}
    
    // Нормализация ключей для массивов
    private function normalizeKey($key) {
        $key = trim($key);
        $key = preg_replace('/[^\w\s-]/u', '', $key);
        $key = preg_replace('/\s+/', '_', $key);
        $key = mb_strtolower($key, 'UTF-8');
        return $key;
    }
    
    // Получение списка доступных регионов
    public function getRegions() {
        return $this->regionMapping;
    }
    
    public function __destruct() {
        if ($this->curl) {
            curl_close($this->curl);
        }
        if (file_exists($this->cookies)) {
            @unlink($this->cookies);
        }
    }
}

// Обработка запросов
if (isset($_GET['action'])) {
    header('Content-Type: application/json; charset=utf-8');
    
    try {
        $parser = new ZakupkiParser();
        
        if ($_GET['action'] === 'search') {
            $params = [
                'page' => $_GET['page'] ?? 1,
                'size' => $_GET['per_page'] ?? 20,
                'search_text' => $_GET['search_text'] ?? '',
                'inn' => $_GET['inn'] ?? '',
                'price_min' => $_GET['price_min'] ?? null,
                'price_max' => $_GET['price_max'] ?? null,
                'date_from' => $_GET['date_from'] ?? null,
                'date_to' => $_GET['date_to'] ?? null,
                'regions' => isset($_GET['regions']) ? explode(',', $_GET['regions']) : [],
                'city_search' => $_GET['city_search'] ?? ''
            ];
            
            $result = $parser->searchTenders($params);
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
            
        } elseif ($_GET['action'] === 'detail' && !empty($_GET['regNumber'])) {
            $details = $parser->getTenderDetails($_GET['regNumber']);
            echo json_encode($details, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
            
        } elseif ($_GET['action'] === 'regions') {
            $regions = $parser->getRegions();
            echo json_encode($regions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
        }
    } catch (Exception $e) {
        http_response_code(500);
        echo json_encode(['error' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
    }
    exit;
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Полный парсер госзакупок</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
    <style>
        .card-detail { 
            transition: all 0.3s; 
            border-left: 4px solid transparent;
        }
        .card-detail:hover { 
            transform: translateY(-5px); 
            box-shadow: 0 10px 20px rgba(0,0,0,0.1);
            border-left-color: #0d6efd;
        }
        .section-title { 
            border-left: 4px solid #0d6efd; 
            padding-left: 15px; 
            margin: 25px 0 15px; 
        }
        .loader { width: 3rem; height: 3rem; }
        .document-badge { 
            cursor: pointer; 
            transition: all 0.2s; 
            margin: 2px;
        }
        .document-badge:hover { 
            background: #0d6efd!important; 
            transform: scale(1.05);
        }
        .pagination .page-item.active .page-link { 
            background: #0d6efd; 
            border-color: #0d6efd; 
        }
        .lot-badge { font-size: 0.85rem; }
        .region-badge {
            font-size: 0.75rem;
            margin-left: 5px;
        }
        .select2-container {
            width: 100% !important;
        }
        .tender-stats {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border-radius: 10px;
            padding: 20px;
            margin-bottom: 20px;
        }
        .cache-info {
            position: fixed;
            top: 10px;
            right: 10px;
            z-index: 1000;
        }
        .detail-section {
            margin-bottom: 30px;
            border: 1px solid #e9ecef;
            border-radius: 8px;
            overflow: hidden;
        }
        .detail-section-header {
            background: #f8f9fa;
            padding: 15px 20px;
            border-bottom: 1px solid #e9ecef;
            font-weight: 600;
            color: #495057;
        }
        .detail-section-content {
            padding: 20px;
        }
        .info-table {
            width: 100%;
            margin-bottom: 0;
        }
        .info-table td {
            padding: 8px 12px;
            border-bottom: 1px solid #f1f3f4;
        }
        .info-table td:first-child {
            font-weight: 500;
            color: #6c757d;
            width: 30%;
        }
        .object-item {
            border: 1px solid #e9ecef;
            border-radius: 6px;
            margin-bottom: 15px;
            overflow: hidden;
        }
        .object-item-header {
            background: #f8f9fa;
            padding: 10px 15px;
            font-weight: 500;
        }
        .object-item-content {
            padding: 15px;
        }
        .characteristics-table {
            font-size: 0.9em;
            margin-top: 10px;
        }
        .financial-table {
            font-size: 0.9em;
            margin-bottom: 20px;
        }
    </style>
</head>
<body>
    <div class="container py-4">
        <h1 class="text-center mb-4">
            <i class="fas fa-file-contract me-2"></i>Полный парсер госзакупок
        </h1>
        
        <!-- Информация о кэше -->
        <div id="cacheInfo" class="cache-info d-none">
            <div class="alert alert-info alert-dismissible fade show" role="alert">
                <i class="fas fa-info-circle me-2"></i>
                <span id="cacheMessage">Данные загружены из кэша</span>
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        </div>
        
        <!-- Статистика -->
        <div id="tenderStats" class="tender-stats d-none">
            <div class="row text-center">
                <div class="col-md-3">
                    <h3 id="totalTenders">0</h3>
                    <p class="mb-0">Всего тендеров</p>
                </div>
                <div class="col-md-3">
                    <h3 id="totalValue">0 ₽</h3>
                    <p class="mb-0">Общая стоимость</p>
                </div>
                <div class="col-md-3">
                    <h3 id="avgValue">0 ₽</h3>
                    <p class="mb-0">Средняя стоимость</p>
                </div>
                <div class="col-md-3">
                    <h3 id="regionsCount">0</h3>
                    <p class="mb-0">Регионов</p>
                </div>
            </div>
        </div>
        
        <!-- Форма поиска -->
        <div class="card mb-4">
            <div class="card-body">
                <form id="searchForm" class="row g-3">
                    <div class="col-md-6">
                        <label class="form-label">Ключевые слова</label>
                        <input type="text" name="search_text" class="form-control" placeholder="Номер или название закупки...">
                    </div>
                    <div class="col-md-6">
                        <label class="form-label">ИНН заказчика</label>
                        <input type="text" name="inn" class="form-control" placeholder="ИНН организации...">
                    </div>
                    
                    <div class="col-md-6">
                        <label class="form-label">Регионы</label>
                        <select name="regions" id="regionSelect" class="form-select" multiple>
                            <!-- Опции будут загружены динамически -->
                        </select>
                        <div class="form-text">Выберите один или несколько регионов для фильтрации</div>
                    </div>

                    <div class="col-md-6">
                        <label class="form-label">Поиск по городу/региону</label>
                        <input type="text" name="city_search" class="form-control" placeholder="Введите название города или региона...">
                        <div class="form-text">Например: Москва, Санкт-Петербург, Екатеринбург</div>
                    </div>
                    
                    <div class="col-md-3">
                        <label class="form-label">Минимальная цена</label>
                        <div class="input-group">
                            <input type="number" name="price_min" class="form-control" placeholder="₽">
                            <span class="input-group-text">₽</span>
                        </div>
                    </div>
                    <div class="col-md-3">
                        <label class="form-label">Максимальная цена</label>
                        <div class="input-group">
                            <input type="number" name="price_max" class="form-control" placeholder="₽">
                            <span class="input-group-text">₽</span>
                        </div>
                    </div>
                    
                    <div class="col-md-3">
                        <label class="form-label">Дата от</label>
                        <input type="date" name="date_from" class="form-control">
                    </div>
                    <div class="col-md-3">
                        <label class="form-label">Дата до</label>
                        <input type="date" name="date_to" class="form-control">
                    </div>
                    
                    <div class="col-md-2">
                        <label class="form-label">На странице</label>
                        <select name="per_page" class="form-select">
                            <option value="10">10</option>
                            <option value="20" selected>20</option>
                            <option value="50">50</option>
                            <option value="100">100</option>
                        </select>
                    </div>
                    
                    <div class="col-md-8 d-flex align-items-end">
                        <button type="submit" class="btn btn-primary w-100">
                            <i class="fas fa-search me-2"></i>Найти тендеры
                        </button>
                    </div>
                    
                    <div class="col-md-2 d-flex align-items-end">
                        <button type="button" class="btn btn-outline-secondary w-100" onclick="app.clearCache()">
                            <i class="fas fa-trash me-2"></i>Очистить кэш
                        </button>
                    </div>
                </form>
            </div>
        </div>
        
        <!-- Результаты -->
        <div id="results" class="mb-4">
            <div class="text-center py-5">
                <div class="spinner-border text-primary loader" role="status">
                    <span class="visually-hidden">Загрузка...</span>
                </div>
                <p class="mt-3">Загружаем список тендеров...</p>
            </div>
        </div>
        
        <!-- Пагинация -->
        <nav id="pagination" class="d-none">
            <ul class="pagination justify-content-center">
                <!-- Номера страниц будут вставлены здесь -->
            </ul>
        </nav>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
    <script>
    class ZakupkiApp {
        constructor() {
            this.currentPage = 1;
            this.totalPages = 1;
            this.perPage = 20;
            this.totalItems = 0;
            this.currentTender = null;
            this.cache = new Map();
            this.regions = {};
            
            this.initializeRegions();
            this.searchTenders();
        }
        
        async initializeRegions() {
            try {
                const response = await fetch('?action=regions');
                if (response.ok) {
                    this.regions = await response.json();
                    this.populateRegionSelect();
                }
            } catch (error) {
                console.error('Ошибка загрузки регионов:', error);
            }
        }
        
        populateRegionSelect() {
            const select = document.getElementById('regionSelect');
            select.innerHTML = '';
            
            Object.entries(this.regions).forEach(([code, name]) => {
                const option = document.createElement('option');
                option.value = code;
                option.textContent = name;
                select.appendChild(option);
            });
            
            // Инициализация Select2
            $('#regionSelect').select2({
                placeholder: 'Выберите регионы...',
                allowClear: true,
                width: '100%'
            });
        }
        
        async searchTenders(page = 1) {
            try {
                this.showLoader('Загружаем список тендеров...');
                this.currentPage = page;
                
                const formData = new FormData(document.getElementById('searchForm'));
                const selectedRegions = $('#regionSelect').val() || [];
                
                const params = {
                    action: 'search',
                    page: page,
                    per_page: formData.get('per_page') || 20,
                    search_text: formData.get('search_text') || '',
                    inn: formData.get('inn') || '',
                    price_min: formData.get('price_min') || null,
                    price_max: formData.get('price_max') || null,
                    date_from: formData.get('date_from') || null,
                    date_to: formData.get('date_to') || null,
                    regions: selectedRegions.join(','),
                    city_search: formData.get('city_search') || ''
                };
                
                // Проверяем кэш
                const cacheKey = JSON.stringify(params);
                if (this.cache.has(cacheKey)) {
                    const cachedResult = this.cache.get(cacheKey);
                    this.renderTenders(cachedResult);
                    this.showCacheInfo('Данные загружены из кэша');
                    return;
                }
                
                // Формируем параметры запроса
                const queryString = Object.keys(params)
                    .filter(key => params[key] !== null && params[key] !== '')
                    .map(key => `${key}=${encodeURIComponent(params[key])}`)
                    .join('&');
                
                const response = await fetch(`?${queryString}`);
                if (!response.ok) throw new Error('Ошибка сети');
                
                const result = await response.json();
                
                // Сохраняем в кэш
                this.cache.set(cacheKey, result);
                
                this.renderTenders(result);
                
            } catch (error) {
                this.showError(error.message);
            }
        }
        
        renderTenders(data) {
            const tenders = data.content || [];
            this.totalItems = data.totalElements || tenders.length;
            this.totalPages = Math.ceil(this.totalItems / this.perPage);
            
            // Обновляем статистику
            this.updateStats(tenders);
            
            let html = `<h2 class="mb-3">Найдено тендеров: ${this.totalItems.toLocaleString('ru-RU')}</h2>`;
            
            if (tenders.length === 0) {
                html += `<div class="alert alert-warning">
                    <i class="fas fa-exclamation-triangle me-2"></i>
                    По вашему запросу тендеров не найдено
                </div>`;
            } else {
                html += '<div class="row g-4">';
                
                tenders.forEach(tender => {
                    const regNumber = tender.regNumber || tender.tenderId || '';
                    const number = tender.purchaseNumber || '';
                    const title = tender.purchaseObjectInfo || 'Без названия';
                    const customer = tender.customer?.fullName || 'Заказчик не указан';
                    
                    // Цена
                    let price = 'Цена не указана';
                    if (tender.lot?.price) {
                        const numPrice = parseFloat(tender.lot.price.replace(/[^\d.]/g, ''));
                        if (!isNaN(numPrice)) {
                            price = numPrice.toLocaleString('ru-RU', {
                                minimumFractionDigits: 2,
                                maximumFractionDigits: 2
                            }) + ' ₽';
                        }
                    }
                    
                    // Регион
                    const regionBadge = tender.region && this.regions[tender.region] 
                        ? `<span class="badge bg-secondary region-badge">${this.regions[tender.region]}</span>`
                        : '';
                    
                    // Даты
                    const formatDate = (dateString) => {
                        if (!dateString) return 'Н/Д';
                        try {
                            const date = new Date(dateString);
                            return isNaN(date) ? dateString : date.toLocaleDateString('ru-RU');
                        } catch {
                            return dateString;
                        }
                    };
                    
                    const posted = formatDate(tender.publishDate);
                    const updated = formatDate(tender.updateDate);
                    const deadline = formatDate(tender.submissionCloseDateTime);
                    
                    html += `
                    <div class="col-md-6">
                        <div class="card card-detail h-100" data-tender-id="${regNumber}">
                            <div class="card-body">
                                <h5 class="card-title text-primary">${title}${regionBadge}</h5>
                                <h6 class="card-subtitle mb-2 text-muted">${number}</h6>
                                
                                <div class="mb-2">
                                    <span class="badge bg-info">${price}</span>
                                </div>
                                
                                <p class="card-text">
                                    <strong>Заказчик:</strong> ${customer}<br>
                                    <strong>Размещено:</strong> ${posted}<br>
                                    <strong>Обновлено:</strong> ${updated}<br>
                                    <strong>Окончание подачи:</strong> ${deadline}
                                </p>
                                
                                <button class="btn btn-sm btn-outline-primary" 
                                    onclick="app.showTenderDetails('${regNumber}')">
                                    <i class="fas fa-info-circle me-1"></i> Подробнее
                                </button>
                            </div>
                        </div>
                    </div>`;
                });
                
                html += '</div>';
            }
            
            document.getElementById('results').innerHTML = html;
            this.renderPagination();
        }
        
        updateStats(tenders) {
            const statsContainer = document.getElementById('tenderStats');
            
            if (tenders.length === 0) {
                statsContainer.classList.add('d-none');
                return;
            }
            
            let totalValue = 0;
            const regions = new Set();
            
            tenders.forEach(tender => {
                if (tender.lot?.price) {
                    const price = parseFloat(tender.lot.price.replace(/[^\d.]/g, ''));
                    if (!isNaN(price)) {
                        totalValue += price;
                    }
                }
        
                // Учитываем регион, если он определен
                if (tender.region && tender.region !== '') {
                    regions.add(tender.region);
                }
        
                // Также проверяем regionName для случаев, когда код региона не определен
                if (!tender.region && tender.regionName && tender.regionName !== 'Не определен') {
                    regions.add(tender.regionName);
                }
            });
            
            const avgValue = tenders.length > 0 ? totalValue / tenders.length : 0;
            
            document.getElementById('totalTenders').textContent = this.totalItems.toLocaleString('ru-RU');
            document.getElementById('totalValue').textContent = totalValue.toLocaleString('ru-RU', {
                minimumFractionDigits: 0,
                maximumFractionDigits: 0
            }) + ' ₽';
            document.getElementById('avgValue').textContent = avgValue.toLocaleString('ru-RU', {
                minimumFractionDigits: 0,
                maximumFractionDigits: 0
            }) + ' ₽';
            document.getElementById('regionsCount').textContent = regions.size;
            
            statsContainer.classList.remove('d-none');
        }
        
        showCacheInfo(message) {
            const cacheInfo = document.getElementById('cacheInfo');
            const cacheMessage = document.getElementById('cacheMessage');
            
            cacheMessage.textContent = message;
            cacheInfo.classList.remove('d-none');
            
            setTimeout(() => {
                cacheInfo.classList.add('d-none');
            }, 3000);
        }
        
        clearCache() {
            this.cache.clear();
            this.showCacheInfo('Кэш очищен');
        }
        
        renderPagination() {
            const paginationContainer = document.querySelector('#pagination ul');
            paginationContainer.innerHTML = '';
            
            if (this.totalPages <= 1) {
                document.getElementById('pagination').classList.add('d-none');
                return;
            }
            
            document.getElementById('pagination').classList.remove('d-none');
            
            // Кнопка "Назад"
            const prevBtn = document.createElement('li');
            prevBtn.className = `page-item ${this.currentPage === 1 ? 'disabled' : ''}`;
            prevBtn.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${this.currentPage - 1})">&laquo;</a>`;
            paginationContainer.appendChild(prevBtn);
            
            // Номера страниц
            const startPage = Math.max(1, this.currentPage - 2);
            const endPage = Math.min(this.totalPages, startPage + 4);
            
            for (let i = startPage; i <= endPage; i++) {
                const pageItem = document.createElement('li');
                pageItem.className = `page-item ${i === this.currentPage ? 'active' : ''}`;
                pageItem.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${i})">${i}</a>`;
                paginationContainer.appendChild(pageItem);
            }
            
            // Кнопка "Вперед"
            const nextBtn = document.createElement('li');
            nextBtn.className = `page-item ${this.currentPage === this.totalPages ? 'disabled' : ''}`;
            nextBtn.innerHTML = `<a class="page-link" href="#" onclick="app.goToPage(${this.currentPage + 1})">&raquo;</a>`;
            paginationContainer.appendChild(nextBtn);
        }
        
        goToPage(page) {
            if (page < 1) page = 1;
            if (page > this.totalPages) page = this.totalPages;
            this.searchTenders(page);
        }
        
        async showTenderDetails(identifier) {
            try {
                this.showLoader('Загружаем детали тендера...');
                this.currentTender = identifier;
                
                // Проверяем кэш
                const cacheKey = `details_${identifier}`;
                if (this.cache.has(cacheKey)) {
                    const cachedDetails = this.cache.get(cacheKey);
                    this.renderTenderDetails(cachedDetails);
                    this.showCacheInfo('Детали загружены из кэша');
                    return;
                }
                
                const response = await fetch(`?action=detail&regNumber=${encodeURIComponent(identifier)}`);
                if (!response.ok) throw new Error('Ошибка загрузки деталей');
                
                const details = await response.json();
                
                // Сохраняем в кэш
                this.cache.set(cacheKey, details);
                
                this.renderTenderDetails(details);
                
            } catch (error) {
                this.showError(error.message);
            }
        }
        
        renderTenderDetails(details) {
            if (!details) {
                this.showError('Не удалось загрузить детали тендера');
                return;
            }
            
            const regionInfo = details.region && this.regions[details.region] 
                ? `<span class="badge bg-info">${this.regions[details.region]}</span>`
                : '';
            
            let html = `
            <div class="mb-4">
                <button class="btn btn-secondary" onclick="app.backToList()">
                    <i class="fas fa-arrow-left me-2"></i>Назад к списку
                </button>
            </div>
            
            <!-- Основная информация -->
            <div class="detail-section">
                <div class="detail-section-header">
                    <h4 class="mb-0">
                        <i class="fas fa-info-circle me-2"></i>
                        ${details.generalInfo?.object || 'Детальная информация о тендере'} ${regionInfo}
                    </h4>
                </div>
                <div class="detail-section-content">
                    <table class="info-table">
                        <tr><td>Регистрационный номер</td><td>${details.regNumber || 'Не указан'}</td></tr>
                        <tr><td>Номер закупки</td><td>${details.generalInfo?.number || 'Не указан'}</td></tr>
                        <tr><td>Статус</td><td><span class="badge bg-info">${details.generalInfo?.status || 'Неизвестен'}</span></td></tr>
                        <tr><td>Организация</td><td>${details.generalInfo?.organization || 'Не указана'}</td></tr>
                        <tr><td>Начальная цена</td><td>${details.generalInfo?.price || 'Не указана'}</td></tr>
                        <tr><td>Дата размещения</td><td>${details.generalInfo?.publishDate || 'Не указана'}</td></tr>
                        <tr><td>Дата обновления</td><td>${details.generalInfo?.updateDate || 'Не указана'}</td></tr>
                        <tr><td>Окончание подачи заявок</td><td>${details.generalInfo?.endDate || 'Не указана'}</td></tr>
                        <tr><td>Регион</td><td>${details.regionName || 'Не определен'}</td></tr>
                    </table>
                </div>
            </div>`;
            
            // Общая информация о закупке
            if (details.generalInfo && Object.keys(details.generalInfo).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-clipboard-list me-2"></i>Общая информация о закупке
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.generalInfo).forEach(([key, value]) => {
                    if (value && !['title', 'number', 'status', 'object', 'organization', 'price', 'publishDate', 'updateDate', 'endDate'].includes(key)) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Контактная информация
            if (details.contactInfo && Object.keys(details.contactInfo).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-address-book me-2"></i>Контактная информация
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.contactInfo).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Информация о процедуре закупки
            if (details.procedureInfo && Object.keys(details.procedureInfo).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-calendar-alt me-2"></i>Информация о процедуре закупки
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.procedureInfo).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Информация о цене
            if (details.priceInfo && Object.keys(details.priceInfo).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-ruble-sign me-2"></i>Информация о цене контракта
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.priceInfo).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Информация об объекте закупки (КТРУ)
            if (details.objectInfo && Array.isArray(details.objectInfo) && details.objectInfo.length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-boxes me-2"></i>Информация об объекте закупки (КТРУ)
                    </div>
                    <div class="detail-section-content">`;
                
                details.objectInfo.forEach((item, index) => {
                    if (typeof item === 'object' && item.code) {
                        html += `
                        <div class="object-item">
                            <div class="object-item-header">
                                <strong>Позиция ${index + 1}:</strong> ${item.code} - ${item.name}
                            </div>
                            <div class="object-item-content">
                                <div class="row">
                                    <div class="col-md-6">
                                        <table class="info-table">
                                            <tr><td>Код КТРУ</td><td>${item.code}</td></tr>
                                            <tr><td>Наименование</td><td>${item.name}</td></tr>
                                            <tr><td>Единица измерения</td><td>${item.unit}</td></tr>
                                        </table>
                                    </div>
                                    <div class="col-md-6">
                                        <table class="info-table">
                                            <tr><td>Количество</td><td>${item.quantity}</td></tr>
                                            <tr><td>Цена за единицу</td><td>${item.price}</td></tr>
                                            <tr><td>Общая стоимость</td><td>${item.sum}</td></tr>
                                        </table>
                                    </div>
                                </div>`;
                        
                        // Характеристики
                        if (item.characteristics && Object.keys(item.characteristics).length > 0) {
                            html += `
                            <h6 class="mt-3 mb-2">Характеристики:</h6>
                            <table class="characteristics-table table table-sm">
                                <thead>
                                    <tr>
                                        <th>Характеристика</th>
                                        <th>Значение</th>
                                    </tr>
                                </thead>
                                <tbody>`;
                            
                            Object.entries(item.characteristics).forEach(([charName, charValue]) => {
                                html += `<tr><td>${charName}</td><td>${charValue}</td></tr>`;
                            });
                            
                            html += `</tbody></table>`;
                        }
                        
                        html += `</div></div>`;
                    }
                });
                
                if (details.objectInfo.total) {
                    html += `<div class="alert alert-info mt-3">
                        <strong>Итого:</strong> ${details.objectInfo.total}
                    </div>`;
                }
                
                html += `</div></div>`;
            }
            
            // Требования и преимущества
            if (details.requirements && Object.keys(details.requirements).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-check-circle me-2"></i>Преимущества и требования к участникам
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.requirements).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Требования заказчика
            if (details.customerRequirements && Object.keys(details.customerRequirements).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-user-tie me-2"></i>Требования заказчика
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.customerRequirements).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Финансовая информация
            if (details.financialInfo && Object.keys(details.financialInfo).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-chart-line me-2"></i>Финансовая информация
                    </div>
                    <div class="detail-section-content">`;
                
                // Таблицы финансирования
                if (details.financialInfo.tables && Array.isArray(details.financialInfo.tables)) {
                    details.financialInfo.tables.forEach((table, index) => {
                        if (Array.isArray(table) && table.length > 0) {
                            html += `<table class="financial-table table table-striped">`;
                            
                            table.forEach((row, rowIndex) => {
                                if (Array.isArray(row)) {
                                    html += `<tr>`;
                                    row.forEach(cell => {
                                        const tag = rowIndex === 0 ? 'th' : 'td';
                                        html += `<${tag}>${cell}</${tag}>`;
                                    });
                                    html += `</tr>`;
                                }
                            });
                            
                            html += `</table>`;
                        }
                    });
                }
                
                // Дополнительная финансовая информация
                html += `<table class="info-table">`;
                Object.entries(details.financialInfo).forEach(([key, value]) => {
                    if (value && key !== 'tables') {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                html += `</table></div></div>`;
            }
            
            // Обеспечение заявки
            if (details.applicationSecurity && Object.keys(details.applicationSecurity).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-shield-alt me-2"></i>Обеспечение заявки
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.applicationSecurity).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Условия контракта
            if (details.contractConditions && Object.keys(details.contractConditions).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-file-contract me-2"></i>Условия контракта
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.contractConditions).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Обеспечение исполнения контракта
            if (details.contractSecurity && Object.keys(details.contractSecurity).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-lock me-2"></i>Обеспечение исполнения контракта
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.contractSecurity).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Банковское сопровождение
            if (details.bankingSupport && Object.keys(details.bankingSupport).length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-university me-2"></i>Банковское и казначейское сопровождение
                    </div>
                    <div class="detail-section-content">
                        <table class="info-table">`;
                
                Object.entries(details.bankingSupport).forEach(([key, value]) => {
                    if (value) {
                        const label = this.formatLabel(key);
                        html += `<tr><td>${label}</td><td>${value}</td></tr>`;
                    }
                });
                
                html += `</table></div></div>`;
            }
            
            // Документы
            if (details.documents && details.documents.length > 0) {
                html += `
                <div class="detail-section">
                    <div class="detail-section-header">
                        <i class="fas fa-file-pdf me-2"></i>Документы
                    </div>
                    <div class="detail-section-content">
                        <div class="d-flex flex-wrap gap-2">`;
                
                details.documents.forEach(doc => {
                    html += `<a href="${doc.url}" target="_blank" class="document-badge badge bg-success text-decoration-none">
                        <i class="fas fa-file-pdf me-1"></i> ${doc.fileName || 'Документ'}
                    </a>`;
                });
                
                html += `</div></div></div>`;
            }
            
            document.getElementById('results').innerHTML = html;
            document.getElementById('pagination').classList.add('d-none');
        }
        
        formatLabel(key) {
            return key.replace(/_/g, ' ')
                     .replace(/\b\w/g, l => l.toUpperCase())
                     .replace(/([a-z])([A-Z])/g, '$1 $2');
        }
        
        backToList() {
            this.searchTenders(this.currentPage);
        }
        
        showLoader(message = 'Загрузка данных...') {
            document.getElementById('results').innerHTML = `
                <div class="text-center py-5">
                    <div class="spinner-border text-primary loader" role="status">
                        <span class="visually-hidden">Загрузка...</span>
                    </div>
                    <p class="mt-3">${message}</p>
                </div>`;
                
            document.getElementById('pagination').classList.add('d-none');
        }
        
        showError(message) {
            document.getElementById('results').innerHTML = `
                <div class="alert alert-danger">
                    <h4><i class="fas fa-exclamation-circle me-2"></i> Ошибка</h4>
                    <p>${message}</p>
                    <button class="btn btn-secondary" onclick="app.searchTenders()">
                        <i class="fas fa-sync me-1"></i> Попробовать снова
                    </button>
                </div>`;
                
            document.getElementById('pagination').classList.add('d-none');
        }
    }

    // Инициализация приложения
    const app = new ZakupkiApp();
    
    // Обработка формы
    document.getElementById('searchForm').addEventListener('submit', function(e) {
        e.preventDefault();
        app.searchTenders();
    });
    </script>
</body>
</html>

Сгенерированный Результат

import React, { useState, useEffect } from 'react';
import './App.css';

const App = () => {
  const [tenders, setTenders] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);

  const fetchData = async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(`https://api.example.com/tenders?page=${page}`);
      if (!response.ok) throw new Error('Failed to fetch data');
      const data = await response.json();
      setTenders(data.tenders);
      setTotalPages(data.totalPages);
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, [page]);

  return (
    <div className="App">
      <header className="bg-blue-600 text-white py-4">
        <h1 className="text-3xl font-bold text-center">Tender Management System</h1>
      </header>

      <main className="container mx-auto px-4 py-8">
        <section className="mb-8">
          <h2 className="text-2xl font-semibold mb-4">Tenders List</h2>
          {loading ? (
            <div className="text-center">
              <div className="spinner-border" role="status">
                <span className="sr-only">Loading...</span>
              </div>
            </div>
          ) : error ? (
            <div className="alert alert-danger">{error}</div>
          ) : (
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
              {tenders.map((tender) => (
                <div key={tender.id} className="bg-white shadow-lg rounded-lg overflow-hidden hover:shadow-xl transition-shadow duration-300">
                  <div className="p-6">
                    <h3 className="text-xl font-bold mb-2">{tender.title}</h3>
                    <p className="text-gray-600 mb-4">{tender.description}</p>
                    <div className="flex justify-between items-center">
                      <span className="text-sm text-gray-500">Price: {tender.price}</span>
                      <a href={tender.link} className="text-blue-600 hover:underline">More Info</a>
                    </div>
                  </div>
                </div>
              ))}
            </div>
          )}
        </section>

        <section className="flex justify-center mt-8">
          <nav>
            <ul className="pagination flex space-x-2">
              {[...Array(totalPages).keys()].map((number) => (
                <li key={number + 1} className={`page-item ${page === number + 1 ? 'active' : ''}`}>
                  <button
                    onClick={() => setPage(number + 1)}
                    className="px-4 py-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
                  >
                    {number + 1}
                  </button>
                </li>
              ))}
            </ul>
          </nav>
        </section>
      </main>

      <footer className="bg-gray-800 text-white py-4 mt-8">
        <p className="text-center">© 2025 Tender Management System. All rights reserved.</p>
      </footer>
    </div>
  );
};

export default App;

Больше Рекомендаций

Посмотреть Все

Создание хакерского приложения

Программированиеru
4.0

Сделай "хакерское" приложение с успокаивающей музыкой на фоне и терминалом с зеленым текстом, к терминалу будет подключена нейросеть deepseek через api, сделай что бы приложение можно было вызвать комбинацией клавиш windows+shift+f, а так же сделай что бы в терминале (именно в самом терминале) можно было воспроизводить видео из ютуба, сделай что бы в приложении можно было вводить текст не только текстом но и голосом. Сделай что бы по приложению была инструкция на английском и русском языках (в самом приложении) а еще что бы было 2 страницы - главная (с терминалом) и дополнительная с кнопками которые вызывают команды из обычного терминала но только по нажатию. Добавь в терминал команды для управления системой, всего команд должно быть 200+, еще на 2 страница справа сделай анимацию ноутбука на экране которого бесконечно взламывается система

хакерское приложениенейросетьтерминал29.03.2025

передать форму в приложение

Программированиеru
4.0

передать содержимое формы в другое приложение

формаприложениепередача данных28.03.2025