DeepSeek ArtifactsDeepSeek Artifacts

Госзакупки: парсер 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/[email protected]/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/[email protected]/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/[email protected]/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/[email protected]/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;

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

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

创建名片网站

4.0
网页设计
ru
名片网站
网站制作
在线名片
напиши сайт визитку ...

Создать читательский дневник

4.0
Образование
ru
читательский дневник
русский язык
литература
создать читательский дневник русском языке ...

Создать читательский дневник

4.0
Образование
ru
читательский дневник
книги
литература
заметки
чтение
создать читательский дневник ...

在线巴士票务系统

4.0
交通与物流
ru
巴士票务
在线购票
票务系统
Создать систему по поиску автобусных билетов и продажу билетов онлайн...

Умный кулинарный помощник

4.0
Технологии и кулинария
ru
рецепты
кулинария
список покупок
• Идея: "Умный кулинарный помощник" • Описание: Веб-приложение, которое помогает пользователям с рецептами. Основная фишка – не просто база рецептов, а возможность масштабировать ингредиенты (например...

Духовная психология

4.0
Психология
ru
духовная психология
йога
буддизм
психология
духовные практики
сгенерируй лендинг используя следующий текст для наполнения - Духовная психология - про что это? Я выбрала это название для очень обширной сферы своей деятельности, которой не просто подобрать конк...