Deploy on 2026-06-05 16:08:53
This commit is contained in:
@@ -20,9 +20,6 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, Response $response): string
|
public function index(Request $request, Response $response): string
|
||||||
{
|
{
|
||||||
$user = $request->routeParam('_authenticated_user');
|
|
||||||
$lang = $this->session->get('lang', 'en');
|
|
||||||
|
|
||||||
// Real stats from database
|
// Real stats from database
|
||||||
$orgCount = (int)$this->pdo->query("SELECT COUNT(*) FROM organizations WHERE deleted_at IS NULL")->fetchColumn();
|
$orgCount = (int)$this->pdo->query("SELECT COUNT(*) FROM organizations WHERE deleted_at IS NULL")->fetchColumn();
|
||||||
$vcCount = (int)$this->pdo->query("SELECT COUNT(*) FROM organizations WHERE type='vc' AND deleted_at IS NULL")->fetchColumn();
|
$vcCount = (int)$this->pdo->query("SELECT COUNT(*) FROM organizations WHERE type='vc' AND deleted_at IS NULL")->fetchColumn();
|
||||||
@@ -31,6 +28,8 @@ class DashboardController extends Controller
|
|||||||
$contactCount = (int)$this->pdo->query("SELECT COUNT(*) FROM contacts WHERE deleted_at IS NULL")->fetchColumn();
|
$contactCount = (int)$this->pdo->query("SELECT COUNT(*) FROM contacts WHERE deleted_at IS NULL")->fetchColumn();
|
||||||
$sourceCount = (int)$this->pdo->query("SELECT COUNT(*) FROM sources WHERE status='active'")->fetchColumn();
|
$sourceCount = (int)$this->pdo->query("SELECT COUNT(*) FROM sources WHERE status='active'")->fetchColumn();
|
||||||
$todayOpps = (int)$this->pdo->query("SELECT COUNT(*) FROM opportunities WHERE DATE(created_at) = CURDATE()")->fetchColumn();
|
$todayOpps = (int)$this->pdo->query("SELECT COUNT(*) FROM opportunities WHERE DATE(created_at) = CURDATE()")->fetchColumn();
|
||||||
|
$todayContacts = (int)$this->pdo->query("SELECT COUNT(*) FROM contacts WHERE DATE(created_at) = CURDATE() AND deleted_at IS NULL")->fetchColumn();
|
||||||
|
$todayOrgs = (int)$this->pdo->query("SELECT COUNT(*) FROM organizations WHERE DATE(created_at) = CURDATE() AND deleted_at IS NULL")->fetchColumn();
|
||||||
|
|
||||||
// Recent opportunities
|
// Recent opportunities
|
||||||
$stmt = $this->pdo->query(
|
$stmt = $this->pdo->query(
|
||||||
@@ -50,14 +49,45 @@ class DashboardController extends Controller
|
|||||||
$stmt = $this->pdo->query("SELECT * FROM activity_logs ORDER BY created_at DESC LIMIT 10");
|
$stmt = $this->pdo->query("SELECT * FROM activity_logs ORDER BY created_at DESC LIMIT 10");
|
||||||
$recentActivities = $stmt->fetchAll() ?: [];
|
$recentActivities = $stmt->fetchAll() ?: [];
|
||||||
|
|
||||||
$langFile = __DIR__ . "/../../resources/lang/{$lang}.php";
|
// 6-month growth data
|
||||||
$t = file_exists($langFile) ? require $langFile : [];
|
$months = [];
|
||||||
|
$opportunityMonthly = [];
|
||||||
|
$interactionMonthly = [];
|
||||||
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
|
$date = new \DateTime("-$i months");
|
||||||
|
$key = $date->format('Y-m');
|
||||||
|
$name = $date->format('M');
|
||||||
|
$months[$key] = $name;
|
||||||
|
$opportunityMonthly[$key] = 0;
|
||||||
|
$interactionMonthly[$key] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oppsQuery = $this->pdo->query(
|
||||||
|
"SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count
|
||||||
|
FROM opportunities
|
||||||
|
WHERE deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)
|
||||||
|
GROUP BY month"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
||||||
|
|
||||||
|
$interQuery = $this->pdo->query(
|
||||||
|
"SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count
|
||||||
|
FROM interactions
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH)
|
||||||
|
GROUP BY month"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
||||||
|
|
||||||
|
foreach ($oppsQuery as $monthKey => $count) {
|
||||||
|
if (isset($opportunityMonthly[$monthKey])) {
|
||||||
|
$opportunityMonthly[$monthKey] = (int)$count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($interQuery as $monthKey => $count) {
|
||||||
|
if (isset($interactionMonthly[$monthKey])) {
|
||||||
|
$interactionMonthly[$monthKey] = (int)$count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->render('admin/dashboard', [
|
return $this->render('admin/dashboard', [
|
||||||
'user' => $user,
|
|
||||||
'title' => $t['dashboard'] ?? 'Dashboard',
|
|
||||||
't' => $t,
|
|
||||||
'lang' => $lang,
|
|
||||||
'stats' => [
|
'stats' => [
|
||||||
'organizations' => $orgCount,
|
'organizations' => $orgCount,
|
||||||
'vc' => $vcCount,
|
'vc' => $vcCount,
|
||||||
@@ -66,10 +96,15 @@ class DashboardController extends Controller
|
|||||||
'contacts' => $contactCount,
|
'contacts' => $contactCount,
|
||||||
'sources' => $sourceCount,
|
'sources' => $sourceCount,
|
||||||
'today' => $todayOpps,
|
'today' => $todayOpps,
|
||||||
|
'today_contacts' => $todayContacts,
|
||||||
|
'today_orgs' => $todayOrgs,
|
||||||
],
|
],
|
||||||
'recent_opportunities' => $recentOpps,
|
'recent_opportunities' => $recentOpps,
|
||||||
'opportunities_by_type' => $byType,
|
'opportunities_by_type' => $byType,
|
||||||
'recent_activities' => $recentActivities,
|
'recent_activities' => $recentActivities,
|
||||||
|
'growth_labels' => array_values($months),
|
||||||
|
'growth_opportunities' => array_values($opportunityMonthly),
|
||||||
|
'growth_interactions' => array_values($interactionMonthly),
|
||||||
], 'admin');
|
], 'admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,18 +24,12 @@ class SettingsController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, Response $response): string
|
public function index(Request $request, Response $response): string
|
||||||
{
|
{
|
||||||
$lang = $this->session->get('lang', 'en');
|
|
||||||
$langFile = __DIR__ . "/../../resources/lang/{$lang}.php";
|
|
||||||
$t = file_exists($langFile) ? require $langFile : [];
|
|
||||||
|
|
||||||
// Get telegram settings from database
|
// Get telegram settings from database
|
||||||
$tgToken = $this->getSetting('telegram_bot_token', '');
|
$tgToken = $this->getSetting('telegram_bot_token', '');
|
||||||
$tgChatId = $this->getSetting('telegram_chat_id', '');
|
$tgChatId = $this->getSetting('telegram_chat_id', '');
|
||||||
$tgEnabled = $this->getSetting('telegram_enabled', '0');
|
$tgEnabled = $this->getSetting('telegram_enabled', '0');
|
||||||
|
|
||||||
return $this->render('admin/settings/index', [
|
return $this->render('admin/settings/index', [
|
||||||
't' => $t,
|
|
||||||
'lang' => $lang,
|
|
||||||
'tg_token' => $tgToken,
|
'tg_token' => $tgToken,
|
||||||
'tg_chat_id' => $tgChatId,
|
'tg_chat_id' => $tgChatId,
|
||||||
'tg_enabled' => $tgEnabled,
|
'tg_enabled' => $tgEnabled,
|
||||||
@@ -47,33 +41,29 @@ class SettingsController extends Controller
|
|||||||
$tgToken = $request->post('telegram_bot_token', '');
|
$tgToken = $request->post('telegram_bot_token', '');
|
||||||
$tgChatId = $request->post('telegram_chat_id', '');
|
$tgChatId = $request->post('telegram_chat_id', '');
|
||||||
$tgEnabled = $request->post('telegram_enabled', '0');
|
$tgEnabled = $request->post('telegram_enabled', '0');
|
||||||
|
$action = $request->post('action', 'save');
|
||||||
|
|
||||||
$this->saveSetting('telegram_bot_token', $tgToken);
|
$this->saveSetting('telegram_bot_token', $tgToken);
|
||||||
$this->saveSetting('telegram_chat_id', $tgChatId);
|
$this->saveSetting('telegram_chat_id', $tgChatId);
|
||||||
$this->saveSetting('telegram_enabled', $tgEnabled);
|
$this->saveSetting('telegram_enabled', $tgEnabled);
|
||||||
|
|
||||||
$this->session->setFlash('success', 'Settings saved successfully.');
|
if ($action === 'test') {
|
||||||
$response->redirect('/admin/settings');
|
$this->notifier->configure($tgToken, $tgChatId);
|
||||||
}
|
if ($this->notifier->sendTest()) {
|
||||||
|
$this->session->setFlash('success', 'Settings saved and test notification sent to Telegram!');
|
||||||
public function testTelegram(Request $request, Response $response): void
|
} else {
|
||||||
{
|
$err = $this->notifier->getLastError() ?: 'Check your token and chat ID.';
|
||||||
$tgToken = $request->post('telegram_bot_token', '');
|
$this->session->setFlash('error', 'Settings saved, but failed to send Telegram notification: ' . $err);
|
||||||
$tgChatId = $request->post('telegram_chat_id', '');
|
}
|
||||||
|
|
||||||
$this->notifier->configure($tgToken, $tgChatId);
|
|
||||||
|
|
||||||
if ($this->notifier->sendTest()) {
|
|
||||||
$this->session->setFlash('success', 'Test notification sent to Telegram!');
|
|
||||||
} else {
|
} else {
|
||||||
$this->session->setFlash('error', 'Failed to send Telegram notification. Check your token and chat ID.');
|
$this->session->setFlash('success', 'Settings saved successfully.');
|
||||||
}
|
}
|
||||||
$response->redirect('/admin/settings');
|
$response->redirect('/admin/settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function switchLang(Request $request, Response $response): void
|
public function switchLang(Request $request, Response $response, string $lang = 'en'): void
|
||||||
{
|
{
|
||||||
$lang = $request->get('lang', 'en');
|
$lang = $request->routeParam('lang', $lang);
|
||||||
if (in_array($lang, ['ar', 'en'])) {
|
if (in_array($lang, ['ar', 'en'])) {
|
||||||
$this->session->set('lang', $lang);
|
$this->session->set('lang', $lang);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ abstract class Controller
|
|||||||
throw new \Exception("View template {$view} not found.");
|
throw new \Exception("View template {$view} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically inject current language, translation array, and authenticated user
|
||||||
|
$lang = $this->session->get('lang', 'en');
|
||||||
|
$langFile = __DIR__ . "/../../resources/lang/{$lang}.php";
|
||||||
|
$t = file_exists($langFile) ? require $langFile : [];
|
||||||
|
$user = \App\Core\App::$app->request->routeParam('_authenticated_user');
|
||||||
|
|
||||||
|
$data = array_merge([
|
||||||
|
'lang' => $lang,
|
||||||
|
't' => $t,
|
||||||
|
'user' => $user,
|
||||||
|
], $data);
|
||||||
|
|
||||||
// Extract variables to local scope
|
// Extract variables to local scope
|
||||||
extract($data);
|
extract($data);
|
||||||
|
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ class App
|
|||||||
$this->request->setRouteParams($params);
|
$this->request->setRouteParams($params);
|
||||||
|
|
||||||
// Run Middleware Chain
|
// Run Middleware Chain
|
||||||
$this->executeMiddlewareChain($middlewares, function() use ($callback) {
|
$this->executeMiddlewareChain($middlewares, function() use ($callback, $params) {
|
||||||
// Execute Route action
|
// Execute Route action
|
||||||
if (is_callable($callback)) {
|
if (is_callable($callback)) {
|
||||||
$response = $callback($this->request, $this->response);
|
$response = call_user_func_array($callback, array_merge([$this->request, $this->response], array_values($params)));
|
||||||
} else {
|
} else {
|
||||||
[$controllerClass, $method] = $callback;
|
[$controllerClass, $method] = $callback;
|
||||||
$controller = $this->container->get($controllerClass);
|
$controller = $this->container->get($controllerClass);
|
||||||
$response = $controller->$method($this->request, $this->response);
|
$args = array_merge([$this->request, $this->response], array_values($params));
|
||||||
|
$response = call_user_func_array([$controller, $method], $args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-output string responses as HTML
|
// Auto-output string responses as HTML
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AiAnalyzer
|
|||||||
{
|
{
|
||||||
$config = require __DIR__ . '/../../../config/ai.php';
|
$config = require __DIR__ . '/../../../config/ai.php';
|
||||||
$this->apiKey = $config['gemini']['api_key'] ?? null;
|
$this->apiKey = $config['gemini']['api_key'] ?? null;
|
||||||
$this->model = $config['gemini']['model'] ?? 'gemini-1.5-flash-latest';
|
$this->model = $config['gemini']['model'] ?? 'gemini-flash-lite-latest';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services\Crawler;
|
|||||||
|
|
||||||
use App\Services\Database\Connection;
|
use App\Services\Database\Connection;
|
||||||
use App\Services\Database\ActivityLogger;
|
use App\Services\Database\ActivityLogger;
|
||||||
|
use App\Services\Notification\TelegramNotifier;
|
||||||
use PDO;
|
use PDO;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -13,17 +14,20 @@ class Collector
|
|||||||
private RssParser $rssParser;
|
private RssParser $rssParser;
|
||||||
private AiAnalyzer $aiAnalyzer;
|
private AiAnalyzer $aiAnalyzer;
|
||||||
private ActivityLogger $logger;
|
private ActivityLogger $logger;
|
||||||
|
private TelegramNotifier $notifier;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Connection $connection,
|
Connection $connection,
|
||||||
RssParser $rssParser,
|
RssParser $rssParser,
|
||||||
AiAnalyzer $aiAnalyzer,
|
AiAnalyzer $aiAnalyzer,
|
||||||
ActivityLogger $logger
|
ActivityLogger $logger,
|
||||||
|
TelegramNotifier $notifier
|
||||||
) {
|
) {
|
||||||
$this->pdo = $connection->getPdo();
|
$this->pdo = $connection->getPdo();
|
||||||
$this->rssParser = $rssParser;
|
$this->rssParser = $rssParser;
|
||||||
$this->aiAnalyzer = $aiAnalyzer;
|
$this->aiAnalyzer = $aiAnalyzer;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->notifier = $notifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +80,12 @@ class Collector
|
|||||||
'new_organizations' => $results['new_organizations'],
|
'new_organizations' => $results['new_organizations'],
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
// Send Telegram notification if enabled
|
||||||
|
if ($this->getSetting('telegram_enabled') === '1') {
|
||||||
|
$this->notifier->loadSettings();
|
||||||
|
$this->notifier->notifyCollectorResults($results);
|
||||||
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +213,25 @@ class Collector
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger Telegram notification if enabled
|
||||||
|
if ($this->getSetting('telegram_enabled') === '1') {
|
||||||
|
$orgName = '';
|
||||||
|
if ($orgId) {
|
||||||
|
$orgStmt = $this->pdo->prepare("SELECT name FROM organizations WHERE id = ?");
|
||||||
|
$orgStmt->execute([$orgId]);
|
||||||
|
$orgName = $orgStmt->fetchColumn() ?: '';
|
||||||
|
}
|
||||||
|
$this->notifier->loadSettings();
|
||||||
|
$this->notifier->notifyNewOpportunity([
|
||||||
|
'title' => $entry['title'],
|
||||||
|
'type' => $analysis['opportunity_type'] ?? $analysis['type'] ?? 'other',
|
||||||
|
'score' => $score,
|
||||||
|
'url' => $entry['url'],
|
||||||
|
'description' => $analysis['summary'] ?? $entry['description'],
|
||||||
|
'org_name' => $orgName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Log but don't fail
|
// Log but don't fail
|
||||||
}
|
}
|
||||||
@@ -246,4 +275,19 @@ class Collector
|
|||||||
);
|
);
|
||||||
return $stmt->fetchAll() ?: [];
|
return $stmt->fetchAll() ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting by key from the database.
|
||||||
|
*/
|
||||||
|
private function getSetting(string $key): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT `value` FROM settings WHERE `key` = ?");
|
||||||
|
$stmt->execute([$key]);
|
||||||
|
$val = $stmt->fetchColumn();
|
||||||
|
return $val !== false ? $val : null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,21 +2,49 @@
|
|||||||
|
|
||||||
namespace App\Services\Notification;
|
namespace App\Services\Notification;
|
||||||
|
|
||||||
|
use App\Services\Database\Connection;
|
||||||
|
use PDO;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class TelegramNotifier
|
class TelegramNotifier
|
||||||
{
|
{
|
||||||
private ?string $botToken;
|
private PDO $pdo;
|
||||||
private ?string $chatId;
|
private ?string $botToken = null;
|
||||||
|
private ?string $chatId = null;
|
||||||
|
private ?string $lastError = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct(Connection $connection)
|
||||||
{
|
{
|
||||||
$this->botToken = $_ENV['TELEGRAM_BOT_TOKEN'] ?? null;
|
$this->pdo = $connection->getPdo();
|
||||||
$this->chatId = $_ENV['TELEGRAM_CHAT_ID'] ?? null;
|
$this->loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure from database settings.
|
* Load settings from database settings table.
|
||||||
|
*/
|
||||||
|
public function loadSettings(): void
|
||||||
|
{
|
||||||
|
$this->botToken = $this->getSetting('telegram_bot_token');
|
||||||
|
$this->chatId = $this->getSetting('telegram_chat_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to retrieve setting by key.
|
||||||
|
*/
|
||||||
|
private function getSetting(string $key): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT `value` FROM settings WHERE `key` = ?");
|
||||||
|
$stmt->execute([$key]);
|
||||||
|
$val = $stmt->fetchColumn();
|
||||||
|
return $val !== false ? $val : null;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure manually (for testing unsaved parameters).
|
||||||
*/
|
*/
|
||||||
public function configure(string $botToken, string $chatId): void
|
public function configure(string $botToken, string $chatId): void
|
||||||
{
|
{
|
||||||
@@ -24,12 +52,23 @@ class TelegramNotifier
|
|||||||
$this->chatId = $chatId;
|
$this->chatId = $chatId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the last HTTP or API error message.
|
||||||
|
*/
|
||||||
|
public function getLastError(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastError;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a notification to Telegram.
|
* Send a notification to Telegram.
|
||||||
*/
|
*/
|
||||||
public function send(string $message, string $type = 'info'): bool
|
public function send(string $message, string $type = 'info'): bool
|
||||||
{
|
{
|
||||||
|
$this->lastError = null;
|
||||||
|
|
||||||
if (!$this->botToken || !$this->chatId) {
|
if (!$this->botToken || !$this->chatId) {
|
||||||
|
$this->lastError = "Telegram token or chat ID is missing.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,19 +93,62 @@ class TelegramNotifier
|
|||||||
'disable_web_page_preview' => true,
|
'disable_web_page_preview' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$context = stream_context_create([
|
if (function_exists('curl_init')) {
|
||||||
'http' => [
|
$ch = curl_init();
|
||||||
'method' => 'POST',
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
'header' => "Content-Type: application/json\r\n",
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
'content' => $payload,
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
'timeout' => 10,
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||||
],
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
]);
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
|
||||||
$response = @file_get_contents($url, false, $context);
|
$response = curl_exec($ch);
|
||||||
return $response !== false;
|
if ($response === false) {
|
||||||
|
$this->lastError = "Connection error: " . curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
$resDecoded = json_decode($response, true);
|
||||||
|
$this->lastError = $resDecoded['description'] ?? "API error HTTP {$httpCode}";
|
||||||
|
error_log("Telegram API returned code {$httpCode}: {$response}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\n",
|
||||||
|
'content' => $payload,
|
||||||
|
'timeout' => 10,
|
||||||
|
],
|
||||||
|
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $context);
|
||||||
|
if ($response === false) {
|
||||||
|
$error = error_get_last();
|
||||||
|
$this->lastError = $error['message'] ?? "Connection timed out.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resDecoded = json_decode($response, true);
|
||||||
|
if (($resDecoded['ok'] ?? false) === false) {
|
||||||
|
$this->lastError = $resDecoded['description'] ?? "API error";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
$this->lastError = $e->getMessage();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ $app->router->group([
|
|||||||
// Settings
|
// Settings
|
||||||
$r->get('/settings', [SettingsController::class, 'index']);
|
$r->get('/settings', [SettingsController::class, 'index']);
|
||||||
$r->post('/settings/save', [SettingsController::class, 'save']);
|
$r->post('/settings/save', [SettingsController::class, 'save']);
|
||||||
$r->post('/settings/test-telegram', [SettingsController::class, 'testTelegram']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout endpoint
|
// Logout endpoint
|
||||||
|
|||||||
@@ -71,4 +71,18 @@ return [
|
|||||||
'language' => 'اللغة',
|
'language' => 'اللغة',
|
||||||
'arabic' => 'العربية',
|
'arabic' => 'العربية',
|
||||||
'english' => 'English',
|
'english' => 'English',
|
||||||
|
'title' => 'العنوان',
|
||||||
|
'grant' => 'المنح',
|
||||||
|
'competition' => 'المسابقات',
|
||||||
|
'demo_day' => 'أيام العروض',
|
||||||
|
'event' => 'الفعاليات',
|
||||||
|
'partnership' => 'الشراكات',
|
||||||
|
'investment' => 'الاستثمارات',
|
||||||
|
'other' => 'أخرى',
|
||||||
|
'ingested_opportunities' => 'الفرص المجمعة',
|
||||||
|
'interactions_logged' => 'التفاعلات المسجلة',
|
||||||
|
'today_contacts' => 'جهات اتصال مضافة اليوم',
|
||||||
|
'today_orgs' => 'منظمات جديدة',
|
||||||
|
'recent_activities' => 'آخر النشاطات',
|
||||||
|
'recent_opportunities' => 'آخر الفرص',
|
||||||
];
|
];
|
||||||
@@ -71,4 +71,18 @@ return [
|
|||||||
'language' => 'Language',
|
'language' => 'Language',
|
||||||
'arabic' => 'العربية',
|
'arabic' => 'العربية',
|
||||||
'english' => 'English',
|
'english' => 'English',
|
||||||
|
'title' => 'Title',
|
||||||
|
'grant' => 'Grants',
|
||||||
|
'competition' => 'Competitions',
|
||||||
|
'demo_day' => 'Demo Days',
|
||||||
|
'event' => 'Events',
|
||||||
|
'partnership' => 'Partnerships',
|
||||||
|
'investment' => 'Investments',
|
||||||
|
'other' => 'Other',
|
||||||
|
'ingested_opportunities' => 'Ingested Opportunities',
|
||||||
|
'interactions_logged' => 'Interactions Logged',
|
||||||
|
'today_contacts' => 'Contacts Added Today',
|
||||||
|
'today_orgs' => 'new organizations',
|
||||||
|
'recent_activities' => 'Recent Activities',
|
||||||
|
'recent_opportunities' => 'Recent Opportunities',
|
||||||
];
|
];
|
||||||
@@ -1,143 +1,107 @@
|
|||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<h1>Welcome, <?= $this->escape($user['name']) ?></h1>
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap;">
|
||||||
<p>Here is your daily intelligence summary for ScoutIQ.</p>
|
<div>
|
||||||
|
<h1><?= $t['dashboard'] ?? 'Dashboard' ?></h1>
|
||||||
|
<p><?= $t['welcome'] ?? 'Welcome' ?>, <?= $this->escape($user['name']) ?></p>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/sources" class="btn btn-primary"><?= $t['run_collector'] ?? 'Run Collector' ?></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metrics Row -->
|
<!-- Metrics Row -->
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<div class="glass-panel metric-card">
|
<div class="glass-panel metric-card">
|
||||||
<span class="metric-title">Total Investors</span>
|
<span class="metric-title"><?= $t['total_organizations'] ?? 'Organizations' ?></span>
|
||||||
<span class="metric-value">142</span>
|
<span class="metric-value"><?= $stats['organizations'] ?></span>
|
||||||
<span class="metric-footer">
|
<span class="metric-footer">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||||
<span>+12% vs last month</span>
|
<span><?= $stats['vc'] ?> VC • <?= $stats['accelerators'] ?> Accelerators</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass-panel metric-card">
|
<div class="glass-panel metric-card">
|
||||||
<span class="metric-title">Total Accelerators</span>
|
<span class="metric-title"><?= $t['total_opportunities'] ?? 'Opportunities' ?></span>
|
||||||
<span class="metric-value">38</span>
|
<span class="metric-value"><?= $stats['opportunities'] ?></span>
|
||||||
<span class="metric-footer">
|
<span class="metric-footer">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||||
<span>+5% vs last month</span>
|
<span><?= $stats['today'] ?> new today</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass-panel metric-card">
|
<div class="glass-panel metric-card">
|
||||||
<span class="metric-title">Open Opportunities</span>
|
<span class="metric-title"><?= $t['total_contacts'] ?? 'Contacts' ?></span>
|
||||||
<span class="metric-value">29</span>
|
<span class="metric-value"><?= $stats['contacts'] ?></span>
|
||||||
<span class="metric-footer" style="color: var(--warning);">
|
<span class="metric-footer">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||||
<span>4 closing this week</span>
|
<span><?= $t['view_all'] ?? 'View all' ?></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass-panel metric-card">
|
<div class="glass-panel metric-card">
|
||||||
<span class="metric-title">Contacts Added Today</span>
|
<span class="metric-title"><?= $t['active_sources'] ?? 'Sources' ?></span>
|
||||||
<span class="metric-value">7</span>
|
<span class="metric-value"><?= $stats['sources'] ?></span>
|
||||||
<span class="metric-footer">
|
<span class="metric-footer">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
<span>+3 new organizations</span>
|
<span>RSS feeds active</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts Row -->
|
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
|
<!-- Recent Opportunities -->
|
||||||
<div class="glass-panel chart-card">
|
<div class="glass-panel chart-card">
|
||||||
<span class="chart-title">Opportunities by Category</span>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<div style="flex: 1; position: relative;">
|
<span class="chart-title"><?= $t['recent_opportunities'] ?? 'Recent Opportunities' ?></span>
|
||||||
<canvas id="categoryChart"></canvas>
|
<a href="/admin/opportunities" class="btn btn-sm btn-secondary"><?= $t['view_all'] ?? 'View All' ?></a>
|
||||||
|
</div>
|
||||||
|
<div style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<?php if (empty($recent_opportunities)): ?>
|
||||||
|
<p style="color: var(--text-muted); text-align: center; padding: 40px;">No opportunities yet. Run the collector to start gathering data.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($recent_opportunities as $opp): ?>
|
||||||
|
<div class="list-item">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 10px;">
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<a href="/admin/opportunities/<?= $opp['id'] ?>" style="font-weight: 600;"><?= $this->escape(mb_substr($opp['title'], 0, 80)) ?></a>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap;">
|
||||||
|
<span class="badge badge-type-<?= $opp['type'] ?>"><?= $this->escape($opp['type']) ?></span>
|
||||||
|
<span style="font-size: 0.8rem; color: var(--text-muted);">Score: <?= $opp['score'] ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small style="color: var(--text-muted); white-space: nowrap;"><?= date('M j', strtotime($opp['created_at'])) ?></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activities -->
|
||||||
<div class="glass-panel chart-card">
|
<div class="glass-panel chart-card">
|
||||||
<span class="chart-title">Monthly Growth & Ingestion</span>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<div style="flex: 1; position: relative;">
|
<span class="chart-title"><?= $t['recent_activities'] ?? 'Recent Activity' ?></span>
|
||||||
<canvas id="growthChart"></canvas>
|
</div>
|
||||||
|
<div style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<?php if (empty($recent_activities)): ?>
|
||||||
|
<p style="color: var(--text-muted); text-align: center; padding: 40px;">No recent activity.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($recent_activities as $activity): ?>
|
||||||
|
<div class="list-item">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<span class="badge" style="background: rgba(139, 92, 246, 0.2); color: #a78bfa;"><?= $this->escape($activity['action'] ?? 'info') ?></span>
|
||||||
|
<span style="font-size: 0.85rem; color: var(--text-muted); margin-left: 8px;"><?= $this->escape(mb_substr($activity['description'] ?? '', 0, 100)) ?></span>
|
||||||
|
</div>
|
||||||
|
<small style="color: var(--text-muted); white-space: nowrap;"><?= date('M j, g:i a', strtotime($activity['created_at'])) ?></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<style>
|
||||||
// Category Chart (Doughnut)
|
.list-item { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||||
const ctxCategory = document.getElementById('categoryChart').getContext('2d');
|
.list-item:last-child { border-bottom: none; }
|
||||||
new Chart(ctxCategory, {
|
</style>
|
||||||
type: 'doughnut',
|
|
||||||
data: {
|
|
||||||
labels: ['Mobility & Logistics', 'AI & Automation', 'SaaS', 'Fintech', 'Marketplaces'],
|
|
||||||
datasets: [{
|
|
||||||
data: [35, 25, 20, 12, 8],
|
|
||||||
backgroundColor: [
|
|
||||||
'hsl(263, 90%, 60%)',
|
|
||||||
'hsl(220, 95%, 50%)',
|
|
||||||
'hsl(180, 100%, 40%)',
|
|
||||||
'hsl(142, 70%, 45%)',
|
|
||||||
'hsl(38, 92%, 50%)'
|
|
||||||
],
|
|
||||||
borderWidth: 0
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
color: 'hsl(215, 20%, 75%)',
|
|
||||||
font: { family: 'Inter', size: 12 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Growth Chart (Line)
|
|
||||||
const ctxGrowth = document.getElementById('growthChart').getContext('2d');
|
|
||||||
new Chart(ctxGrowth, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Ingested Opportunities',
|
|
||||||
data: [12, 19, 32, 45, 68, 96],
|
|
||||||
borderColor: 'hsl(180, 100%, 50%)',
|
|
||||||
backgroundColor: 'rgba(0, 242, 254, 0.1)',
|
|
||||||
borderWidth: 3,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}, {
|
|
||||||
label: 'Interactions Logged',
|
|
||||||
data: [5, 12, 18, 25, 30, 48],
|
|
||||||
borderColor: 'hsl(263, 90%, 60%)',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 3,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
labels: {
|
|
||||||
color: 'hsl(215, 20%, 75%)',
|
|
||||||
font: { family: 'Inter', size: 12 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
||||||
ticks: { color: 'hsl(215, 20%, 70%)' }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
||||||
ticks: { color: 'hsl(215, 20%, 70%)' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -33,15 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap;">
|
||||||
<button type="submit" name="action" value="save" class="btn btn-primary"><?= $t['save'] ?? 'Save' ?></button>
|
<button type="submit" name="action" value="save" class="btn btn-primary"><?= $t['save'] ?? 'Save' ?></button>
|
||||||
|
<button type="submit" name="action" value="test" class="btn btn-secondary"><?= $t['test_notification'] ?? 'Send Test' ?></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form action="/admin/settings/test-telegram" method="POST" style="margin-top: 12px;">
|
|
||||||
<input type="hidden" name="_csrf" value="<?= $this->session->getCsrfToken() ?>">
|
|
||||||
<input type="hidden" name="telegram_bot_token" value="<?= $this->escape($tg_token) ?>">
|
|
||||||
<input type="hidden" name="telegram_chat_id" value="<?= $this->escape($tg_chat_id) ?>">
|
|
||||||
<button type="submit" class="btn btn-secondary"><?= $t['test_notification'] ?? 'Send Test' ?></button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Info -->
|
<!-- Help Info -->
|
||||||
|
|||||||
Reference in New Issue
Block a user